4 Commits

Author SHA1 Message Date
arvanitakis 772c008949 Feature: Wire up carrier-agnostic shipping admin UI
Registers ShippingServiceProvider (carrier config, driver/fulfillment bindings, Rates page override) and wires the shipping admin surface into CorePlugin: dynamic carrier dropdown on Shipping Method create/edit, a "Create Shipment" order action resolved generically by carrier, and a Pickup Manifests page for carriers that support manifest batching.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 00:52:01 +03:00
arvanitakis 77dbbd63a4 Feature: Add Box Now locker delivery integration
Box Now rate driver (fixed pricing only — no live pricing API) and fulfillment service (delivery request creation, label printing, cancellation). Unlike ACS, Box Now books courier pickup automatically on delivery request creation, so no manifest/pickup-list step is implemented. Storefront locker selection is not yet built; createShipment() expects the chosen locker's locationId to be supplied by the caller.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 00:51:23 +03:00
arvanitakis ca5c826849 Feature: Add ACS courier integration
ACS rate driver (live price quotes via ACS_Price_Calculation, cached postcode-to-station lookups) and fulfillment service (voucher creation, label printing, end-of-day pickup manifest). Adds a per-rate pricing_mode column so admins can choose live API pricing vs. a fixed price on ACS-driven shipping rates, surfaced via a custom Rates page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 00:50:51 +03:00
arvanitakis 851bdc6031 Feature: Add ManifestResult DTO for carrier manifest batching
Completes the carrier-agnostic shipping abstraction (CarrierFulfillmentInterface, SupportsManifestBatching, Shipment model) with the result type SupportsManifestBatching::issueManifest() returns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 00:50:15 +03:00
20 changed files with 176 additions and 605 deletions
@@ -0,0 +1,26 @@
<?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');
});
}
};
@@ -1,28 +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
{
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,7 +19,6 @@ 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
@@ -74,10 +73,6 @@ 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,33 +2,26 @@
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, SupportsTracking class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching
{ {
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, ShipmentRequest $request): Shipment public function createShipment(Order $order, array $overrides = []): Shipment
{ {
$address = $order->shippingAddress; $address = $order->shippingAddress;
$destination = $this->areaResolver->resolve($address->postcode); $destination = $this->areaResolver->resolve($address->postcode);
$weight = $request->weight ?? 0.5;
$params = [ $response = $this->client->call('ACS_Create_Voucher', array_merge([
'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}"),
@@ -40,17 +33,9 @@ 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' => $request->packageCount, 'Item_Quantity' => 1,
'Weight' => $weight, 'Weight' => 0.5,
]; ], $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'];
@@ -60,12 +45,12 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
'tracking_reference' => $voucherNo, 'tracking_reference' => $voucherNo,
'meta' => [ 'meta' => [
'station_destination' => $destination->stationId, 'station_destination' => $destination->stationId,
'weight' => $weight, 'weight' => $overrides['Weight'] ?? 0.5,
'pickup_date' => now()->toDateString(), 'pickup_date' => now()->toDateString(),
], ],
]); ]);
if ($request->packageCount > 1) { if (($overrides['Item_Quantity'] ?? 1) > 1) {
$this->persistMultipartVouchers($shipment); $this->persistMultipartVouchers($shipment);
} }
@@ -129,60 +114,6 @@ 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', [
+25 -7
View File
@@ -4,17 +4,15 @@ 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(
@@ -38,19 +36,39 @@ 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,15 +2,9 @@
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;
/** /**
@@ -19,32 +13,28 @@ 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() requires the chosen * is out of scope for this pass — createShipment() expects the chosen
* locker's Box Now locationId via ShipmentRequest::$destinationLocationId * locker's Box Now locationId via $overrides['locationId'] (e.g. set
* (e.g. set manually by admin staff until checkout UI exists). * manually by admin staff until checkout UI exists).
*/ */
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking class BoxNowFulfillmentService implements CarrierFulfillmentInterface
{ {
public function __construct(private readonly BoxNowClient $client) {} public function __construct(private readonly BoxNowClient $client) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment public function createShipment(Order $order, array $overrides = []): Shipment
{ {
$address = $order->shippingAddress; $address = $order->shippingAddress;
$destinationLocationId = $request->destinationLocationId; $destinationLocationId = $overrides['locationId'] ?? null;
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' => $isCod ? 'cod' : 'prepaid', 'paymentMode' => 'prepaid',
'amountToBeCollected' => $isCod 'amountToBeCollected' => '0.00',
? 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'),
@@ -62,8 +52,8 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
'id' => (string) $order->id, 'id' => (string) $order->id,
'name' => 'Order '.$order->reference, 'name' => 'Order '.$order->reference,
'value' => '0.00', 'value' => '0.00',
'compartmentSize' => 1, 'compartmentSize' => $overrides['compartmentSize'] ?? 1,
'weight' => $request->weight ?? 0, 'weight' => $overrides['weight'] ?? 0,
], ],
], ],
]); ]);
@@ -99,52 +89,4 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
$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,21 +3,19 @@
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 method's normal * Box Now has no pricing API, so this always resolves the admin-configured
* charge_by + price-break configuration — the same mechanism the built-in * price/price-breaks on the ShippingRate — the same mechanism the built-in
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing: * flat-rate driver uses. Unlike AcsRateDriver, this does not implement
* there is no live option to offer. * SupportsLivePricing: there is nothing to toggle between.
*/ */
class BoxNowRateDriver implements ShippingRateInterface class BoxNowRateDriver implements ShippingRateInterface
{ {
use ResolvesFixedPricing;
public ShippingRate $shippingRate; public ShippingRate $shippingRate;
public function name(): string public function name(): string
@@ -32,10 +30,25 @@ class BoxNowRateDriver implements ShippingRateInterface
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{ {
return $this->resolveFixedPrice( $shippingRate = $shippingOptionRequest->shippingRate;
$shippingOptionRequest->shippingRate, $shippingMethod = $shippingRate->shippingMethod;
$shippingOptionRequest->shippingRate->shippingMethod, $cart = $shippingOptionRequest->cart;
$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(),
); );
} }
@@ -1,42 +0,0 @@
<?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,7 +3,6 @@
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
@@ -11,7 +10,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, ShipmentRequest $request): Shipment; public function createShipment(Order $order, array $overrides = []): Shipment;
/** /**
* Fetch the printable label for a shipment (raw file bytes). * Fetch the printable label for a shipment (raw file bytes).
@@ -1,23 +0,0 @@
<?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;
}
@@ -1,20 +0,0 @@
<?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,
) {}
}
@@ -1,22 +0,0 @@
<?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 = [],
) {}
}
-29
View File
@@ -1,29 +0,0 @@
<?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,
};
}
}
@@ -1,17 +0,0 @@
<?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) {}
}
+2 -17
View File
@@ -9,7 +9,6 @@ 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
{ {
@@ -27,15 +26,6 @@ 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.')
@@ -49,7 +39,7 @@ class OrderViewExtension extends ViewPageExtension
}, },
]), ]),
]) ])
->action(function (Order $record, array $data, Actions\Action $action) { ->action(function (Order $record, Actions\Action $action) {
$service = $this->resolveFulfillmentService($record); $service = $this->resolveFulfillmentService($record);
if (! $service) { if (! $service) {
@@ -63,13 +53,8 @@ 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, $request); $service->createShipment($record);
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
@@ -6,88 +6,20 @@ 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->replaceChargeByField( $this->replaceDriverField($form->getComponents())
$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(
@@ -146,7 +78,6 @@ 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,13 +2,16 @@
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\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates; use Lunar\Shipping\Facades\Shipping;
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
@@ -17,13 +20,11 @@ use Lunar\Shipping\Models\ShippingRate;
* 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.
* *
* Hides the price / price-break fields for a rate whose method has * Adds a per-rate "Pricing" toggle (live API vs. fixed price) for methods
* charge_by = "live" (see ShippingMethodResourceExtension, which adds that * whose driver supports live pricing (see SupportsLivePricing). Rates on
* option to methods whose driver supports live pricing) — those fields * methods without live pricing behave exactly as the vendor page always did
* would otherwise be dead configuration the driver never reads. Pricing * — no toggle shown, price fields always required, vendor save logic used
* strategy (cart_total / weight / live) stays entirely on the Shipping * as-is.
* 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
{ {
@@ -32,13 +33,56 @@ class ManageShippingRates extends BaseManageShippingRates
$form = parent::form($form); $form = parent::form($form);
return $form->schema( return $form->schema(
$this->hidePriceFieldsWhenLive($form->getComponents()) $this->insertPricingModeFieldAfterShippingMethod(
$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
{ {
$isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live'; $isFixedOrNotLiveCapable = fn (Get $get) => ! static::methodHasLivePricing($get('shipping_method_id'))
|| $get('pricing_mode') === 'fixed';
foreach ($components as $component) { foreach ($components as $component) {
if (! method_exists($component, 'getName')) { if (! method_exists($component, 'getName')) {
@@ -46,11 +90,14 @@ class ManageShippingRates extends BaseManageShippingRates
} }
if ($component->getName() === 'price') { if ($component->getName() === 'price') {
$component->visible($isNotLive)->required($isNotLive)->dehydrated(true); $component->visible($isFixedOrNotLiveCapable)
->required($isFixedOrNotLiveCapable)
->dehydrated(true);
} }
if ($component->getName() === 'prices') { if ($component->getName() === 'prices') {
$component->visible($isNotLive)->dehydrated(true); $component->visible($isFixedOrNotLiveCapable)
->dehydrated(true);
} }
} }
@@ -67,7 +114,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::methodChargeBy($record->shipping_method_id) === 'live') { if (static::methodHasLivePricing($record->shipping_method_id) && $record->pricing_mode === 'live') {
return 'Live API pricing'; return 'Live API pricing';
} }
@@ -82,23 +129,37 @@ class ManageShippingRates extends BaseManageShippingRates
protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void
{ {
if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') { $isLive = static::methodHasLivePricing($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id)
&& ($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 methodChargeBy(ShippingMethod|int|string|null $method): ?string protected static function methodHasLivePricing(ShippingMethod|int|string|null $method): bool
{ {
if (blank($method)) { if (blank($method)) {
return null; return false;
} }
if (! $method instanceof ShippingMethod) { if (! $method instanceof ShippingMethod) {
$method = ShippingMethod::find($method); $method = ShippingMethod::find($method);
} }
return $method?->data['charge_by'] ?? null; if (! $method) {
return false;
}
try {
return Shipping::driver($method->driver) instanceof SupportsLivePricing;
} catch (\InvalidArgumentException) {
return false;
}
} }
} }
@@ -1,107 +0,0 @@
<?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]);
}
}
-11
View File
@@ -5,7 +5,6 @@ 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
@@ -22,14 +21,4 @@ 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();
}
} }
-31
View File
@@ -1,31 +0,0 @@
<?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);
}
}