From 4acabe4185ffb8ba454ffa338811a486183f3bc7 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 00:52:01 +0300 Subject: [PATCH] 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. --- composer.json | 3 +- .../pages/manage-pickup-manifests.blade.php | 3 + src/CorePlugin.php | 13 ++- src/Providers/ShippingServiceProvider.php | 97 ++++++++++++++++ .../Extensions/OrderViewExtension.php | 101 +++++++++++++++++ .../ShippingMethodListExtension.php | 48 ++++++++ .../ShippingMethodResourceExtension.php | 83 ++++++++++++++ .../Filament/Pages/ManagePickupManifests.php | 106 ++++++++++++++++++ 8 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php create mode 100644 src/Providers/ShippingServiceProvider.php create mode 100644 src/Shipping/Extensions/OrderViewExtension.php create mode 100644 src/Shipping/Extensions/ShippingMethodListExtension.php create mode 100644 src/Shipping/Extensions/ShippingMethodResourceExtension.php create mode 100644 src/Shipping/Filament/Pages/ManagePickupManifests.php diff --git a/composer.json b/composer.json index 84768af..cac7ef2 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "providers": [ "Modules\\Core\\Providers\\CoreServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider", - "Modules\\Core\\Providers\\CustomerServiceProvider" + "Modules\\Core\\Providers\\CustomerServiceProvider", + "Modules\\Core\\Providers\\ShippingServiceProvider" ] } }, diff --git a/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php b/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php new file mode 100644 index 0000000..ce096a2 --- /dev/null +++ b/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php @@ -0,0 +1,3 @@ + + {{ $this->table }} + diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 38a356f..a9ef2b4 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -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 { diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php new file mode 100644 index 0000000..0adecbb --- /dev/null +++ b/src/Providers/ShippingServiceProvider.php @@ -0,0 +1,97 @@ +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); + } +} diff --git a/src/Shipping/Extensions/OrderViewExtension.php b/src/Shipping/Extensions/OrderViewExtension.php new file mode 100644 index 0000000..93a4722 --- /dev/null +++ b/src/Shipping/Extensions/OrderViewExtension.php @@ -0,0 +1,101 @@ +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]); + } +} diff --git a/src/Shipping/Extensions/ShippingMethodListExtension.php b/src/Shipping/Extensions/ShippingMethodListExtension.php new file mode 100644 index 0000000..f9d9b0c --- /dev/null +++ b/src/Shipping/Extensions/ShippingMethodListExtension.php @@ -0,0 +1,48 @@ +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'); + } +} diff --git a/src/Shipping/Extensions/ShippingMethodResourceExtension.php b/src/Shipping/Extensions/ShippingMethodResourceExtension.php new file mode 100644 index 0000000..0aa8cd6 --- /dev/null +++ b/src/Shipping/Extensions/ShippingMethodResourceExtension.php @@ -0,0 +1,83 @@ +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 $components + * @return array + */ + 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'); + } +} diff --git a/src/Shipping/Filament/Pages/ManagePickupManifests.php b/src/Shipping/Filament/Pages/ManagePickupManifests.php new file mode 100644 index 0000000..993c513 --- /dev/null +++ b/src/Shipping/Filament/Pages/ManagePickupManifests.php @@ -0,0 +1,106 @@ +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]); + } +}