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]);
+ }
+}