From 9f30a7324e594849d396874328e41489a68a2b8a Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 18:27:24 +0300 Subject: [PATCH] Feature: Shipment Updates to handle COD --- .../Carriers/Acs/AcsFulfillmentService.php | 85 +++++++++++++++++-- .../BoxNow/BoxNowFulfillmentService.php | 78 ++++++++++++++--- .../Contracts/CarrierFulfillmentInterface.php | 3 +- .../DataTransferObjects/ShipmentRequest.php | 20 +++++ .../Extensions/OrderViewExtension.php | 19 ++++- 5 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 src/Shipping/DataTransferObjects/ShipmentRequest.php diff --git a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php index 1635408..a40b9fb 100644 --- a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php +++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php @@ -2,26 +2,33 @@ namespace Modules\Core\Shipping\Carriers\Acs; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Lunar\Models\Order; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; 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\ShipmentRequest; +use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint; +use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Models\Shipment; -class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching +class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking { public function __construct( private readonly AcsClient $client, private readonly AreaResolver $areaResolver, ) {} - public function createShipment(Order $order, array $overrides = []): Shipment + public function createShipment(Order $order, ShipmentRequest $request): Shipment { $address = $order->shippingAddress; $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(), 'Sender' => config('acs.sender.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, 'Billing_Code' => config('acs.billing_code'), 'Charge_Type' => 2, - 'Item_Quantity' => 1, - 'Weight' => 0.5, - ], $overrides))->throwIfError(); + 'Item_Quantity' => $request->packageCount, + 'Weight' => $weight, + ]; + + 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']; @@ -45,12 +60,12 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani 'tracking_reference' => $voucherNo, 'meta' => [ 'station_destination' => $destination->stationId, - 'weight' => $overrides['Weight'] ?? 0.5, + 'weight' => $weight, 'pickup_date' => now()->toDateString(), ], ]); - if (($overrides['Item_Quantity'] ?? 1) > 1) { + if ($request->packageCount > 1) { $this->persistMultipartVouchers($shipment); } @@ -114,6 +129,60 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani 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 { $response = $this->client->call('ACS_Get_Multipart_Vouchers', [ diff --git a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php index 05266c5..ea32ea7 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php @@ -2,9 +2,15 @@ namespace Modules\Core\Shipping\Carriers\BoxNow; +use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Lunar\Models\Order; use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException; 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; /** @@ -13,28 +19,32 @@ use Modules\Core\Shipping\Models\Shipment; * CarrierFulfillmentInterface (not SupportsManifestBatching). * * Box Now delivers to lockers, not addresses. The storefront locker-picker - * is out of scope for this pass — createShipment() expects the chosen - * locker's Box Now locationId via $overrides['locationId'] (e.g. set - * manually by admin staff until checkout UI exists). + * is out of scope for this pass — createShipment() requires the chosen + * locker's Box Now locationId via ShipmentRequest::$destinationLocationId + * (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 createShipment(Order $order, array $overrides = []): Shipment + public function createShipment(Order $order, ShipmentRequest $request): Shipment { $address = $order->shippingAddress; - $destinationLocationId = $overrides['locationId'] ?? null; + $destinationLocationId = $request->destinationLocationId; if (! $destinationLocationId) { throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.'); } + $isCod = $request->paymentMode === 'cod'; + $response = $this->client->request('post', '/delivery-requests', [ 'orderNumber' => $order->reference.'-'.$order->id, 'invoiceValue' => number_format($order->total->decimal, 2, '.', ''), - 'paymentMode' => 'prepaid', - 'amountToBeCollected' => '0.00', + 'paymentMode' => $isCod ? 'cod' : 'prepaid', + 'amountToBeCollected' => $isCod + ? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '') + : '0.00', 'origin' => [ 'contactNumber' => config('boxnow.sender.phone'), 'contactEmail' => config('boxnow.sender.email'), @@ -52,8 +62,8 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface 'id' => (string) $order->id, 'name' => 'Order '.$order->reference, 'value' => '0.00', - 'compartmentSize' => $overrides['compartmentSize'] ?? 1, - 'weight' => $overrides['weight'] ?? 0, + 'compartmentSize' => 1, + 'weight' => $request->weight ?? 0, ], ], ]); @@ -89,4 +99,52 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface $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, + }; + } } diff --git a/src/Shipping/Contracts/CarrierFulfillmentInterface.php b/src/Shipping/Contracts/CarrierFulfillmentInterface.php index 68f1b30..d76d8d4 100644 --- a/src/Shipping/Contracts/CarrierFulfillmentInterface.php +++ b/src/Shipping/Contracts/CarrierFulfillmentInterface.php @@ -3,6 +3,7 @@ namespace Modules\Core\Shipping\Contracts; use Lunar\Models\Order; +use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; use Modules\Core\Shipping\Models\Shipment; interface CarrierFulfillmentInterface @@ -10,7 +11,7 @@ interface CarrierFulfillmentInterface /** * 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). diff --git a/src/Shipping/DataTransferObjects/ShipmentRequest.php b/src/Shipping/DataTransferObjects/ShipmentRequest.php new file mode 100644 index 0000000..f63b4a0 --- /dev/null +++ b/src/Shipping/DataTransferObjects/ShipmentRequest.php @@ -0,0 +1,20 @@ +icon('heroicon-o-truck') ->modalSubmitActionLabel('Create Shipment') ->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') ->label('Confirm') ->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); if (! $service) { @@ -53,8 +63,13 @@ class OrderViewExtension extends ViewPageExtension return; } + $request = new ShipmentRequest( + weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, + destinationLocationId: $data['destination_location_id'] ?? null, + ); + try { - $service->createShipment($record); + $service->createShipment($record, $request); } catch (\Throwable $e) { report($e);