Feature: Wire up carrier-agnostic shipping admin UI
Registers ShippingServiceProvider (carrier config, driver/fulfillment bindings, Rates page override) and wires the shipping admin surface into CorePlugin: dynamic carrier dropdown on Shipping Method create/edit, a "Create Shipment" order action resolved generically by carrier, and a Pickup Manifests page for carriers that support manifest batching.
This commit is contained in:
+12
-1
@@ -6,17 +6,24 @@ use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Lunar\Admin\Filament\Resources\OrderResource;
|
||||
use Lunar\Admin\Filament\Resources\ProductResource;
|
||||
use Lunar\Admin\Filament\Resources\StaffResource;
|
||||
use Lunar\Admin\Models\Staff as LunarStaff;
|
||||
use Lunar\Admin\Support\Facades\LunarPanel;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethod;
|
||||
use Lunar\Shipping\ShippingPlugin;
|
||||
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
||||
use Modules\Core\Auth\Filament\Pages\Login;
|
||||
use Modules\Core\Auth\Mail\InviteMail;
|
||||
use Modules\Core\Review\Extensions\ProductResourceExtension;
|
||||
use Modules\Core\Review\Models\ProductReview;
|
||||
use Modules\Core\Shipping\Extensions\OrderViewExtension;
|
||||
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
|
||||
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
|
||||
use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests;
|
||||
|
||||
class CorePlugin implements Plugin
|
||||
{
|
||||
@@ -32,11 +39,15 @@ class CorePlugin implements Plugin
|
||||
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
|
||||
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
|
||||
->login(Login::class)
|
||||
->plugin(ShippingPlugin::make());
|
||||
->plugin(ShippingPlugin::make())
|
||||
->pages([ManagePickupManifests::class]);
|
||||
|
||||
LunarPanel::extensions([
|
||||
StaffResource::class => StaffResourceExtension::class,
|
||||
ProductResource::class => ProductResourceExtension::class,
|
||||
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
|
||||
ListShippingMethod::class => ShippingMethodListExtension::class,
|
||||
OrderResource\Pages\ManageOrder::class => OrderViewExtension::class,
|
||||
]);
|
||||
|
||||
Product::macro('reviews', function (): HasMany {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
use Livewire\Mechanisms\ComponentRegistry;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Modules\Core\Shipping\Carriers\Acs\AcsClient;
|
||||
use Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService;
|
||||
use Modules\Core\Shipping\Carriers\Acs\AcsRateDriver;
|
||||
use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
|
||||
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
|
||||
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
|
||||
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
class ShippingServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
|
||||
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
|
||||
|
||||
$this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
|
||||
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
|
||||
|
||||
$this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
|
||||
return match ($params['carrier'] ?? null) {
|
||||
'acs' => $app->make(AcsFulfillmentService::class),
|
||||
'box-now' => $app->make(BoxNowFulfillmentService::class),
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
|
||||
// The vendor Rates page has no extension hook, so we swap it for
|
||||
// our subclass everywhere. Route::get($path, VendorClass::class)
|
||||
// instantiates the vendor class directly via the container for the
|
||||
// initial full-page load (bypassing Livewire's component registry
|
||||
// entirely), so this container bind is required in addition to the
|
||||
// Livewire::component() re-registration below — the bind covers
|
||||
// first load, the Livewire registration covers every AJAX
|
||||
// round-trip (form submits, table interactions) afterwards.
|
||||
$this->app->bind(VendorManageShippingRates::class, ManageShippingRates::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->publishes([
|
||||
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
|
||||
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
|
||||
], 'core-config');
|
||||
|
||||
Order::resolveRelationUsing('shipments', function ($order) {
|
||||
return $order->hasMany(Shipment::class);
|
||||
});
|
||||
|
||||
// Deferred: the Shipping facade resolves a binding registered in
|
||||
// lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(),
|
||||
// and provider boot order between packages isn't guaranteed.
|
||||
$this->app->booted(function () {
|
||||
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
|
||||
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
|
||||
|
||||
$this->app->make(ConsoleSchedule::class)
|
||||
->job(new WarmAcsAreaCacheJob)
|
||||
->dailyAt('06:00')
|
||||
->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
|
||||
|
||||
$this->overrideRatesPageLivewireComponent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The vendor Rates page has no extension hook, so we swap it for our
|
||||
* subclass (see Shipping/Filament/Pages/ManageShippingRates). Filament
|
||||
* already registered the vendor class as a Livewire component under a
|
||||
* name derived from its class string (see
|
||||
* Panel::registerLivewireComponents()); Livewire's own registry is a
|
||||
* simple last-write-wins name => class map, so re-registering the same
|
||||
* derived name against our subclass here overrides it — keeping the
|
||||
* route, sub-navigation, and every Livewire round-trip (including form
|
||||
* submissions) pointed at one consistent component identity.
|
||||
*/
|
||||
private function overrideRatesPageLivewireComponent(): void
|
||||
{
|
||||
$name = $this->app->make(ComponentRegistry::class)->getName(VendorManageShippingRates::class);
|
||||
|
||||
Livewire::component($name, ManageShippingRates::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Extensions;
|
||||
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Lunar\Admin\Support\Extending\ViewPageExtension;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
|
||||
class OrderViewExtension extends ViewPageExtension
|
||||
{
|
||||
public function headerActions(array $actions): array
|
||||
{
|
||||
$actions[] = $this->createShipmentAction();
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function createShipmentAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('create_shipment')
|
||||
->label('Create Shipment')
|
||||
->icon('heroicon-o-truck')
|
||||
->modalSubmitActionLabel('Create Shipment')
|
||||
->form([
|
||||
Forms\Components\Toggle::make('confirm')
|
||||
->label('Confirm')
|
||||
->helperText('This will create a real shipment with the carrier.')
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if ($value !== true) {
|
||||
$fail('Please confirm before creating the shipment.');
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
])
|
||||
->action(function (Order $record, Actions\Action $action) {
|
||||
$service = $this->resolveFulfillmentService($record);
|
||||
|
||||
if (! $service) {
|
||||
Notification::make()
|
||||
->title('No carrier fulfillment integration is configured for this order.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
$action->halt();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$service->createShipment($record);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title('Failed to create shipment: '.$e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
$action->halt();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Shipment created.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Order $record) => $record->shipments()->exists() === false
|
||||
&& $this->resolveFulfillmentService($record) !== null);
|
||||
}
|
||||
|
||||
private function resolveCarrier(Order $record): ?string
|
||||
{
|
||||
$code = $record->shippingAddress?->shipping_option;
|
||||
|
||||
if (! $code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ShippingMethod::where('code', $code)->value('driver');
|
||||
}
|
||||
|
||||
private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface
|
||||
{
|
||||
$carrier = $this->resolveCarrier($record);
|
||||
|
||||
if (! $carrier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Extensions;
|
||||
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Group;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Lunar\Admin\Support\Extending\BaseExtension;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
|
||||
|
||||
/**
|
||||
* ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's
|
||||
* form inline (calling ShippingMethodResource::getDriverFormComponent()
|
||||
* directly, a hardcoded 2-option Select) rather than through the resource's
|
||||
* own extendForm() pipeline, so ShippingMethodResourceExtension's driver
|
||||
* fix never reaches it. Re-declares the same create-action form with a
|
||||
* dynamic driver Select instead.
|
||||
*/
|
||||
class ShippingMethodListExtension extends BaseExtension
|
||||
{
|
||||
public function headerActions(array $actions): array
|
||||
{
|
||||
foreach ($actions as $action) {
|
||||
if ($action instanceof Actions\CreateAction) {
|
||||
$action->form([
|
||||
ShippingMethodResource::getNameFormComponent(),
|
||||
Group::make([
|
||||
ShippingMethodResource::getCodeFormComponent(),
|
||||
$this->driverSelect(),
|
||||
])->columns(2),
|
||||
ShippingMethodResource::getDescriptionFormComponent(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function driverSelect(): Select
|
||||
{
|
||||
return Select::make('driver')
|
||||
->label('Type')
|
||||
->options(fn () => collect(Shipping::getSupportedDrivers())
|
||||
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
|
||||
->default('flat-rate');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Extensions;
|
||||
|
||||
use Filament\Forms\Components\Component;
|
||||
use Filament\Forms\Components\Concerns\HasChildComponents;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
|
||||
class ShippingMethodResourceExtension extends ResourceExtension
|
||||
{
|
||||
public function extendForm(Form $form): Form
|
||||
{
|
||||
return $form->schema(
|
||||
$this->replaceDriverField($form->getComponents())
|
||||
);
|
||||
}
|
||||
|
||||
public function extendTable(Table $table): Table
|
||||
{
|
||||
return $table->columns(
|
||||
array_map(function ($column) {
|
||||
if (method_exists($column, 'getName') && $column->getName() === 'driver') {
|
||||
return $this->driverColumn();
|
||||
}
|
||||
|
||||
return $column;
|
||||
}, $table->getColumns())
|
||||
);
|
||||
}
|
||||
|
||||
private function driverColumn(): TextColumn
|
||||
{
|
||||
return TextColumn::make('driver')
|
||||
->label('Type')
|
||||
->formatStateUsing(fn ($state) => $this->driverLabel($state));
|
||||
}
|
||||
|
||||
private function driverLabel(string $key): string
|
||||
{
|
||||
$driver = collect(Shipping::getSupportedDrivers())->get($key);
|
||||
|
||||
return $driver?->name() ?? $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk the form tree and replace the hardcoded driver
|
||||
* Select (nested inside Section > Group) with one listing every
|
||||
* registered driver, built-in or custom.
|
||||
*
|
||||
* @param array<Component> $components
|
||||
* @return array<Component>
|
||||
*/
|
||||
private function replaceDriverField(array $components): array
|
||||
{
|
||||
return array_map(function (Component $component) {
|
||||
if (method_exists($component, 'getName') && $component->getName() === 'driver') {
|
||||
return $this->driverSelect();
|
||||
}
|
||||
|
||||
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
|
||||
$component->schema(
|
||||
$this->replaceDriverField($component->getChildComponents())
|
||||
);
|
||||
}
|
||||
|
||||
return $component;
|
||||
}, $components);
|
||||
}
|
||||
|
||||
private function driverSelect(): Select
|
||||
{
|
||||
return Select::make('driver')
|
||||
->label('Type')
|
||||
->options(fn () => collect(Shipping::getSupportedDrivers())
|
||||
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
|
||||
->default('flat-rate');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Pages;
|
||||
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\BulkAction;
|
||||
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 $navigationIcon = 'heroicon-o-truck';
|
||||
|
||||
protected static ?string $navigationLabel = 'Pickup Manifests';
|
||||
|
||||
protected static 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'),
|
||||
])
|
||||
->actions([
|
||||
Action::make('print')
|
||||
->label('Print')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Shipment $record) => $this->printShipment($record)),
|
||||
])
|
||||
->bulkActions([
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user