Feature: Order Updates, Events, Order Flows, Shipment And COD support
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user