2026-07-19 00:51:23 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
|
|
|
|
|
2026-07-19 18:27:24 +03:00
|
|
|
use Illuminate\Support\Carbon;
|
|
|
|
|
use Illuminate\Support\Collection;
|
2026-07-19 00:51:23 +03:00
|
|
|
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;
|
2026-09-03 15:58:51 +03:00
|
|
|
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;
|
2026-07-19 00:51:23 +03:00
|
|
|
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
|
2026-09-14 00:03:06 +03:00
|
|
|
* (e.g. set manually by admin staff until checkout UI exists — see
|
|
|
|
|
* Modules\Core\Shipping\Extensions\OrderViewExtension, which locks the
|
|
|
|
|
* field instead once the shopper's own checkout selection is present in
|
|
|
|
|
* $order->shippingAddress->meta['box_now_locker']).
|
|
|
|
|
*
|
|
|
|
|
* Box Now ships by compartment size, not weight — unlike ACS, which bills
|
|
|
|
|
* by kg. One 'items' entry per box in ShipmentRequest::$boxes, so an order
|
|
|
|
|
* needing more than one physical parcel (doesn't fit one compartment)
|
|
|
|
|
* sends that many entries in a single delivery request rather than
|
|
|
|
|
* several separate ones.
|
2026-07-19 00:51:23 +03:00
|
|
|
*/
|
2026-07-19 18:27:24 +03:00
|
|
|
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
|
2026-07-19 00:51:23 +03:00
|
|
|
{
|
2026-09-14 00:03:06 +03:00
|
|
|
private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
|
|
|
|
|
|
2026-07-19 00:51:23 +03:00
|
|
|
public function __construct(private readonly BoxNowClient $client) {}
|
|
|
|
|
|
2026-07-19 18:27:24 +03:00
|
|
|
public function createShipment(Order $order, ShipmentRequest $request): Shipment
|
2026-07-19 00:51:23 +03:00
|
|
|
{
|
|
|
|
|
$address = $order->shippingAddress;
|
2026-07-19 18:27:24 +03:00
|
|
|
$destinationLocationId = $request->destinationLocationId;
|
2026-07-19 00:51:23 +03:00
|
|
|
|
|
|
|
|
if (! $destinationLocationId) {
|
|
|
|
|
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 00:03:06 +03:00
|
|
|
if (empty($request->boxes)) {
|
|
|
|
|
throw new BoxNowApiException('At least one box (compartment size) is required for a Box Now shipment.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 18:27:24 +03:00
|
|
|
$isCod = $request->paymentMode === 'cod';
|
|
|
|
|
|
2026-07-19 00:51:23 +03:00
|
|
|
$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',
|
2026-07-19 00:51:23 +03: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,
|
|
|
|
|
],
|
2026-09-14 00:03:06 +03:00
|
|
|
'items' => collect($request->boxes)->values()->map(fn (string $size, int $index) => [
|
|
|
|
|
'id' => $order->id.'-'.($index + 1),
|
|
|
|
|
'name' => 'Order '.$order->reference.' (box '.($index + 1).')',
|
|
|
|
|
'value' => '0.00',
|
|
|
|
|
'compartmentSize' => self::COMPARTMENT_SIZES[$size] ?? self::COMPARTMENT_SIZES['S'],
|
|
|
|
|
])->all(),
|
2026-07-19 00:51:23 +03:00
|
|
|
]);
|
|
|
|
|
|
2026-09-14 00:03:06 +03:00
|
|
|
$parcels = collect($response['parcels'] ?? []);
|
|
|
|
|
|
|
|
|
|
if ($parcels->isEmpty()) {
|
|
|
|
|
throw new BoxNowApiException('Box Now delivery request succeeded but returned no parcel ids.', $response);
|
|
|
|
|
}
|
2026-07-19 00:51:23 +03:00
|
|
|
|
2026-09-14 00:03:06 +03:00
|
|
|
// One Shipment row per box/parcel — each is independently
|
|
|
|
|
// trackable/printable/cancellable via its own tracking_reference
|
|
|
|
|
// (printLabel()/cancelShipment()/trackShipment() below already
|
|
|
|
|
// operate per-Shipment), even though all boxes were submitted in
|
|
|
|
|
// one delivery request. Siblings are linked via the shared
|
|
|
|
|
// delivery_request_id in meta.
|
|
|
|
|
$shipments = $parcels->map(fn (array $parcel) => Shipment::create([
|
2026-07-19 00:51:23 +03:00
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'carrier' => 'box-now',
|
2026-09-14 00:03:06 +03:00
|
|
|
'tracking_reference' => (string) $parcel['id'],
|
2026-07-19 00:51:23 +03:00
|
|
|
'meta' => [
|
|
|
|
|
'delivery_request_id' => $response['id'] ?? null,
|
|
|
|
|
'locker_id' => $destinationLocationId,
|
|
|
|
|
],
|
2026-09-14 00:03:06 +03:00
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
return $shipments->first();
|
2026-07-19 00:51:23 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
{
|
2026-09-14 00:03:06 +03:00
|
|
|
// TODO: confirm against a live BoxNow webhook payload whether a
|
|
|
|
|
// distinct collected-from-sender state exists (e.g. between 'new'
|
|
|
|
|
// and 'in-transit') before mapping it to
|
|
|
|
|
// TrackingStatus::CollectedFromSender — BoxNow's own model is
|
|
|
|
|
// locker-drop-off-based, so it may not have one. No guessed match
|
|
|
|
|
// arm added; 'new' still falls through to Pending, InTransit
|
|
|
|
|
// remains the earliest recognized checkpoint.
|
2026-07-19 18:27:24 +03:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-07-19 00:51:23 +03:00
|
|
|
}
|