Files
core/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php
T

151 lines
5.7 KiB
PHP
Raw Normal View History

<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
2026-07-19 18:27:24 +03:00
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;
2026-07-19 18:27:24 +03:00
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
2026-07-19 18:27:24 +03:00
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
/**
* Unlike ACS, Box Now has no manifest/pickup-list step — creating a
* delivery request also books the courier pickup, so this only implements
* CarrierFulfillmentInterface (not SupportsManifestBatching).
*
* Box Now delivers to lockers, not addresses. The storefront locker-picker
2026-07-19 18:27:24 +03:00
* 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).
*/
2026-07-19 18:27:24 +03:00
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
{
public function __construct(private readonly BoxNowClient $client) {}
2026-07-19 18:27:24 +03:00
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
2026-07-19 18:27:24 +03:00
$destinationLocationId = $request->destinationLocationId;
if (! $destinationLocationId) {
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
}
2026-07-19 18:27:24 +03:00
$isCod = $request->paymentMode === 'cod';
$response = $this->client->request('post', '/delivery-requests', [
'orderNumber' => $order->reference.'-'.$order->id,
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
2026-07-19 18:27:24 +03: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'),
'contactName' => config('boxnow.sender.name'),
'locationId' => config('boxnow.origin_location_id'),
],
'destination' => [
'contactNumber' => $address->contact_phone,
'contactEmail' => $address->contact_email,
'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId,
],
'items' => [
[
'id' => (string) $order->id,
'name' => 'Order '.$order->reference,
'value' => '0.00',
2026-07-19 18:27:24 +03:00
'compartmentSize' => 1,
'weight' => $request->weight ?? 0,
],
],
]);
$parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException(
'Box Now delivery request succeeded but returned no parcel id.',
$response,
));
return Shipment::create([
'order_id' => $order->id,
'carrier' => 'box-now',
'tracking_reference' => $parcelId,
'meta' => [
'delivery_request_id' => $response['id'] ?? null,
'locker_id' => $destinationLocationId,
],
]);
}
public function printLabel(Shipment $shipment): string
{
$bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf");
$shipment->update(['label_printed_at' => now()]);
return $bytes;
}
public function cancelShipment(Shipment $shipment): void
{
$this->client->request('post', "/parcels/{$shipment->tracking_reference}:cancel");
$shipment->update(['cancelled_at' => now()]);
}
2026-07-19 18:27:24 +03:00
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,
};
}
}