Feature: Order Updates, Events, Order Flows, Shipment And COD support

This commit is contained in:
2026-09-14 00:03:06 +03:00
parent 78bbd8390a
commit 44c6b7defd
73 changed files with 2854 additions and 464 deletions
@@ -14,6 +14,7 @@ use Modules\Core\Shipping\DTOs\ManifestResult;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Manifest;
use Modules\Core\Shipping\Models\Shipment;
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
@@ -88,7 +89,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
public function cancelShipment(Shipment $shipment): void
{
if ($shipment->manifest_reference) {
if ($shipment->manifest_id) {
throw new RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
}
@@ -103,7 +104,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
{
return Shipment::query()
->where('carrier', 'acs')
->whereNull('manifest_reference')
->whereNull('manifest_id')
->whereNull('cancelled_at')
->get();
}
@@ -123,11 +124,18 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
$pickupListNo = (string) $response->valueOutput['PickupList_No'];
$manifest = Manifest::create([
'carrier' => 'acs',
'reference' => $pickupListNo,
'shipment_count' => $shipments->count(),
'issued_at' => now(),
]);
$shipments->each(fn (Shipment $shipment) => $shipment->update([
'manifest_reference' => $pickupListNo,
'manifest_id' => $manifest->id,
]));
return ManifestResult::success($pickupListNo, $shipments);
return ManifestResult::success($manifest, $shipments);
}
public function trackShipment(Shipment $shipment): Collection
@@ -176,6 +184,11 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
{
$action = strtolower($action);
// TODO: no live ACS payload sample yet showing a distinct
// collection checkpoint separate from transit ("arrival"/
// "departure" already map to InTransit) — add a str_contains()
// arm mapping to TrackingStatus::CollectedFromSender here once
// one is confirmed.
return match (true) {
str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered,
str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery,
+2 -21
View File
@@ -11,6 +11,7 @@ use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
{
@@ -103,26 +104,6 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
private function totalWeightInKg($cart): float
{
$weight = 0.0;
foreach ($cart->lines->load('purchasable') as $line) {
$variant = $line->purchasable;
if (! $variant || ! $variant->weight_value) {
continue;
}
$unit = $variant->weight_unit ?? 'kg';
$value = (float) $variant->weight_value;
$weight += match ($unit) {
'g' => $value / 1000,
'lb' => $value * 0.45359237,
'oz' => $value * 0.0283495231,
default => $value, // kg
} * $line->quantity;
}
return max($weight, 0.5); // ACS minimum billable weight
return max(WeightCalculator::totalKg($cart->lines), 0.5); // ACS minimum billable weight
}
}
@@ -21,10 +21,21 @@ use Modules\Core\Shipping\Models\Shipment;
* 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).
* (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
{
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
@@ -36,6 +47,10 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
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';
$response = $this->client->request('post', '/delivery-requests', [
@@ -57,31 +72,37 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId,
],
'items' => [
[
'id' => (string) $order->id,
'name' => 'Order '.$order->reference,
'value' => '0.00',
'compartmentSize' => 1,
'weight' => $request->weight ?? 0,
],
],
'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(),
]);
$parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException(
'Box Now delivery request succeeded but returned no parcel id.',
$response,
));
$parcels = collect($response['parcels'] ?? []);
return Shipment::create([
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.
$shipments = $parcels->map(fn (array $parcel) => Shipment::create([
'order_id' => $order->id,
'carrier' => 'box-now',
'tracking_reference' => $parcelId,
'tracking_reference' => (string) $parcel['id'],
'meta' => [
'delivery_request_id' => $response['id'] ?? null,
'locker_id' => $destinationLocationId,
],
]);
]));
return $shipments->first();
}
public function printLabel(Shipment $shipment): string
@@ -136,6 +157,13 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
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,