Feature: Order Updates, Events, Order Flows, Shipment And COD support
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,24 +3,26 @@
|
||||
namespace Modules\Core\Shipping\DTOs;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Core\Shipping\Models\Manifest;
|
||||
|
||||
class ManifestResult
|
||||
{
|
||||
private function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly ?string $reference,
|
||||
public readonly ?Manifest $manifest,
|
||||
public readonly Collection $includedShipments,
|
||||
public readonly Collection $blockedShipments,
|
||||
public readonly ?string $reason,
|
||||
) {}
|
||||
|
||||
public static function success(string $reference, Collection $includedShipments): self
|
||||
public static function success(Manifest $manifest, Collection $includedShipments): self
|
||||
{
|
||||
return new self(true, $reference, $includedShipments, collect(), null);
|
||||
return new self(true, $manifest->reference, $manifest, $includedShipments, collect(), null);
|
||||
}
|
||||
|
||||
public static function blocked(Collection $blockedShipments, string $reason): self
|
||||
{
|
||||
return new self(false, null, collect(), $blockedShipments, $reason);
|
||||
return new self(false, null, null, collect(), $blockedShipments, $reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,27 @@ namespace Modules\Core\Shipping\DTOs;
|
||||
/**
|
||||
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
|
||||
* Every field is optional — a carrier reads only what it needs and ignores
|
||||
* the rest (e.g. destinationLocationId only matters to locker-delivery
|
||||
* carriers like Box Now; ACS has no use for it).
|
||||
* the rest (e.g. destinationLocationId/boxes only matter to locker-delivery
|
||||
* carriers like Box Now; ACS has no use for either — it ships by weight,
|
||||
* not by box/compartment size).
|
||||
*/
|
||||
class ShipmentRequest
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $boxes Box Now only — one entry per
|
||||
* physical parcel, each a compartment size ('S'|'M'|'L'). A single
|
||||
* shipment can be split across several lockers of the same
|
||||
* destinationLocationId's collection point, e.g. two Large boxes for
|
||||
* an order that doesn't fit one compartment. Empty for every other
|
||||
* carrier, which ships as a single package described by $weight
|
||||
* instead.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?float $weight = null,
|
||||
public readonly int $packageCount = 1,
|
||||
public readonly ?string $destinationLocationId = null,
|
||||
public readonly ?string $paymentMode = null,
|
||||
public readonly ?float $amountToCollect = null,
|
||||
public readonly array $boxes = [],
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,25 @@ namespace Modules\Core\Shipping\Enums;
|
||||
enum TrackingStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
|
||||
/**
|
||||
* The carrier collected the parcel from the merchant — deliberately
|
||||
* NOT named "PickedUp" to avoid colliding with the unrelated
|
||||
* order status 'picked_up' (Modules\Core\Order\Services\
|
||||
* OrderStatusFlow), which means the opposite end of a different flow
|
||||
* (a CUSTOMER collecting a store-pickup order). Consumed by
|
||||
* Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint.
|
||||
*
|
||||
* Not yet mapped from either carrier driver's own checkpoint data —
|
||||
* see Carriers\BoxNow\BoxNowFulfillmentService::mapState() and
|
||||
* Carriers\Acs\AcsFulfillmentService::guessStatusFromAction() for
|
||||
* TODOs on confirming against real payload samples before adding a
|
||||
* mapping. Until then this case exists but nothing produces it, and
|
||||
* InTransit remains the effective first real checkpoint for both
|
||||
* carriers.
|
||||
*/
|
||||
case CollectedFromSender = 'collected_from_sender';
|
||||
|
||||
case InTransit = 'in_transit';
|
||||
case OutForDelivery = 'out_for_delivery';
|
||||
case Delivered = 'delivered';
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Extensions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Lunar\Admin\Support\Extending\ViewPageExtension;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Adds a "Shipments" section to the order page's main column — previously
|
||||
* "Create Shipment" (Modules\Core\Shipping\Extensions\OrderViewExtension)
|
||||
* had no counterpart anywhere on the order to actually SEE what it
|
||||
* created (carrier, tracking reference, current status, whether a label's
|
||||
* been printed or the shipment cancelled). One row per Shipment record —
|
||||
* a Box Now order with several boxes shows one row per box/parcel (see
|
||||
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService, which
|
||||
* creates one Shipment row per parcel Box Now returns), not one row per
|
||||
* "Create Shipment" click.
|
||||
*
|
||||
* Uses the extendInfolistSchema hook (main column — alongside shipping
|
||||
* address, order lines, totals, transactions, timeline), not
|
||||
* extendInfolistAsideSchema (sidebar) — a shipment list can grow long
|
||||
* (multi-box Box Now orders, a re-dispatched order after a delivery
|
||||
* failure) and reads more naturally as a main-column section like
|
||||
* Transactions, not a compact sidebar entry.
|
||||
*
|
||||
* Each shipment renders as two inline-labelled lines (carrier + tracking
|
||||
* reference, then status + timestamp) rather than a grid of individually
|
||||
* stacked label/value blocks — Filament's own multi-column grid still
|
||||
* collapses to one column below its lg breakpoint (1024px), which is
|
||||
* exactly where the admin's main content area commonly sits with the
|
||||
* sidebar open, so a 5-6 field grid reads as a wall of repeated labels
|
||||
* there.
|
||||
*
|
||||
* "Print Label" opens Modules\Core\Shipping\Http\Controllers\
|
||||
* DownloadShipmentLabelController via a short-lived signed URL — the same
|
||||
* auth model (and Action wiring pattern) Lunar's own vendor PdfDownload
|
||||
* action uses for order PDFs. Previously the only place that called
|
||||
* CarrierFulfillmentInterface::printLabel() (Modules\Core\Shipping\
|
||||
* Filament\Pages\ManagePickupManifests) discarded the returned bytes
|
||||
* entirely — this is the first place that actually delivers a label to
|
||||
* staff.
|
||||
*/
|
||||
class OrderShipmentsExtension extends ViewPageExtension
|
||||
{
|
||||
/**
|
||||
* Inserted right after Transactions and before Timeline — vendor
|
||||
* ManageOrder::getInfolistSchema() builds this array as
|
||||
* [shipping, orderLines, orderTotals, transactions, timeline] (see
|
||||
* Lunar\Admin\...\Concerns\DisplaysTransactions/DisplaysTimeline), so
|
||||
* splicing at index 4 lands the new section there regardless of how
|
||||
* many earlier entries any OTHER extension on this same hook has
|
||||
* already added/removed — counting from the end (timeline is always
|
||||
* last) would be equally fragile to some other extension appending
|
||||
* its own section after timeline, so this anchors on the known
|
||||
* vendor order instead.
|
||||
*/
|
||||
public function extendInfolistSchema(array $schema): array
|
||||
{
|
||||
array_splice($schema, 4, 0, [$this->shipmentsSection()]);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
private function shipmentsSection(): Section
|
||||
{
|
||||
return Section::make('shipments')
|
||||
->heading('Shipments')
|
||||
->compact()
|
||||
->collapsed(fn ($record) => $record->shipments->isEmpty())
|
||||
->collapsible(fn ($record) => $record->shipments->isNotEmpty())
|
||||
->schema([
|
||||
RepeatableEntry::make('shipments')
|
||||
->hiddenLabel()
|
||||
->placeholder('No shipments have been created for this order yet.')
|
||||
->contained(true)
|
||||
->schema([
|
||||
TextEntry::make('tracking_reference')
|
||||
->label(fn (Shipment $record) => $this->carrierLabel($record))
|
||||
->inlineLabel()
|
||||
->copyable(),
|
||||
TextEntry::make('status')
|
||||
->label('Status')
|
||||
->inlineLabel()
|
||||
->state(fn (Shipment $record) => $this->statusLabel($record))
|
||||
->badge()
|
||||
->color(fn (Shipment $record) => $this->statusColor($record))
|
||||
->helperText(fn (Shipment $record) => $this->helperText($record))
|
||||
->suffixActions([
|
||||
Action::make('print_label')
|
||||
->label('Print Label')
|
||||
->icon('heroicon-o-printer')
|
||||
->url(fn (Shipment $record) => URL::temporarySignedRoute(
|
||||
'shipments.label',
|
||||
now()->addMinutes(5),
|
||||
['shipment' => $record->id],
|
||||
), shouldOpenInNewTab: true)
|
||||
->visible(fn (Shipment $record) => ! $record->cancelled_at),
|
||||
Action::make('cancel_shipment')
|
||||
->label('Cancel')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Cancels this shipment with the carrier. This cannot be undone.')
|
||||
->action(fn (Shipment $record) => $this->cancel($record))
|
||||
->visible(fn (Shipment $record) => ! $record->cancelled_at),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function carrierLabel(Shipment $record): string
|
||||
{
|
||||
return match ($record->carrier) {
|
||||
'acs' => 'ACS',
|
||||
'box-now' => 'Box Now',
|
||||
default => (string) str($record->carrier)->title(),
|
||||
};
|
||||
}
|
||||
|
||||
private function helperText(Shipment $record): string
|
||||
{
|
||||
$parts = ['Created '.$record->created_at->format('Y-m-d H:i')];
|
||||
|
||||
if ($record->carrier === 'box-now' && $locker = $record->meta['locker_id'] ?? null) {
|
||||
$parts[] = 'Locker '.$locker;
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
private function statusLabel(Shipment $record): string
|
||||
{
|
||||
if ($record->cancelled_at) {
|
||||
return 'Cancelled';
|
||||
}
|
||||
|
||||
$latest = $record->latestShipmentInfo();
|
||||
|
||||
return $latest ? (string) str($latest->status->value)->replace('_', ' ')->title() : 'Pending';
|
||||
}
|
||||
|
||||
private function statusColor(Shipment $record): string
|
||||
{
|
||||
if ($record->cancelled_at) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return match ($record->latestShipmentInfo()?->status?->value) {
|
||||
'delivered' => 'success',
|
||||
'failed', 'returned' => 'danger',
|
||||
'in_transit', 'out_for_delivery', 'collected_from_sender' => 'warning',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function cancel(Shipment $record): void
|
||||
{
|
||||
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $record->carrier]);
|
||||
|
||||
try {
|
||||
$service->cancelShipment($record);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title('Failed to cancel shipment: '.$e->getMessage())
|
||||
->color('danger')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Shipment cancelled.')
|
||||
->color('success')
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,90 @@
|
||||
namespace Modules\Core\Shipping\Extensions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Closure;
|
||||
use Throwable;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Lunar\Admin\Support\Extending\ViewPageExtension;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Order\DTOs\OrderFulfillmentResult;
|
||||
use Modules\Core\Order\Services\OrderFulfillmentService;
|
||||
use Modules\Core\Order\Services\OrderStatusFlow;
|
||||
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
||||
use Modules\Core\Shipping\Support\WeightCalculator;
|
||||
|
||||
/**
|
||||
* Filament wiring only (labels, icons, visibility, form schema) for the
|
||||
* staff-facing status workflow — every guard check, status write, and
|
||||
* event dispatch lives in Modules\Core\Order\Services\
|
||||
* OrderFulfillmentService/OrderStatusFlow, resolved via app() (a
|
||||
* ViewPageExtension is instantiated by Lunar's own extension mechanism,
|
||||
* not the container, so there's no constructor-injection seam here).
|
||||
*
|
||||
* Strips Lunar's own "Update Status" header action (registered by vendor
|
||||
* ManageOrder as Action::make('update_status')) and replaces it with our
|
||||
* own action of the same name — vendor's writes $record->status directly
|
||||
* with no audit trail, no branch validation, and no side effects. Our
|
||||
* replacement offers every status in the order's own branch (carrier or
|
||||
* pickup — see OrderStatusFlow::allOptions()), not just the guided next
|
||||
* step, so staff can freely revert to an earlier status too. It is a
|
||||
* PLAIN status write — picking 'dispatched' here does not create a real
|
||||
* shipment (see "Create Shipment" below for that).
|
||||
*
|
||||
* "Create Shipment" is its own separate header action, visible only for a
|
||||
* carrier order sitting at 'ready_for_dispatch' — this is the one action
|
||||
* that talks to a real carrier API and writes Order::status to
|
||||
* 'dispatched' as a side effect of that succeeding, so it needs its own
|
||||
* weight/locker inputs specific to that one real-world action, not
|
||||
* bundled into the general-purpose status select where they'd appear for
|
||||
* every revert/manual-override use of 'dispatched' too. The form branches
|
||||
* on which carrier the order actually uses
|
||||
* (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS)
|
||||
* gets a single TOTAL weight field for the whole shipment (ACS has no
|
||||
* per-package weight concept — one Weight value is sent alongside
|
||||
* Item_Quantity in the same ACS_Create_Voucher call, see
|
||||
* AcsFulfillmentService::createShipment()), pre-filled from the order's
|
||||
* own line weights (Modules\Core\Shipping\Support\WeightCalculator) but
|
||||
* still staff-editable, plus a package count (ShipmentRequest::
|
||||
* $packageCount) — more than 1 issues a main voucher plus a
|
||||
* multi-part sub-voucher per extra package (persistMultipartVouchers()),
|
||||
* each recorded as its own Shipment row sharing the same total weight in
|
||||
* meta. Box Now, which bills by compartment size rather than
|
||||
* weight, gets a repeatable list of boxes (one row per physical parcel,
|
||||
* each with its own S/M/L size) instead — see
|
||||
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how
|
||||
* multiple boxes become multiple Shipment rows from one delivery request.
|
||||
* Box Now's locker is locked to read-only once the shopper's own checkout
|
||||
* selection ($order->shippingAddress->meta['box_now_locker']) is present
|
||||
* — staff can only fill it in manually for the (current, checkout-UI-less)
|
||||
* case where nothing set it yet.
|
||||
*
|
||||
* "Mark Paid" is a third, separate header action — Order::paid is
|
||||
* independent of `status` (see OrderStatusFlow's own docblock), so it
|
||||
* doesn't belong bundled into the status select either. Visible only when
|
||||
* the order's payment method doesn't auto-capture at checkout (currently
|
||||
* only cash-on-delivery — see OrderStatusFlow::canMarkPaid()); a
|
||||
* processor-managed method (Stripe) or an immediate-capture offline
|
||||
* method (cash-in-hand, bank-transfer) sets Order::paid automatically via
|
||||
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus, so this button
|
||||
* never appears for those.
|
||||
*
|
||||
* Also strips Lunar's own "Download PDF" header action (getDefaultHeaderActions()
|
||||
* in vendor ManageOrder) — its lunarpanel::pdf.order template does not meet
|
||||
* Greek AADE e-invoicing requirements, so it must not be offered as a
|
||||
* downloadable document until a compliant invoice generator exists.
|
||||
*/
|
||||
class OrderViewExtension extends ViewPageExtension
|
||||
{
|
||||
public function headerActions(array $actions): array
|
||||
{
|
||||
$actions = array_filter($actions, fn ($action) => method_exists($action, 'getName')
|
||||
? ! in_array($action->getName(), ['download_pdf', 'update_status'], true)
|
||||
: true);
|
||||
|
||||
$actions[] = $this->createShipmentAction();
|
||||
$actions[] = $this->markPickedUpAction();
|
||||
$actions[] = $this->updateStatusAction();
|
||||
$actions[] = $this->markPaidAction();
|
||||
|
||||
return $actions;
|
||||
}
|
||||
@@ -32,122 +97,133 @@ class OrderViewExtension extends ViewPageExtension
|
||||
->label('Create Shipment')
|
||||
->icon('heroicon-o-truck')
|
||||
->modalSubmitActionLabel('Create Shipment')
|
||||
->schema([
|
||||
TextInput::make('weight')
|
||||
->label('Package weight (kg)')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->helperText('Leave blank to use the carrier\'s default.'),
|
||||
TextInput::make('destination_location_id')
|
||||
->label('Box Now locker ID')
|
||||
->helperText('Only required for Box Now shipments.')
|
||||
->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null),
|
||||
Toggle::make('confirm')
|
||||
->label('Confirm')
|
||||
->helperText('This will create a real shipment with the carrier.')
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, Closure $fail) {
|
||||
if ($value !== true) {
|
||||
$fail('Please confirm before creating the shipment.');
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
])
|
||||
->action(function (Order $record, array $data, Action $action) {
|
||||
$service = $this->resolveFulfillmentService($record);
|
||||
->schema(function (Order $record) {
|
||||
$isBoxNow = $this->service()->carrierFor($record) === 'box-now';
|
||||
$lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null;
|
||||
|
||||
if (! $service) {
|
||||
Notification::make()
|
||||
->title('No carrier fulfillment integration is configured for this order.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
$action->halt();
|
||||
|
||||
return;
|
||||
if (! $isBoxNow) {
|
||||
return [
|
||||
TextInput::make('weight')
|
||||
->label('Total weight (kg)')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(fn () => round(WeightCalculator::totalKg($record->lines), 2) ?: null)
|
||||
->helperText("Calculated from the order's line weights — adjust if needed, or leave blank to use the carrier's default. One figure for the whole shipment, not per package."),
|
||||
TextInput::make('package_count')
|
||||
->label('Number of packages')
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->default(1)
|
||||
->required()
|
||||
->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'),
|
||||
];
|
||||
}
|
||||
|
||||
$request = new ShipmentRequest(
|
||||
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
|
||||
destinationLocationId: $data['destination_location_id'] ?? null,
|
||||
return [
|
||||
TextInput::make('destination_location_id')
|
||||
->label('Box Now locker ID')
|
||||
->default($lockerId)
|
||||
// Locked once the shopper's own checkout selection is
|
||||
// known — staff should not be able to redirect a
|
||||
// parcel to a different locker than the one the
|
||||
// customer picked. Only editable for the (current,
|
||||
// checkout-UI-less) case where nothing set it yet.
|
||||
->disabled(filled($lockerId))
|
||||
->dehydrated()
|
||||
->required()
|
||||
->helperText($lockerId
|
||||
? 'Set by the customer at checkout.'
|
||||
: 'No locker was selected at checkout — enter it manually.'),
|
||||
Repeater::make('boxes')
|
||||
->label('Boxes')
|
||||
->schema([
|
||||
Select::make('size')
|
||||
->label('Size')
|
||||
->options(['S' => 'Small', 'M' => 'Medium', 'L' => 'Large'])
|
||||
->default('S')
|
||||
->native(false)
|
||||
->required(),
|
||||
])
|
||||
->defaultItems(1)
|
||||
->addActionLabel('Add another box')
|
||||
->minItems(1)
|
||||
->helperText('One row per physical parcel — Box Now ships by compartment size, not weight.'),
|
||||
];
|
||||
})
|
||||
->action(function (Order $record, array $data, Action $action) {
|
||||
$result = $this->service()->createShipmentAndDispatch(
|
||||
$record,
|
||||
new ShipmentRequest(
|
||||
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
|
||||
packageCount: (int) ($data['package_count'] ?? 1),
|
||||
destinationLocationId: $data['destination_location_id'] ?? null,
|
||||
boxes: collect($data['boxes'] ?? [])->pluck('size')->all(),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
$service->createShipment($record, $request);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title('Failed to create shipment: '.$e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
$this->notify($result);
|
||||
|
||||
if (! $result->success) {
|
||||
$action->halt();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Shipment created.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Order $record) => $record->status === 'ready-for-dispatch'
|
||||
&& ! $record->isStorePickupOrder()
|
||||
&& $record->shipments()->exists() === false
|
||||
&& $this->resolveFulfillmentService($record) !== null);
|
||||
->visible(fn (Order $record) => $this->service()->canCreateShipment($record));
|
||||
}
|
||||
|
||||
/**
|
||||
* The store-pickup mirror of createShipmentAction() — a store-pickup
|
||||
* order never gets a Shipment record (no carrier is ever involved), so
|
||||
* it needs its own way to close out of 'ready-for-pickup' once the
|
||||
* customer has actually collected it. Sets status directly to
|
||||
* 'completed', same terminal status DeriveOrderDeliveredFromShipment
|
||||
* writes for a carrier order once tracking confirms delivery — see
|
||||
* that listener's own docblock.
|
||||
*/
|
||||
private function markPickedUpAction(): Action
|
||||
private function updateStatusAction(): Action
|
||||
{
|
||||
return Action::make('mark_picked_up')
|
||||
->label('Mark Picked Up')
|
||||
->icon('heroicon-o-check-circle')
|
||||
return Action::make('update_status')
|
||||
->label('Update Status')
|
||||
->icon('heroicon-o-adjustments-horizontal')
|
||||
->schema(fn (Order $record) => [
|
||||
Select::make('to_status')
|
||||
->label('New status')
|
||||
->options(app(OrderStatusFlow::class)->allOptions($record))
|
||||
->default($record->status)
|
||||
->native(false)
|
||||
->required(),
|
||||
])
|
||||
->action(function (Order $record, array $data, Action $action) {
|
||||
$to = $data['to_status'];
|
||||
$service = $this->service();
|
||||
|
||||
$result = match (true) {
|
||||
($to === 'ready_for_dispatch' || $to === 'ready_for_pickup') && $record->status === 'processing' => $service->markReady($record),
|
||||
$to === 'picked_up' && $record->status === 'ready_for_pickup' => $service->markPickedUp($record),
|
||||
default => $service->transitionTo($record, $to),
|
||||
};
|
||||
|
||||
$this->notify($result);
|
||||
|
||||
if (! $result->success) {
|
||||
$action->halt();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function markPaidAction(): Action
|
||||
{
|
||||
return Action::make('mark_paid')
|
||||
->label('Mark Paid')
|
||||
->icon('heroicon-o-banknotes')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Confirms the customer has collected this order in store.')
|
||||
->action(function (Order $record) {
|
||||
$record->update(['status' => 'completed']);
|
||||
|
||||
Notification::make()
|
||||
->title('Order marked as picked up.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Order $record) => $record->status === 'ready-for-pickup'
|
||||
&& $record->isStorePickupOrder());
|
||||
->modalDescription('Confirms payment for this order has been received outside the system.')
|
||||
->visible(fn (Order $record) => app(OrderStatusFlow::class)->canMarkPaid($record))
|
||||
->action(fn (Order $record) => $this->notify($this->service()->markPaid($record)));
|
||||
}
|
||||
|
||||
private function resolveCarrier(Order $record): ?string
|
||||
private function service(): OrderFulfillmentService
|
||||
{
|
||||
$code = $record->shippingAddress?->shipping_option;
|
||||
|
||||
if (! $code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ShippingMethod::where('code', $code)->value('driver');
|
||||
return app(OrderFulfillmentService::class);
|
||||
}
|
||||
|
||||
private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface
|
||||
private function notify(OrderFulfillmentResult $result): void
|
||||
{
|
||||
$carrier = $this->resolveCarrier($record);
|
||||
|
||||
if (! $carrier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||
Notification::make()
|
||||
->title($result->message)
|
||||
->color($result->success ? 'success' : 'danger')
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Pages;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
class ManagePickupManifests extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-truck';
|
||||
|
||||
protected static ?string $navigationLabel = 'Pickup Manifests';
|
||||
|
||||
/**
|
||||
* Without an explicit group, this page had no navigation group at all —
|
||||
* Filament's Panel::getUrl() falls back to "first item in the first
|
||||
* navigation group" when no homeUrl is set (neither Lunar nor CorePlugin
|
||||
* sets one), and an ungrouped page sorted ahead of every one of Lunar's
|
||||
* own grouped resources (Sales, Catalog, etc.), making this page the
|
||||
* panel's de facto home instead of the real Dashboard. Grouping it under
|
||||
* Sales — alongside CartResource, OrderResource — fixes that by letting
|
||||
* a legitimate item sort first again. Sorted last within the group
|
||||
* deliberately (a high explicit navigationSort — Lunar's own
|
||||
* OrderResource uses 1) so this page never competes to be first even as
|
||||
* more Sales-group items are added later.
|
||||
*/
|
||||
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
|
||||
|
||||
protected static ?int $navigationSort = 100;
|
||||
|
||||
protected string $view = 'core::shipping.filament.pages.manage-pickup-manifests';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query($this->pendingQuery())
|
||||
->columns([
|
||||
TextColumn::make('carrier')->badge(),
|
||||
TextColumn::make('tracking_reference')->label('Tracking #'),
|
||||
TextColumn::make('order.reference')->label('Order'),
|
||||
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('print')
|
||||
->label('Print')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Shipment $record) => $this->printShipment($record)),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkAction::make('print_selected')
|
||||
->label('Print selected')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => $this->printShipment($shipment))),
|
||||
BulkAction::make('issue_manifest')
|
||||
->label('Issue Manifest')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->action(fn (Collection $records) => $this->issueManifest($records)),
|
||||
]);
|
||||
}
|
||||
|
||||
private function pendingQuery(): Builder
|
||||
{
|
||||
$carriers = collect(Shipping::getSupportedDrivers())->keys()->filter(
|
||||
fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsManifestBatching
|
||||
);
|
||||
|
||||
return Shipment::query()
|
||||
->whereIn('carrier', $carriers)
|
||||
->whereNull('manifest_reference')
|
||||
->whereNull('cancelled_at');
|
||||
}
|
||||
|
||||
private function printShipment(Shipment $shipment): void
|
||||
{
|
||||
$this->fulfillmentService($shipment->carrier)?->printLabel($shipment);
|
||||
}
|
||||
|
||||
private function issueManifest(Collection $shipments): void
|
||||
{
|
||||
$shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) {
|
||||
$service = $this->fulfillmentService($carrier);
|
||||
|
||||
if (! $service instanceof SupportsManifestBatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $service->issueManifest($group);
|
||||
|
||||
if (! $result->success) {
|
||||
Notification::make()
|
||||
->title("Manifest blocked for {$carrier}: {$result->reason}")
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title("Manifest issued for {$carrier}: {$result->reference}")
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
|
||||
{
|
||||
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ListManifests;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ViewManifest;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers\ShipmentsRelationManager;
|
||||
use Modules\Core\Shipping\Models\Manifest;
|
||||
|
||||
/**
|
||||
* Issued manifests — Modules\Core\Shipping\Models\Manifest is the only
|
||||
* record of "which shipments were on manifest X, and when" this codebase
|
||||
* keeps; ACS's own ACS_Issue_Pickup_List call returns nothing beyond a
|
||||
* reference number, so there is nothing to re-fetch from the carrier
|
||||
* later (see that model's own docblock). Complements
|
||||
* Modules\Core\Shipping\Filament\Resources\ShipmentResource, which only
|
||||
* ever shows shipments NOT YET on a manifest.
|
||||
*/
|
||||
class ManifestResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Manifest::class;
|
||||
|
||||
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||
|
||||
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
|
||||
|
||||
protected static ?string $navigationLabel = 'Issued Manifests';
|
||||
|
||||
protected static ?string $modelLabel = 'Manifest';
|
||||
|
||||
protected static ?int $navigationSort = 101;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('carrier')->badge(),
|
||||
TextColumn::make('reference')->label('Reference')->copyable(),
|
||||
TextColumn::make('shipment_count')->label('Shipments'),
|
||||
TextColumn::make('issued_at')->label('Issued')->dateTime(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
])
|
||||
->defaultSort('issued_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
ShipmentsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListManifests::route('/'),
|
||||
'view' => ViewManifest::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
|
||||
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
|
||||
|
||||
class ListManifests extends ListRecords
|
||||
{
|
||||
protected static string $resource = ManifestResource::class;
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
$carriers = collect(Shipping::getSupportedDrivers())
|
||||
->keys()
|
||||
->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching);
|
||||
|
||||
return $carriers->mapWithKeys(fn (string $carrier) => [
|
||||
$carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier)))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)),
|
||||
])->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
|
||||
|
||||
/**
|
||||
* Relation managers (ShipmentsRelationManager) are registered on
|
||||
* ManifestResource::getRelations() — the actual wiring point
|
||||
* (Filament\Resources\Pages\Concerns\HasRelationManagers::
|
||||
* getAllRelationManagers() reads from Resource::getRelations(), not from
|
||||
* an override here). An earlier version of this page overrode
|
||||
* getRelationManagers() directly, bypassing that trait's own
|
||||
* canViewForRecord()/caching logic and causing a broken Livewire
|
||||
* component mount (surfaced as a 419/redirect loop on this exact page).
|
||||
*/
|
||||
class ViewManifest extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ManifestResource::class;
|
||||
|
||||
public function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
TextEntry::make('carrier')->badge(),
|
||||
TextEntry::make('reference')->label('Reference')->copyable(),
|
||||
TextEntry::make('shipment_count')->label('Shipments'),
|
||||
TextEntry::make('issued_at')->label('Issued')->dateTime(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
/**
|
||||
* The shipments a given Manifest actually included — read-only (a
|
||||
* shipment's manifest membership is set once, at issueManifest() time,
|
||||
* never edited here). Reuses ShipmentResource::printShipment() for the
|
||||
* "Print" action rather than duplicating its try/catch-and-notify
|
||||
* handling.
|
||||
*/
|
||||
class ShipmentsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'shipments';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('tracking_reference')->label('Tracking #'),
|
||||
TextColumn::make('order.reference')->label('Order'),
|
||||
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
|
||||
TextColumn::make('cancelled_at')->label('Cancelled')->dateTime()->placeholder('—'),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('print')
|
||||
->label('Print')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Shipment $record) => ShipmentResource::printShipment($record)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Replaces the standalone Modules\Core\Shipping\Filament\Pages\
|
||||
* ManagePickupManifests page — a bare Page has no access to Filament's
|
||||
* resource-level pill-tab UI (Filament\Resources\Concerns\HasTabs is
|
||||
* scoped to ListRecords), so carrier-by-carrier separation
|
||||
* (ListShipments::getTabs(), one tab per SupportsManifestBatching
|
||||
* implementer) needed a real Resource to attach to.
|
||||
*
|
||||
* Shows only shipments NOT yet on an issued manifest — see
|
||||
* Modules\Core\Shipping\Filament\Resources\ManifestResource for
|
||||
* shipments that already are.
|
||||
*/
|
||||
class ShipmentResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Shipment::class;
|
||||
|
||||
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-truck';
|
||||
|
||||
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
|
||||
|
||||
protected static ?string $navigationLabel = 'Pending Vouchers';
|
||||
|
||||
protected static ?string $modelLabel = 'Shipment';
|
||||
|
||||
protected static ?int $navigationSort = 100;
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->whereNull('manifest_id')
|
||||
->whereNull('cancelled_at');
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('carrier')->badge(),
|
||||
TextColumn::make('tracking_reference')->label('Tracking #'),
|
||||
TextColumn::make('order.reference')->label('Order'),
|
||||
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('print')
|
||||
->label('Print')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Shipment $record) => self::printShipment($record)),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkAction::make('print_selected')
|
||||
->label('Print selected')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => self::printShipment($shipment))),
|
||||
BulkAction::make('issue_manifest')
|
||||
->label('Issue Manifest')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->action(fn (Collection $records) => self::issueManifest($records)),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function printShipment(Shipment $shipment): void
|
||||
{
|
||||
$service = self::fulfillmentService($shipment->carrier);
|
||||
|
||||
if (! $service) {
|
||||
Notification::make()
|
||||
->title("No fulfillment integration configured for {$shipment->carrier}.")
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$service->printLabel($shipment);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title("Failed to print label for {$shipment->tracking_reference}: {$e->getMessage()}")
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public static function issueManifest(Collection $shipments): void
|
||||
{
|
||||
$shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) {
|
||||
$service = self::fulfillmentService($carrier);
|
||||
|
||||
if (! $service instanceof SupportsManifestBatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $service->issueManifest($group);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title("Failed to issue manifest for {$carrier}: {$e->getMessage()}")
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $result->success) {
|
||||
Notification::make()
|
||||
->title("Manifest blocked for {$carrier}: {$result->reason}")
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title("Manifest issued for {$carrier}: {$result->reference}")
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
public static function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
|
||||
{
|
||||
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShipments::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
|
||||
|
||||
/**
|
||||
* One tab per carrier that actually implements SupportsManifestBatching
|
||||
* (ACS today) — a carrier with no manifest concept at all (Box Now,
|
||||
* which books courier pickup at shipment-creation time, no separate
|
||||
* batching step) never gets a tab here, since there is nothing to batch.
|
||||
* Adding a new carrier (e.g. Speedex) that also implements the contract
|
||||
* needs zero changes to this page — the tab list is derived from
|
||||
* Shipping::getSupportedDrivers(), not hardcoded.
|
||||
*/
|
||||
class ListShipments extends ListRecords
|
||||
{
|
||||
protected static string $resource = ShipmentResource::class;
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
$carriers = collect(Shipping::getSupportedDrivers())
|
||||
->keys()
|
||||
->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching);
|
||||
|
||||
return $carriers->mapWithKeys(fn (string $carrier) => [
|
||||
$carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier)))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)),
|
||||
])->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
/**
|
||||
* Streams a carrier's raw label bytes (CarrierFulfillmentInterface::
|
||||
* printLabel() — already whatever file format the carrier's own API
|
||||
* returns, e.g. a PDF for both ACS and Box Now today) straight to the
|
||||
* browser. Only reachable via a short-lived signed URL (see
|
||||
* Modules\Core\Shipping\Extensions\OrderShipmentsExtension's "Print
|
||||
* Label" action) — the same auth model Lunar's own vendor
|
||||
* DownloadPdfController uses for order PDFs (a valid signature IS the
|
||||
* auth check; there is no separate staff-session check here, matching
|
||||
* that precedent), so the link only works for the few minutes it's
|
||||
* actually open in a browser tab.
|
||||
*
|
||||
* Looks the Shipment up manually from a plain {shipment} id rather than
|
||||
* relying on implicit route-model-binding — this route is registered via
|
||||
* loadRoutesFrom() with no middleware group (see
|
||||
* Modules\Core\Providers\ShippingServiceProvider::boot()), so
|
||||
* SubstituteBindings never runs and a type-hinted Shipment parameter
|
||||
* silently resolves to an empty, non-existent model instead of 404ing.
|
||||
*
|
||||
* Sets label_printed_at as a side effect of a successful stream — this is
|
||||
* the first place in the codebase that actually delivers a label's bytes
|
||||
* to a human; the existing Modules\Core\Shipping\Filament\Pages\
|
||||
* ManagePickupManifests "Print" action calls printLabel() too, but only
|
||||
* to mark the timestamp, discarding the returned bytes entirely (no
|
||||
* download route existed until this one).
|
||||
*/
|
||||
class DownloadShipmentLabelController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, int $shipment)
|
||||
{
|
||||
if (! $request->hasValidSignature()) {
|
||||
abort(401);
|
||||
}
|
||||
|
||||
$shipment = Shipment::findOrFail($shipment);
|
||||
|
||||
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $shipment->carrier]);
|
||||
|
||||
$bytes = $service->printLabel($shipment);
|
||||
|
||||
return response($bytes, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="shipment-'.$shipment->tracking_reference.'.pdf"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* A carrier pickup-list/manifest as WE recorded it at the moment it was
|
||||
* issued (Modules\Core\Shipping\Contracts\SupportsManifestBatching::
|
||||
* issueManifest()) — the carrier's own API (ACS's ACS_Issue_Pickup_List
|
||||
* included) typically returns nothing beyond a reference number, so this
|
||||
* table is the only place "which shipments were on manifest X, and when"
|
||||
* is ever recorded; it cannot be re-derived from the carrier later.
|
||||
*/
|
||||
class Manifest extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'issued_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function shipments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Shipment::class);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ class Shipment extends Model
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function manifest(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Manifest::class);
|
||||
}
|
||||
|
||||
public function shipmentInfo(): HasMany
|
||||
{
|
||||
return $this->hasMany(ShipmentInfo::class);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Support;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Sums ProductVariant::weight_value/weight_unit across a collection of
|
||||
* Cart/Order lines into a single kg figure — the same conversion table
|
||||
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver::totalWeightInKg()
|
||||
* already used privately for live rate quoting, now shared so
|
||||
* Modules\Core\Shipping\Extensions\OrderViewExtension's "Create Shipment"
|
||||
* weight default can compute the identical figure for an already-placed
|
||||
* Order instead of duplicating the unit table.
|
||||
*/
|
||||
class WeightCalculator
|
||||
{
|
||||
/**
|
||||
* @param Collection $lines Cart::$lines or Order::$lines, each with
|
||||
* its purchasable relation loaded (or loadable — ->load('purchasable')
|
||||
* is called here if not already eager-loaded).
|
||||
*/
|
||||
public static function totalKg(Collection $lines): float
|
||||
{
|
||||
$weight = 0.0;
|
||||
|
||||
foreach ($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 $weight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelController;
|
||||
|
||||
Route::get('shipments/{shipment}/label', DownloadShipmentLabelController::class)
|
||||
->name('shipments.label');
|
||||
Reference in New Issue
Block a user