187 lines
7.7 KiB
PHP
187 lines
7.7 KiB
PHP
<?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();
|
||
|
|
}
|
||
|
|
}
|