Feat: Shipping functionalities redesign, elta courier integration, commenting out acs since no integration can happen, flow updates
This commit is contained in:
@@ -2,12 +2,16 @@
|
||||
|
||||
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;
|
||||
@@ -32,7 +36,7 @@ use Modules\Core\Shipping\Models\Shipment;
|
||||
* sends that many entries in a single delivery request rather than
|
||||
* several separate ones.
|
||||
*/
|
||||
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
|
||||
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
|
||||
{
|
||||
private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
|
||||
|
||||
@@ -53,8 +57,21 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
|
||||
$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' => $order->reference.'-'.$order->id,
|
||||
'orderNumber' => $orderNumber,
|
||||
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
|
||||
'paymentMode' => $isCod ? 'cod' : 'prepaid',
|
||||
'amountToBeCollected' => $isCod
|
||||
@@ -92,13 +109,18 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
// 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([
|
||||
// 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,
|
||||
'locker_id' => $destinationLocationId,
|
||||
'cod_amount' => $index === 0 ? $codAmount : null,
|
||||
],
|
||||
]));
|
||||
|
||||
@@ -186,6 +208,91 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -201,7 +308,7 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
|
||||
'delivered' => TrackingStatus::Delivered,
|
||||
'returned', 'accepted-for-return' => TrackingStatus::Returned,
|
||||
'cancelled' => TrackingStatus::Cancelled,
|
||||
'cancelled', 'canceled' => TrackingStatus::Cancelled,
|
||||
'expired-return', 'missing' => TrackingStatus::Failed,
|
||||
default => TrackingStatus::Unknown,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user