Feat: Shipping functionalities redesign, elta courier integration, commenting out acs since no integration can happen, flow updates

This commit is contained in:
2026-09-30 17:28:47 +03:00
parent f411dec1da
commit 49ec4333af
63 changed files with 3143 additions and 347 deletions
@@ -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,
};