460 lines
21 KiB
PHP
460 lines
21 KiB
PHP
<?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\BoxNowShipping;
|
|
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',
|
|
];
|
|
|
|
/**
|
|
* One color per source, the same on the list and the view page:
|
|
* ours (green), typed in by hand (orange), a manual carrier (grey),
|
|
* reported by the carrier (blue).
|
|
*/
|
|
private const SOURCE_COLORS = [
|
|
Shipment::SOURCE_CREATED => 'success',
|
|
Shipment::SOURCE_MANUAL_VOUCHER => 'warning',
|
|
Shipment::SOURCE_MANUAL => 'gray',
|
|
Shipment::SOURCE_SYNCED => 'info',
|
|
];
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
return self::vouchers(parent::getEloquentQuery())
|
|
->with(['order.shippingAddress', 'shipmentInfo']);
|
|
}
|
|
|
|
/**
|
|
* Vouchers with a number, plus ours still waiting for one (an ELTA
|
|
* voucher gets its number when printed) so they can be printed from
|
|
* here too.
|
|
*/
|
|
public static function vouchers(Builder $query): Builder
|
|
{
|
|
return $query->where(fn (Builder $q) => $q
|
|
->whereNotNull('tracking_reference')
|
|
->orWhere(fn (Builder $pending) => $pending
|
|
->where('source', Shipment::SOURCE_CREATED)
|
|
->whereNull('cancelled_at')));
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->defaultSort('created_at', 'desc')
|
|
->columns([
|
|
TextColumn::make('tracking_reference')
|
|
->label('Tracking number')
|
|
->placeholder('Not issued yet')
|
|
->description(fn (Shipment $record) => $record->parcelDescription())
|
|
->copyable(),
|
|
TextColumn::make('carrier')
|
|
->label('Carrier')
|
|
->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()),
|
|
TextColumn::make('source')
|
|
->label('Source')
|
|
->badge()
|
|
->formatStateUsing(fn (string $state) => self::SOURCES[$state] ?? $state)
|
|
->color(fn (string $state) => self::SOURCE_COLORS[$state] ?? 'gray'),
|
|
TextColumn::make('status')
|
|
->label('Status')
|
|
->state(fn (Shipment $record) => self::status($record))
|
|
->placeholder('None yet')
|
|
->badge()
|
|
->color(fn (Shipment $record) => self::statusColor($record)),
|
|
TextColumn::make('order.reference')
|
|
->label('Order')
|
|
->placeholder('None'),
|
|
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}%")
|
|
->orWhere('meta->recipient->name', 'ilike', "%{$search}%")
|
|
->orWhere('meta->recipient->phone', 'ilike', "%{$search}%")
|
|
->orWhere('meta->recipient->mobile', '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([
|
|
self::printAction(),
|
|
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')
|
|
->placeholder('Not issued yet — printing issues it')
|
|
->helperText(fn (Shipment $record) => $record->parcelDescription())
|
|
->copyable(),
|
|
TextEntry::make('source')
|
|
->badge()
|
|
->formatStateUsing(fn (string $state) => self::SOURCES[$state] ?? $state)
|
|
->color(fn (string $state) => self::SOURCE_COLORS[$state] ?? 'gray'),
|
|
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('sent_reference')
|
|
->label('Reference sent to the carrier')
|
|
->state(fn (Shipment $record) => self::sentReference($record))
|
|
->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) => self::postcode($record))
|
|
->placeholder('—'),
|
|
TextEntry::make('phone')
|
|
->state(fn (Shipment $record) => self::phone($record))
|
|
->placeholder('—'),
|
|
]),
|
|
// What the carrier's tracking reports beyond the checkpoints
|
|
// (ELTA's pel_rec) — filled in by the tracking polls.
|
|
Section::make('Delivery')
|
|
->columns(3)
|
|
->schema([
|
|
TextEntry::make('meta.delivered_to')->label('Received by')->placeholder('—'),
|
|
TextEntry::make('meta.delivered_at')->label('Received at')->placeholder('—'),
|
|
TextEntry::make('cod_remitted')
|
|
->label('COD paid back')
|
|
->state(fn (Shipment $record) => ($amount = $record->meta['cod_remitted'] ?? null)
|
|
? trim('€'.number_format((float) $amount, 2).' '.($record->meta['cod_remittance'] ?? ''))
|
|
: null)
|
|
->placeholder('—'),
|
|
TextEntry::make('meta.return_vg')->label('Return voucher')->placeholder('—'),
|
|
])
|
|
->visible(fn (Shipment $record) => filled($record->meta['delivered_to'] ?? null)
|
|
|| filled($record->meta['cod_remitted'] ?? null)
|
|
|| filled($record->meta['return_vg'] ?? null)),
|
|
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->inDisplayTimezone()->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),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Opens the voucher's label (the same signed link as the order page and
|
|
* Pending Vouchers) — for vouchers created here, from an order or with
|
|
* "Create voucher". Carrier-reported and typed-in vouchers have no label
|
|
* of ours to print.
|
|
*/
|
|
public static function printAction(): Action
|
|
{
|
|
return Action::make('print')
|
|
->label('Print')
|
|
->icon('heroicon-o-printer')
|
|
->url(fn (Shipment $record) => ShipmentResource::labelUrl($record), shouldOpenInNewTab: true)
|
|
->visible(fn (Shipment $record) => $record->hasCarrierLabel());
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* The reference the carrier has for this voucher: saved on the shipment
|
|
* (reported by the carrier, or sent by us since 0.29.2). For our own
|
|
* older vouchers it's what we sent them: the order reference, or
|
|
* "{reference}-{id}" for Box Now. Manual vouchers and manual carriers
|
|
* never sent one.
|
|
*/
|
|
private static function sentReference(Shipment $record): ?string
|
|
{
|
|
if (filled($record->meta['reference'] ?? null)) {
|
|
return $record->meta['reference'];
|
|
}
|
|
|
|
if ($record->source !== Shipment::SOURCE_CREATED || ! $record->order) {
|
|
return null;
|
|
}
|
|
|
|
return BoxNowShipping::isDriver($record->carrier)
|
|
? $record->order->reference.'-'.$record->order->id
|
|
: $record->order->reference;
|
|
}
|
|
|
|
/*
|
|
* A synced voucher's recipient is what the carrier reported
|
|
* (meta.recipient_name / phone / postcode); our own vouchers' is the
|
|
* one saved when they were created (Shipment::recipient()).
|
|
*/
|
|
|
|
private static function phone(Shipment $record): ?string
|
|
{
|
|
return $record->meta['phone'] ?? ($record->recipient()?->phones() ?: null);
|
|
}
|
|
|
|
private static function recipient(Shipment $record): ?string
|
|
{
|
|
return $record->meta['recipient_name'] ?? ($record->recipient()?->name ?: null);
|
|
}
|
|
|
|
private static function postcode(Shipment $record): ?string
|
|
{
|
|
return $record->meta['postcode'] ?? ($record->recipient()?->postcode ?: 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}'),
|
|
];
|
|
}
|
|
}
|