Feat: Shipping functionalities redesign, elta courier integration, commenting out acs since no integration can happen, flow updates
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Lunar\Admin\Filament\Resources\OrderResource;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Modules\Core\Shipping\Enums\ExtraService;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages\ListCarrierVouchers;
|
||||
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages\ViewCarrierVoucher;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
use Modules\Core\Shipping\Models\ShipmentInfo;
|
||||
use Modules\Core\Shipping\Support\ShipmentTimelineLogger;
|
||||
|
||||
/**
|
||||
* Every voucher with a number — ours and the ones carriers report
|
||||
* (CarrierVoucherSync) — to follow returns and reconcile vouchers that
|
||||
* never made it onto an order. Tabs: All / Unlinked / Returns. The list
|
||||
* shows the tracking number, our status, order, recipient and phone; the view page has the
|
||||
* details (carrier, voucher, carrier status, COD, services, return info,
|
||||
* suggested orders, tracking history). Vouchers are never linked
|
||||
* automatically: each carries suggested orders (its reference match
|
||||
* first, then same postcode + phone or name), and
|
||||
* "Link to order" attaches it, after which it counts towards that order
|
||||
* like any other shipment.
|
||||
*/
|
||||
class CarrierVoucherResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Shipment::class;
|
||||
|
||||
protected static ?string $slug = 'carrier-vouchers';
|
||||
|
||||
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-queue-list';
|
||||
|
||||
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
|
||||
|
||||
protected static ?string $navigationLabel = 'Carrier Vouchers';
|
||||
|
||||
protected static ?string $modelLabel = 'Carrier voucher';
|
||||
|
||||
protected static ?int $navigationSort = 101;
|
||||
|
||||
private const SOURCES = [
|
||||
Shipment::SOURCE_CREATED => 'Created here',
|
||||
Shipment::SOURCE_MANUAL_VOUCHER => 'Manual voucher',
|
||||
Shipment::SOURCE_MANUAL => 'Manual carrier',
|
||||
Shipment::SOURCE_SYNCED => 'From carrier',
|
||||
];
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->whereNotNull('tracking_reference')
|
||||
->with(['order.shippingAddress', 'shipmentInfo']);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('tracking_reference')
|
||||
->label('Tracking number')
|
||||
->copyable(),
|
||||
TextColumn::make('status')
|
||||
->label('Status')
|
||||
->state(fn (Shipment $record) => self::status($record))
|
||||
->placeholder('No updates yet')
|
||||
->badge()
|
||||
->color(fn (Shipment $record) => self::statusColor($record)),
|
||||
TextColumn::make('order.reference')
|
||||
->label('Order')
|
||||
->placeholder('Not linked'),
|
||||
TextColumn::make('recipient')
|
||||
->label('Recipient')
|
||||
->state(fn (Shipment $record) => self::recipient($record))
|
||||
->placeholder('—')
|
||||
// One search box for the voucher number, order reference,
|
||||
// recipient and phone.
|
||||
->searchable(query: fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
|
||||
->where('tracking_reference', 'ilike', "%{$search}%")
|
||||
->orWhere('meta->recipient_name', 'ilike', "%{$search}%")
|
||||
->orWhere('meta->phone', 'ilike', "%{$search}%")
|
||||
->orWhereHas('order', fn (Builder $order) => $order
|
||||
->where('reference', 'ilike', "%{$search}%")
|
||||
->orWhereHas('shippingAddress', fn (Builder $address) => $address
|
||||
->where('first_name', 'ilike', "%{$search}%")
|
||||
->orWhere('last_name', 'ilike', "%{$search}%")
|
||||
->orWhere('contact_phone', 'ilike', "%{$search}%"))))),
|
||||
TextColumn::make('phone')
|
||||
->label('Phone')
|
||||
->state(fn (Shipment $record) => self::phone($record))
|
||||
->placeholder('—'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('carrier')
|
||||
->options(fn () => collect(Shipping::getSupportedDrivers())->map(fn ($driver) => $driver->name())->all()),
|
||||
SelectFilter::make('source')->options(self::SOURCES),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Section::make('Voucher')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('carrier')
|
||||
->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()),
|
||||
TextEntry::make('tracking_reference')->label('Voucher')->copyable(),
|
||||
TextEntry::make('source')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state) => self::SOURCES[$state] ?? $state),
|
||||
TextEntry::make('status')
|
||||
->state(fn (Shipment $record) => self::status($record))
|
||||
->placeholder('No updates yet')
|
||||
->badge()
|
||||
->color(fn (Shipment $record) => self::statusColor($record)),
|
||||
TextEntry::make('carrier_status')
|
||||
->label('Carrier status')
|
||||
->state(fn (Shipment $record) => self::carrierStatus($record))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('meta.reference')->label('Reference sent to the carrier')->placeholder('—'),
|
||||
TextEntry::make('meta.voucher_date')->label('Voucher date')->date()->placeholder('—'),
|
||||
TextEntry::make('cod')
|
||||
->label('Cash on delivery')
|
||||
->state(fn (Shipment $record) => ($amount = $record->meta['cod_amount'] ?? null)
|
||||
? '€'.number_format((float) $amount, 2)
|
||||
: null)
|
||||
->placeholder('—'),
|
||||
TextEntry::make('services')
|
||||
->label('Extra services')
|
||||
->state(fn (Shipment $record) => collect($record->meta['services'] ?? [])
|
||||
->map(fn (string $value) => ExtraService::tryFrom($value)?->label())
|
||||
->filter()
|
||||
->implode(', ') ?: null)
|
||||
->placeholder('—'),
|
||||
TextEntry::make('created_at')->label('Recorded')->dateTime(),
|
||||
]),
|
||||
Section::make('Recipient')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('recipient')
|
||||
->label('Name')
|
||||
->state(fn (Shipment $record) => self::recipient($record))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('postcode')
|
||||
->state(fn (Shipment $record) => $record->meta['postcode'] ?? $record->order?->shippingAddress?->postcode)
|
||||
->placeholder('—'),
|
||||
TextEntry::make('phone')
|
||||
->state(fn (Shipment $record) => self::phone($record))
|
||||
->placeholder('—'),
|
||||
]),
|
||||
Section::make('Order')
|
||||
->schema([
|
||||
TextEntry::make('order.reference')
|
||||
->label('Linked order')
|
||||
->placeholder('Not linked to an order')
|
||||
->url(fn (Shipment $record) => $record->order_id
|
||||
? OrderResource::getUrl('order', ['record' => $record->order_id])
|
||||
: null),
|
||||
TextEntry::make('suggested')
|
||||
->label('Suggested orders')
|
||||
->state(fn (Shipment $record) => array_values(self::orderOptions(Order::with('shippingAddress')
|
||||
->whereKey($record->meta['suggested_order_ids'] ?? [])
|
||||
->get())))
|
||||
->listWithLineBreaks()
|
||||
->helperText('The order its reference points to first, then orders with the same postcode and phone or name.')
|
||||
->visible(fn (Shipment $record) => ! $record->order_id && filled($record->meta['suggested_order_ids'] ?? [])),
|
||||
]),
|
||||
Section::make('Return')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextEntry::make('return_kind')
|
||||
->label('Reported as')
|
||||
->state(fn (Shipment $record) => $record->isReturn() ? 'Return voucher' : 'Being returned to sender'),
|
||||
TextEntry::make('meta.original_voucher')->label('Original voucher')->placeholder('—'),
|
||||
])
|
||||
->visible(fn (Shipment $record) => self::isReturnLike($record)),
|
||||
Section::make('Tracking history')
|
||||
->schema([
|
||||
TextEntry::make('no_history')
|
||||
->hiddenLabel()
|
||||
->state('No tracking updates yet.')
|
||||
->visible(fn (Shipment $record) => $record->shipmentInfo->isEmpty()),
|
||||
RepeatableEntry::make('history')
|
||||
->hiddenLabel()
|
||||
->state(fn (Shipment $record) => $record->shipmentInfo->sortByDesc('occurred_at')->values())
|
||||
->visible(fn (Shipment $record) => $record->shipmentInfo->isNotEmpty())
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i'))
|
||||
->inlineLabel()
|
||||
->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title())
|
||||
->helperText(fn (ShipmentInfo $record) => collect([$record->carrier_status !== 'manual' ? $record->carrier_status : null, $record->location, $record->message])->filter()->unique()->implode(' · ') ?: null),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches an unlinked voucher to an order (suggestions first), from the
|
||||
* view page.
|
||||
*/
|
||||
public static function linkOrderAction(): Action
|
||||
{
|
||||
return Action::make('link_order')
|
||||
->label('Link to order')
|
||||
->icon('heroicon-o-link')
|
||||
->schema(fn (Shipment $record) => [
|
||||
Select::make('order_id')
|
||||
->label('Order')
|
||||
->options(fn () => self::orderOptions(Order::with('shippingAddress')
|
||||
->whereKey($record->meta['suggested_order_ids'] ?? [])
|
||||
->get()))
|
||||
->default(($record->meta['suggested_order_ids'] ?? [])[0] ?? null)
|
||||
->searchable()
|
||||
->getSearchResultsUsing(fn (string $search) => self::orderOptions(Order::with('shippingAddress')
|
||||
->where('reference', 'ilike', "%{$search}%")
|
||||
->orWhereHas('shippingAddress', fn ($q) => $q
|
||||
->where('last_name', 'ilike', "%{$search}%")
|
||||
->orWhere('postcode', $search))
|
||||
->latest('placed_at')
|
||||
->limit(20)
|
||||
->get()))
|
||||
->getOptionLabelUsing(fn ($value) => self::orderOptions(Order::with('shippingAddress')->whereKey($value)->get())[$value] ?? $value)
|
||||
->helperText('Suggestions: the order its reference points to, then orders with the same postcode and phone or name.')
|
||||
->required(),
|
||||
])
|
||||
->action(function (Shipment $record, array $data) {
|
||||
$record->update(['order_id' => $data['order_id']]);
|
||||
|
||||
// Its history so far goes onto the order's Timeline.
|
||||
app(ShipmentTimelineLogger::class)->backfill($record->refresh());
|
||||
|
||||
Notification::make()->title('Voucher linked to the order.')->success()->send();
|
||||
})
|
||||
->visible(fn (Shipment $record) => $record->order_id === null);
|
||||
}
|
||||
|
||||
public static function unlinkOrderAction(): Action
|
||||
{
|
||||
return Action::make('unlink_order')
|
||||
->label('Unlink from order')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->action(fn (Shipment $record) => $record->update(['order_id' => null]))
|
||||
->visible(fn (Shipment $record) => $record->order_id !== null && $record->source === Shipment::SOURCE_SYNCED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Order> $orders
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function orderOptions(iterable $orders): array
|
||||
{
|
||||
return collect($orders)->mapWithKeys(fn (Order $order) => [$order->id => collect([
|
||||
$order->reference,
|
||||
trim($order->shippingAddress?->first_name.' '.$order->shippingAddress?->last_name),
|
||||
$order->shippingAddress?->postcode,
|
||||
$order->placed_at?->format('Y-m-d'),
|
||||
])->filter()->implode(' · ')])->all();
|
||||
}
|
||||
|
||||
private static function phone(Shipment $record): ?string
|
||||
{
|
||||
return $record->meta['phone'] ?? $record->order?->shippingAddress?->contact_phone;
|
||||
}
|
||||
|
||||
private static function recipient(Shipment $record): ?string
|
||||
{
|
||||
if ($name = $record->meta['recipient_name'] ?? null) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$address = $record->order?->shippingAddress;
|
||||
|
||||
return $address ? trim("{$address->first_name} {$address->last_name}") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Our status (TrackingStatus): cancelled here, else the latest
|
||||
* checkpoint in the voucher's history.
|
||||
*/
|
||||
private static function trackingStatus(Shipment $record): ?TrackingStatus
|
||||
{
|
||||
if ($record->isCancelled()) {
|
||||
return TrackingStatus::Cancelled;
|
||||
}
|
||||
|
||||
return $record->shipmentInfo->sortBy('occurred_at')->last()?->status;
|
||||
}
|
||||
|
||||
private static function status(Shipment $record): ?string
|
||||
{
|
||||
$status = self::trackingStatus($record);
|
||||
|
||||
return $status ? (string) str($status->value)->replace('_', ' ')->title() : null;
|
||||
}
|
||||
|
||||
private static function statusColor(Shipment $record): string
|
||||
{
|
||||
return match (self::trackingStatus($record)) {
|
||||
TrackingStatus::Delivered => 'success',
|
||||
TrackingStatus::Failed, TrackingStatus::Returned, TrackingStatus::Cancelled => 'danger',
|
||||
TrackingStatus::InTransit, TrackingStatus::OutForDelivery, TrackingStatus::CollectedFromSender => 'warning',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The carrier's own wording for the latest status, as it sent it.
|
||||
*/
|
||||
private static function carrierStatus(Shipment $record): ?string
|
||||
{
|
||||
$latest = $record->shipmentInfo->sortBy('occurred_at')->last();
|
||||
|
||||
return $latest?->carrier_status !== 'manual' ? $latest?->carrier_status : null;
|
||||
}
|
||||
|
||||
public static function isReturnLike(Shipment $record): bool
|
||||
{
|
||||
return $record->isReturn() || (bool) ($record->meta['reported_return'] ?? false);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCarrierVouchers::route('/'),
|
||||
'view' => ViewCarrierVoucher::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
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\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
|
||||
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
use Modules\Core\Shipping\Services\CarrierVoucherSync;
|
||||
use Throwable;
|
||||
|
||||
class ListCarrierVouchers extends ListRecords
|
||||
{
|
||||
protected static string $resource = CarrierVoucherResource::class;
|
||||
|
||||
private const SYNC_DAYS = 14;
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
return [
|
||||
'all' => Tab::make('All'),
|
||||
'unlinked' => Tab::make('Unlinked')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->whereNull('order_id'))
|
||||
->badge(fn () => Shipment::whereNotNull('tracking_reference')->whereNull('order_id')->count() ?: null),
|
||||
'returns' => Tab::make('Returns')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where(fn (Builder $q) => $q
|
||||
->where('meta->is_return', true)
|
||||
->orWhere('meta->reported_return', true)))
|
||||
->badge(fn () => Shipment::whereNotNull('tracking_reference')
|
||||
->where(fn (Builder $q) => $q->where('meta->is_return', true)->orWhere('meta->reported_return', true))
|
||||
->count() ?: null),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('lookup')
|
||||
->label('Look up voucher')
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->modalDescription('Fetches a voucher from the carrier that hasn\'t synced yet and records it here.')
|
||||
->schema([
|
||||
Select::make('carrier')
|
||||
->label('Carrier')
|
||||
->options(fn () => collect(Shipping::getSupportedDrivers())
|
||||
->filter(fn ($driver, string $carrier) => app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]) instanceof SupportsVoucherLookup)
|
||||
->map(fn ($driver) => $driver->name())
|
||||
->all())
|
||||
->native(false)
|
||||
->required(),
|
||||
TextInput::make('voucher_number')->label('Voucher number')->required(),
|
||||
])
|
||||
->action(fn (array $data) => $this->lookup($data['carrier'], $data['voucher_number'])),
|
||||
Action::make('sync')
|
||||
->label('Sync now')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(fn () => $this->sync()),
|
||||
];
|
||||
}
|
||||
|
||||
private function lookup(string $carrier, string $number): void
|
||||
{
|
||||
try {
|
||||
$shipment = app(CarrierVoucherSync::class)->lookup($carrier, $number);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()->title('Lookup failed: '.$e->getMessage())->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $shipment) {
|
||||
Notification::make()->title("The carrier has no record of {$number}.")->warning()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$body = match (true) {
|
||||
! $shipment->wasRecentlyCreated => 'Already recorded here.',
|
||||
filled($shipment->meta['suggested_order_ids'] ?? []) => 'Recorded — it has a suggested order to link it to.',
|
||||
default => 'Recorded, not linked to an order.',
|
||||
};
|
||||
|
||||
Notification::make()->title("Voucher {$shipment->tracking_reference}")->body($body)->success()->send();
|
||||
}
|
||||
|
||||
private function sync(): void
|
||||
{
|
||||
$results = app(CarrierVoucherSync::class)->syncAll(now()->subDays(self::SYNC_DAYS)->startOfDay(), now());
|
||||
|
||||
foreach ($results as $carrier => $stats) {
|
||||
$name = Shipping::getSupportedDrivers()->get($carrier)?->name() ?? $carrier;
|
||||
|
||||
isset($stats['error'])
|
||||
? Notification::make()->title("{$name}: sync failed")->body($stats['error'])->danger()->send()
|
||||
: Notification::make()
|
||||
->title("{$name}: {$stats['created']} new, {$stats['updated']} updated, of {$stats['seen']} vouchers")
|
||||
->body($stats['suggested'] ? "{$stats['suggested']} new have a suggested order to link." : null)
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Lunar\Admin\Filament\Resources\OrderResource;
|
||||
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
class ViewCarrierVoucher extends ViewRecord
|
||||
{
|
||||
protected static string $resource = CarrierVoucherResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'Voucher '.$this->getRecord()->tracking_reference;
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('open_order')
|
||||
->label('Open order')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Shipment $record) => OrderResource::getUrl('order', ['record' => $record->order_id]))
|
||||
->visible(fn (Shipment $record) => $record->order_id !== null),
|
||||
CarrierVoucherResource::linkOrderAction(),
|
||||
CarrierVoucherResource::unlinkOrderAction(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -12,9 +12,8 @@ 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.
|
||||
* never edited here). "Print" opens the same signed label URL as the
|
||||
* order page (ShipmentResource::labelUrl()).
|
||||
*/
|
||||
class ShipmentsRelationManager extends RelationManager
|
||||
{
|
||||
@@ -33,7 +32,7 @@ class ShipmentsRelationManager extends RelationManager
|
||||
Action::make('print')
|
||||
->label('Print')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Shipment $record) => ShipmentResource::printShipment($record)),
|
||||
->url(fn (Shipment $record) => ShipmentResource::labelUrl($record), shouldOpenInNewTab: true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
@@ -21,12 +23,17 @@ use Throwable;
|
||||
* 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.
|
||||
* (ListShipments::getTabs()) 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.
|
||||
* Shows only shipments created through a carrier's API that are NOT yet on
|
||||
* an issued manifest — see Modules\Core\Shipping\Filament\Resources\
|
||||
* ManifestResource for shipments that already are. Manual carriers, manual
|
||||
* vouchers and carrier-reported (synced) vouchers have no label to print
|
||||
* or manifest to join, so they never show here.
|
||||
*
|
||||
* Print opens the label through the same signed URL as the order page; for
|
||||
* carriers that issue the voucher on print (ELTA), that first open is what
|
||||
* gives the shipment its voucher number.
|
||||
*/
|
||||
class ShipmentResource extends Resource
|
||||
{
|
||||
@@ -45,6 +52,8 @@ class ShipmentResource extends Resource
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->where('source', Shipment::SOURCE_CREATED)
|
||||
->whereNotNull('order_id')
|
||||
->whereNull('manifest_id')
|
||||
->whereNull('cancelled_at');
|
||||
}
|
||||
@@ -52,53 +61,132 @@ class ShipmentResource extends Resource
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('carrier')->badge(),
|
||||
TextColumn::make('tracking_reference')->label('Tracking #'),
|
||||
TextColumn::make('carrier')
|
||||
->badge()
|
||||
->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()),
|
||||
TextColumn::make('tracking_reference')
|
||||
->label('Tracking #')
|
||||
->placeholder('Not issued yet')
|
||||
->copyable(),
|
||||
TextColumn::make('order.reference')->label('Order'),
|
||||
TextColumn::make('created_at')->label('Created')->dateTime(),
|
||||
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)),
|
||||
->url(fn (Shipment $record) => self::labelUrl($record), shouldOpenInNewTab: true),
|
||||
Action::make('cancel')
|
||||
->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) => self::cancelShipment($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))),
|
||||
->deselectRecordsAfterCompletion()
|
||||
->action(fn (Collection $records, $livewire) => self::printSelected($records, $livewire)),
|
||||
BulkAction::make('issue_manifest')
|
||||
->label('Issue Manifest')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->visible(fn ($livewire) => filled($livewire->activeTab ?? null)
|
||||
&& self::fulfillmentService($livewire->activeTab) instanceof SupportsManifestBatching)
|
||||
->action(fn (Collection $records) => self::issueManifest($records)),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function printShipment(Shipment $shipment): void
|
||||
public static function labelUrl(Shipment $shipment): string
|
||||
{
|
||||
return URL::temporarySignedRoute('shipments.label', now()->addMinutes(5), ['shipment' => $shipment->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One combined PDF for carriers that support it (opened in a new tab,
|
||||
* with a link in the notification in case the browser blocks the
|
||||
* pop-up); anything else has to be printed row by row.
|
||||
*/
|
||||
public static function printSelected(Collection $shipments, $livewire): void
|
||||
{
|
||||
$carriers = $shipments->pluck('carrier')->unique();
|
||||
|
||||
if ($carriers->count() !== 1) {
|
||||
Notification::make()
|
||||
->title('Select shipments of a single carrier to print them together.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$shipment = $shipments->first();
|
||||
|
||||
if (! self::fulfillmentService($shipment->carrier) instanceof SupportsBatchLabels) {
|
||||
Notification::make()
|
||||
->title("{$shipment->carrierLabel()} labels can't be combined — print them individually.")
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$url = URL::temporarySignedRoute('shipments.labels', now()->addMinutes(5), [
|
||||
'ids' => $shipments->pluck('id')->sort()->implode(','),
|
||||
]);
|
||||
|
||||
$livewire->js('window.open('.json_encode($url).', "_blank")');
|
||||
|
||||
Notification::make()
|
||||
->title('Printing '.$shipments->count().' labels.')
|
||||
->success()
|
||||
->actions([
|
||||
Action::make('open')->label('Open PDF')->url($url, shouldOpenInNewTab: true),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
public static function cancelShipment(Shipment $shipment): void
|
||||
{
|
||||
if ($shipment->cancelsLocallyOnly()) {
|
||||
$shipment->update(['cancelled_at' => now()]);
|
||||
|
||||
Notification::make()
|
||||
->title('Shipment marked as cancelled. Void the voucher with the courier as well.')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = self::fulfillmentService($shipment->carrier);
|
||||
|
||||
if (! $service) {
|
||||
try {
|
||||
if (! $service) {
|
||||
throw new \RuntimeException("No fulfillment integration configured for {$shipment->carrierLabel()}.");
|
||||
}
|
||||
|
||||
$service->cancelShipment($shipment);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title("No fulfillment integration configured for {$shipment->carrier}.")
|
||||
->title('Failed to cancel shipment: '.$e->getMessage())
|
||||
->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();
|
||||
}
|
||||
Notification::make()
|
||||
->title('Shipment cancelled.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public static function issueManifest(Collection $shipments): void
|
||||
|
||||
@@ -6,17 +6,17 @@ 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\IssuesVoucherOnPrint;
|
||||
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.
|
||||
* One tab per carrier with a step between creating a shipment and handing
|
||||
* it over: batching into a manifest (SupportsManifestBatching — ACS) or
|
||||
* issuing the voucher when it's printed (IssuesVoucherOnPrint — ELTA). A
|
||||
* carrier with neither (Box Now books the pickup at creation) never gets a
|
||||
* tab. The tab list is derived from Shipping::getSupportedDrivers(), so a
|
||||
* new carrier implementing either contract needs no change here.
|
||||
*/
|
||||
class ListShipments extends ListRecords
|
||||
{
|
||||
@@ -24,13 +24,14 @@ class ListShipments extends ListRecords
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
$carriers = collect(Shipping::getSupportedDrivers())
|
||||
->keys()
|
||||
->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching);
|
||||
return collect(Shipping::getSupportedDrivers())
|
||||
->filter(function ($driver, string $carrier) {
|
||||
$service = ShipmentResource::fulfillmentService($carrier);
|
||||
|
||||
return $carriers->mapWithKeys(fn (string $carrier) => [
|
||||
$carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier)))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)),
|
||||
])->all();
|
||||
return $service instanceof SupportsManifestBatching || $service instanceof IssuesVoucherOnPrint;
|
||||
})
|
||||
->map(fn ($driver, string $carrier) => Tab::make($driver->name())
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)))
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user