Feature: Order Updates, Events, Order Flows, Shipment And COD support

This commit is contained in:
2026-09-14 00:03:06 +03:00
parent 78bbd8390a
commit 44c6b7defd
73 changed files with 2854 additions and 464 deletions
@@ -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(),
]);
}
}
@@ -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();
}
}