318 lines
13 KiB
PHP
318 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
|
|
|
use Carbon\CarbonInterface;
|
|
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\Contracts\SupportsVoucherListing;
|
|
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
|
|
use Modules\Core\Shipping\DTOs\CarrierVoucher;
|
|
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
|
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
|
|
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
|
|
* 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 — 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.
|
|
*/
|
|
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
|
|
{
|
|
private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
|
|
|
|
public function __construct(private readonly BoxNowClient $client) {}
|
|
|
|
public function createShipment(Order $order, ShipmentRequest $request): Shipment
|
|
{
|
|
$address = $order->shippingAddress;
|
|
$destinationLocationId = $request->destinationLocationId;
|
|
|
|
if (! $destinationLocationId) {
|
|
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
|
|
}
|
|
|
|
if (empty($request->boxes)) {
|
|
throw new BoxNowApiException('At least one box (compartment size) is required for a Box Now shipment.');
|
|
}
|
|
|
|
$isCod = $request->paymentMode === 'cod';
|
|
|
|
// Box Now rejects an orderNumber it has seen before (P410), even
|
|
// for a cancelled request — so a re-created shipment after a cancel
|
|
// gets "-2", "-3", …; the first attempt keeps "{reference}-{id}".
|
|
$previousRequests = Shipment::where('order_id', $order->id)
|
|
->where('carrier', 'box-now')
|
|
->where('source', Shipment::SOURCE_CREATED)
|
|
->get()
|
|
->map(fn (Shipment $shipment) => $shipment->meta['delivery_request_id'] ?? $shipment->id)
|
|
->unique()
|
|
->count();
|
|
|
|
$orderNumber = $order->reference.'-'.$order->id.($previousRequests > 0 ? '-'.($previousRequests + 1) : '');
|
|
|
|
$response = $this->client->request('post', '/delivery-requests', [
|
|
'orderNumber' => $orderNumber,
|
|
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
|
|
'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' => $this->internationalPhone($address->contact_phone),
|
|
'contactEmail' => $address->contact_email,
|
|
'contactName' => trim("{$address->first_name} {$address->last_name}"),
|
|
'locationId' => $destinationLocationId,
|
|
],
|
|
'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(),
|
|
]);
|
|
|
|
$parcels = collect($response['parcels'] ?? []);
|
|
|
|
if ($parcels->isEmpty()) {
|
|
throw new BoxNowApiException('Box Now delivery request succeeded but returned no parcel ids.', $response);
|
|
}
|
|
|
|
// 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.
|
|
// The cash is collected once for the whole request, so only the
|
|
// first parcel carries cod_amount.
|
|
$codAmount = $isCod ? (float) ($request->amountToCollect ?? $order->total->decimal) : null;
|
|
|
|
$shipments = $parcels->values()->map(fn (array $parcel, int $index) => Shipment::create([
|
|
'order_id' => $order->id,
|
|
'carrier' => 'box-now',
|
|
'tracking_reference' => (string) $parcel['id'],
|
|
'meta' => [
|
|
'delivery_request_id' => $response['id'] ?? null,
|
|
'reference' => $orderNumber,
|
|
'locker_id' => $destinationLocationId,
|
|
'cod_amount' => $index === 0 ? $codAmount : null,
|
|
],
|
|
]));
|
|
|
|
return $shipments->first();
|
|
}
|
|
|
|
/**
|
|
* Box Now rejects any contactNumber not in full international format
|
|
* (error P405 — confirmed in practice: a plain Greek mobile like
|
|
* "6955994563" 400s with {"code":"P405"}). Checkout collects phone
|
|
* numbers in local format, with no international-format enforcement of
|
|
* its own — this store is Greece-only (see CheckoutController::
|
|
* STORE_COUNTRY_ISO3), so a bare local number is assumed Greek and
|
|
* prefixed accordingly, same convention ACS's own sender config already
|
|
* uses (config('boxnow.sender.phone') is documented as "+30..." there
|
|
* too). A number already carrying a country code (leading "+" or "00")
|
|
* is passed through unchanged.
|
|
*/
|
|
private function internationalPhone(?string $phone): ?string
|
|
{
|
|
if ($phone === null) {
|
|
return null;
|
|
}
|
|
|
|
$digitsOnly = preg_replace('/[^\d+]/', '', $phone);
|
|
|
|
if (str_starts_with($digitsOnly, '+')) {
|
|
return $digitsOnly;
|
|
}
|
|
|
|
if (str_starts_with($digitsOnly, '00')) {
|
|
return '+'.substr($digitsOnly, 2);
|
|
}
|
|
|
|
return '+30'.ltrim($digitsOnly, '0');
|
|
}
|
|
|
|
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()]);
|
|
}
|
|
|
|
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,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Every parcel on the account (GET /parcels, 100 per page). Box Now has
|
|
* no date filter, so pages are read until a whole page is older than
|
|
* $from — its order isn't documented, so one old parcel alone doesn't
|
|
* stop the scan.
|
|
*/
|
|
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable
|
|
{
|
|
$pageToken = null;
|
|
|
|
do {
|
|
$response = $this->client->request('get', '/parcels', array_filter([
|
|
'limit' => 100,
|
|
'pageToken' => $pageToken,
|
|
]));
|
|
|
|
$parcels = $response['data'] ?? [];
|
|
$anyInRange = false;
|
|
|
|
foreach ($parcels as $parcel) {
|
|
$created = Carbon::parse($parcel['createTime'] ?? 'now');
|
|
|
|
if ($created->lt($from)) {
|
|
continue;
|
|
}
|
|
|
|
$anyInRange = true;
|
|
|
|
if ($created->lte($to)) {
|
|
yield $this->voucherFromParcel($parcel);
|
|
}
|
|
}
|
|
|
|
$pageToken = $this->nextPageToken($response['pagination']['next'] ?? null);
|
|
} while ($pageToken && $anyInRange && $parcels !== []);
|
|
}
|
|
|
|
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher
|
|
{
|
|
$parcel = $this->client->request('get', '/parcels', ['parcelId' => trim($voucherNumber)])['data'][0] ?? null;
|
|
|
|
return $parcel ? $this->voucherFromParcel($parcel) : null;
|
|
}
|
|
|
|
private function voucherFromParcel(array $parcel): CarrierVoucher
|
|
{
|
|
$request = $parcel['deliveryRequest'] ?? [];
|
|
$destination = $request['destination'] ?? [];
|
|
$state = $parcel['state'] ?? null;
|
|
|
|
return new CarrierVoucher(
|
|
carrier: 'box-now',
|
|
voucherNumber: (string) $parcel['id'],
|
|
reference: $request['orderNumber'] ?? null,
|
|
recipientName: $destination['contactName'] ?? null,
|
|
postcode: $destination['postalCode'] ?? ($destination['address']['postalCode'] ?? null),
|
|
phone: $destination['contactNumber'] ?? null,
|
|
statusText: $state,
|
|
status: $state ? $this->mapState($state) : null,
|
|
codAmount: ($request['paymentMode'] ?? null) === 'cod' ? ((float) ($request['amountToBeCollected'] ?? 0) ?: null) : null,
|
|
isReturn: in_array($state, ['returned', 'expired-return', 'accepted-for-return'], true),
|
|
date: isset($parcel['createTime']) ? Carbon::parse($parcel['createTime']) : null,
|
|
raw: $parcel,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* pagination.next is documented only by name — accept either the bare
|
|
* token or a URL carrying it as ?pageToken=.
|
|
*/
|
|
private function nextPageToken(mixed $next): ?string
|
|
{
|
|
if (blank($next) || ! is_string($next)) {
|
|
return null;
|
|
}
|
|
|
|
if (str_contains($next, 'pageToken=')) {
|
|
parse_str((string) parse_url($next, PHP_URL_QUERY), $query);
|
|
|
|
return $query['pageToken'] ?? null;
|
|
}
|
|
|
|
return $next;
|
|
}
|
|
|
|
private function mapState(string $state): TrackingStatus
|
|
{
|
|
// 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.
|
|
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', 'canceled' => TrackingStatus::Cancelled,
|
|
'expired-return', 'missing' => TrackingStatus::Failed,
|
|
default => TrackingStatus::Unknown,
|
|
};
|
|
}
|
|
}
|