Feature: Shipment Updates to handle COD
This commit is contained in:
@@ -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', [
|
||||||
|
|||||||
@@ -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,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,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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user