diff --git a/composer.json b/composer.json index 4acfd07..7955955 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,8 @@ "Modules\\Core\\Providers\\LocalizationServiceProvider", "Modules\\Core\\Providers\\CatalogServiceProvider", "Modules\\Core\\Providers\\CartServiceProvider", - "Modules\\Core\\Providers\\ReviewServiceProvider" + "Modules\\Core\\Providers\\ReviewServiceProvider", + "Modules\\Core\\Providers\\ShippingServiceProvider" ] } }, diff --git a/config/shippingCarriers/acs.php b/config/shippingCarriers/acs.php new file mode 100644 index 0000000..cea2928 --- /dev/null +++ b/config/shippingCarriers/acs.php @@ -0,0 +1,50 @@ + env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'), + + 'api_key' => env('ACS_API_KEY'), + + 'company_id' => env('ACS_COMPANY_ID'), + 'company_password' => env('ACS_COMPANY_PASSWORD'), + 'user_id' => env('ACS_USER_ID'), + 'user_password' => env('ACS_USER_PASSWORD'), + + 'billing_code' => env('ACS_BILLING_CODE'), + + 'sender' => [ + 'name' => env('ACS_SENDER_NAME'), + 'address' => env('ACS_SENDER_ADDRESS'), + 'zip_code' => env('ACS_SENDER_ZIP'), + 'phone' => env('ACS_SENDER_PHONE'), + ], + + 'timeout' => env('ACS_HTTP_TIMEOUT', 10), + +]; diff --git a/config/shippingCarriers/boxnow.php b/config/shippingCarriers/boxnow.php new file mode 100644 index 0000000..672b95f --- /dev/null +++ b/config/shippingCarriers/boxnow.php @@ -0,0 +1,47 @@ + env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'), + 'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'), + + 'client_id' => env('BOXNOW_CLIENT_ID'), + 'client_secret' => env('BOXNOW_CLIENT_SECRET'), + + 'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'), + + 'sender' => [ + 'name' => env('BOXNOW_SENDER_NAME'), + 'email' => env('BOXNOW_SENDER_EMAIL'), + 'phone' => env('BOXNOW_SENDER_PHONE'), + ], + + 'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10), + +]; diff --git a/database/migrations/2026_07_16_000001_create_shipments_table.php b/database/migrations/2026_07_16_000001_create_shipments_table.php new file mode 100644 index 0000000..ffccb42 --- /dev/null +++ b/database/migrations/2026_07_16_000001_create_shipments_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('order_id')->constrained(config('lunar.database.table_prefix').'orders'); + $table->string('carrier'); + $table->string('tracking_reference')->unique(); + $table->string('parent_reference')->nullable(); + $table->timestamp('label_printed_at')->nullable(); + $table->string('manifest_reference')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipments'); + } +}; diff --git a/database/migrations/2026_07_21_000001_create_shipment_info_table.php b/database/migrations/2026_07_21_000001_create_shipment_info_table.php new file mode 100644 index 0000000..fbfe887 --- /dev/null +++ b/database/migrations/2026_07_21_000001_create_shipment_info_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('shipment_id')->constrained('shipments')->cascadeOnDelete(); + $table->string('status'); + $table->string('carrier_status')->nullable(); + $table->text('message')->nullable(); + $table->string('location')->nullable(); + $table->timestamp('occurred_at'); + $table->json('meta')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipment_info'); + } +}; 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 e18c627..e5c0920 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -8,11 +8,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Mail; use Lunar\Admin\Filament\Resources\ProductOptionResource; use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager; +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; @@ -23,6 +26,10 @@ use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Review\Filament\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 { @@ -42,13 +49,18 @@ class CorePlugin implements Plugin LanguageLineResource::class, CartResource::class, ]) - ->plugin(ShippingPlugin::make()); + ->plugin(ShippingPlugin::make()) + ->plugin(ShippingPlugin::make()) + ->pages([ManagePickupManifests::class]); LunarPanel::extensions([ StaffResource::class => StaffResourceExtension::class, ProductResource::class => ProductResourceExtension::class, ProductOptionResource::class => ProductOptionResourceExtension::class, ValuesRelationManager::class => ValuesRelationManagerExtension::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..c93b899 --- /dev/null +++ b/src/Providers/ShippingServiceProvider.php @@ -0,0 +1,102 @@ +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->app->make(ConsoleSchedule::class) + ->job(new PollShipmentTrackingJob) + ->everyThirtyMinutes(); + + $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/Carriers/Acs/AcsArea.php b/src/Shipping/Carriers/Acs/AcsArea.php new file mode 100644 index 0000000..a4fe1b5 --- /dev/null +++ b/src/Shipping/Carriers/Acs/AcsArea.php @@ -0,0 +1,11 @@ + $this->config['api_key'], + ]) + ->timeout($this->config['timeout']) + ->post($this->config['base_url'], [ + 'ACSAlias' => $alias, + 'ACSInputParameters' => array_merge($this->credentialParams(), $parameters), + ]); + + return AcsResponse::fromHttpResponse($response); + } + + private function credentialParams(): array + { + return [ + 'Company_ID' => $this->config['company_id'], + 'Company_Password' => $this->config['company_password'], + 'User_ID' => $this->config['user_id'], + 'User_Password' => $this->config['user_password'], + ]; + } +} diff --git a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php new file mode 100644 index 0000000..a40b9fb --- /dev/null +++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php @@ -0,0 +1,202 @@ +shippingAddress; + $destination = $this->areaResolver->resolve($address->postcode); + $weight = $request->weight ?? 0.5; + + $params = [ + 'Pickup_Date' => now()->toDateString(), + 'Sender' => config('acs.sender.name'), + 'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"), + 'Recipient_Address' => $address->line_one, + 'Recipient_Zipcode' => $address->postcode, + 'Recipient_Region' => $address->city, + 'Recipient_Phone' => $address->contact_phone, + 'Recipient_Country' => 'GR', + 'Acs_Station_Branch_Destination' => $destination->branchId, + 'Billing_Code' => config('acs.billing_code'), + 'Charge_Type' => 2, + 'Item_Quantity' => $request->packageCount, + 'Weight' => $weight, + ]; + + if ($request->paymentMode === 'cod') { + $params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal; + $params['Cod_Payment_Way'] = 0; // cash + $params['Acs_Delivery_Products'] = 'COD'; + } + + $response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError(); + + $voucherNo = (string) $response->valueOutput['Voucher_No']; + + $shipment = Shipment::create([ + 'order_id' => $order->id, + 'carrier' => 'acs', + 'tracking_reference' => $voucherNo, + 'meta' => [ + 'station_destination' => $destination->stationId, + 'weight' => $weight, + 'pickup_date' => now()->toDateString(), + ], + ]); + + if ($request->packageCount > 1) { + $this->persistMultipartVouchers($shipment); + } + + return $shipment; + } + + public function printLabel(Shipment $shipment): string + { + $response = $this->client->call('ACS_Print_Voucher', [ + 'Voucher_No' => $shipment->tracking_reference, + 'Print_Type' => 2, + 'Start_Position' => 1, + ])->throwIfError(); + + $shipment->update(['label_printed_at' => now()]); + + return $response->valueOutput[$shipment->tracking_reference] ?? ''; + } + + public function cancelShipment(Shipment $shipment): void + { + if ($shipment->manifest_reference) { + throw new \RuntimeException('Cannot cancel a shipment already included in an issued manifest.'); + } + + $this->client->call('ACS_Delete_Voucher', [ + 'Voucher_No' => $shipment->tracking_reference, + ])->throwIfError(); + + $shipment->update(['cancelled_at' => now()]); + } + + public function pendingForManifest(): Collection + { + return Shipment::query() + ->where('carrier', 'acs') + ->whereNull('manifest_reference') + ->whereNull('cancelled_at') + ->get(); + } + + public function issueManifest(Collection $shipments): ManifestResult + { + $unprinted = $shipments->whereNull('label_printed_at'); + + if ($unprinted->isNotEmpty()) { + return ManifestResult::blocked($unprinted, 'unprinted'); + } + + $response = $this->client->call('ACS_Issue_Pickup_List', [ + 'Pickup_Date' => now()->toDateString(), + 'MyData' => null, + ])->throwIfError(); + + $pickupListNo = (string) $response->valueOutput['PickupList_No']; + + $shipments->each(fn (Shipment $shipment) => $shipment->update([ + 'manifest_reference' => $pickupListNo, + ])); + + return ManifestResult::success($pickupListNo, $shipments); + } + + public function trackShipment(Shipment $shipment): Collection + { + $response = $this->client->call('ACS_TrackingDetails', [ + 'Voucher_No' => $shipment->tracking_reference, + ])->throwIfError(); + + $rows = $response->tableOutput['Table_Data'] ?? []; + + // ACS's per-checkpoint data (checkpoint_action) is free text with no + // status code, so the final checkpoint's status is corroborated + // against the structured summary call rather than guessed from text. + $isDelivered = $this->isDelivered($shipment); + + return collect($rows)->values()->map(function (array $row, int $index) use ($rows, $isDelivered) { + $isLast = $index === count($rows) - 1; + + return new TrackingCheckpoint( + status: $isLast && $isDelivered + ? TrackingStatus::Delivered + : $this->guessStatusFromAction($row['checkpoint_action'] ?? ''), + carrierStatus: $row['checkpoint_action'] ?? null, + message: $row['checkpoint_action'] ?? null, + location: $row['checkpoint_location'] ?? null, + occurredAt: Carbon::parse($row['checkpoint_date_time']), + meta: $row, + ); + }); + } + + private function isDelivered(Shipment $shipment): bool + { + try { + $response = $this->client->call('ACS_Trackingsummary', [ + 'Voucher_No' => $shipment->tracking_reference, + ])->throwIfError(); + } catch (AcsApiException) { + return false; + } + + return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4; + } + + private function guessStatusFromAction(string $action): TrackingStatus + { + $action = strtolower($action); + + return match (true) { + str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered, + str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery, + str_contains($action, 'arrival') || str_contains($action, 'departure') => TrackingStatus::InTransit, + default => TrackingStatus::Pending, + }; + } + + private function persistMultipartVouchers(Shipment $mainShipment): void + { + $response = $this->client->call('ACS_Get_Multipart_Vouchers', [ + 'Main_Voucher_No' => $mainShipment->tracking_reference, + ])->throwIfError(); + + foreach ($response->tableOutput['Table_Data'] ?? [] as $row) { + Shipment::create([ + 'order_id' => $mainShipment->order_id, + 'carrier' => 'acs', + 'tracking_reference' => $row['MultiPart_Voucher_No'], + 'parent_reference' => $mainShipment->tracking_reference, + 'meta' => $mainShipment->meta?->toArray() ?? [], + ]); + } + } +} diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php new file mode 100644 index 0000000..6c8af72 --- /dev/null +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -0,0 +1,116 @@ +shippingRate; + $shippingMethod = $shippingRate->shippingMethod; + $cart = $shippingOptionRequest->cart; + + if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') { + return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); + } + + $postcode = $cart->shippingAddress?->postcode; + + if (! $postcode) { + return null; + } + + return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode); + } + + private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption + { + try { + $destination = $this->areaResolver->resolve($postcode); + + $response = $this->client->call('ACS_Price_Calculation', [ + 'Billing_Code' => config('acs.billing_code'), + 'Acs_Station_Destination' => $destination->stationId, + 'Weight' => $this->totalWeightInKg($cart), + 'Pickup_Date' => now()->toDateString(), + 'Charge_Type' => 2, + ])->throwIfError(); + } catch (AcsApiException $e) { + report($e); + + return null; + } + + $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100); + + return new ShippingOption( + name: $shippingMethod->name ?: $this->name(), + description: $shippingMethod->description ?: $this->description(), + identifier: $shippingRate->getIdentifier(), + price: new Price($amount, $cart->currency, 1), + taxClass: $shippingRate->getTaxClass(), + taxReference: $shippingRate->getTaxReference(), + meta: ['acs_station_destination' => $destination->stationId], + ); + } + + public function on(ShippingRate $shippingRate): self + { + $this->shippingRate = $shippingRate; + + return $this; + } + + private function totalWeightInKg($cart): float + { + $weight = 0.0; + + foreach ($cart->lines->load('purchasable') as $line) { + $variant = $line->purchasable; + + if (! $variant || ! $variant->weight_value) { + continue; + } + + $unit = $variant->weight_unit ?? 'kg'; + $value = (float) $variant->weight_value; + + $weight += match ($unit) { + 'g' => $value / 1000, + 'lb' => $value * 0.45359237, + 'oz' => $value * 0.0283495231, + default => $value, // kg + } * $line->quantity; + } + + return max($weight, 0.5); // ACS minimum billable weight + } +} diff --git a/src/Shipping/Carriers/Acs/AcsResponse.php b/src/Shipping/Carriers/Acs/AcsResponse.php new file mode 100644 index 0000000..3e1255d --- /dev/null +++ b/src/Shipping/Carriers/Acs/AcsResponse.php @@ -0,0 +1,43 @@ +json() ?? []; + + // ACS's own JSON key is misspelled ("Responce") — preserved here verbatim. + $output = $body['ACSOutputResponce'] ?? []; + + return new self( + hasError: (bool) ($body['ACSExecution_HasError'] ?? ! $response->successful()), + errorMessage: $body['ACSExecutionErrorMessage'] ?? null, + valueOutput: $output['ACSValueOutput'][0] ?? [], + tableOutput: $output['ACSTableOutput'] ?? [], + ); + } + + public function throwIfError(): self + { + if ($this->hasError) { + throw new AcsApiException( + $this->errorMessage ?? ($this->valueOutput['Error_Message'] ?? 'Unknown ACS API error'), + $this->tableOutput, + ); + } + + return $this; + } +} diff --git a/src/Shipping/Carriers/Acs/AreaResolver.php b/src/Shipping/Carriers/Acs/AreaResolver.php new file mode 100644 index 0000000..a87398f --- /dev/null +++ b/src/Shipping/Carriers/Acs/AreaResolver.php @@ -0,0 +1,81 @@ +toArea($areas[$postcode]); + } + + return $this->toArea($this->fetch($postcode)); + } + + /** + * Fetch and cache the full country's postcode-to-station map in one call. + */ + public function warmAll(): void + { + $areas = []; + + foreach ($this->fetchAll() as $row) { + $areas[$row['Zip_Code']] = $row; + } + + Cache::forever(self::CACHE_KEY, $areas); + } + + private function fetch(string $postcode): array + { + $response = $this->client->call('ACS_Area_Find_By_Zip_Code', [ + 'Zip_Code' => $postcode, + 'Show_Only_Inaccessible_Areas' => 0, + 'Country' => 'GR', + ])->throwIfError(); + + $area = $response->tableOutput['Table_Data'][0] ?? null; + + if (! $area) { + throw new AcsApiException("No ACS area found for postcode {$postcode}"); + } + + return $area; + } + + private function fetchAll(): array + { + $response = $this->client->call('ACS_Area_Find_By_Zip_Code', [ + 'Zip_Code' => null, + 'Show_Only_Inaccessible_Areas' => 0, + 'Country' => 'GR', + ])->throwIfError(); + + return $response->tableOutput['Table_Data'] ?? []; + } + + private function toArea(array $row): AcsArea + { + return new AcsArea( + stationId: $row['Station_ID'], + branchId: (int) $row['Branch_ID'], + ); + } +} diff --git a/src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php b/src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php new file mode 100644 index 0000000..75ed50c --- /dev/null +++ b/src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php @@ -0,0 +1,13 @@ +warmAll(); + } +} diff --git a/src/Shipping/Carriers/BoxNow/BoxNowClient.php b/src/Shipping/Carriers/BoxNow/BoxNowClient.php new file mode 100644 index 0000000..dd8bbea --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/BoxNowClient.php @@ -0,0 +1,97 @@ +token()) + ->timeout($this->config['timeout']) + ->{$method}("{$this->config['base_url']}{$path}", $payload); + + if ($response->status() === 401) { + // Token expired early / was revoked — refresh once and retry. + Cache::forget(self::TOKEN_CACHE_KEY); + + $response = Http::withToken($this->token()) + ->timeout($this->config['timeout']) + ->{$method}("{$this->config['base_url']}{$path}", $payload); + } + + if ($response->failed()) { + throw new BoxNowApiException( + "Box Now API error ({$response->status()}) on {$method} {$path}", + $response->json() ?? [], + ); + } + + return $response->json() ?? []; + } + + /** + * The origins/destinations lookups are served faster from a separate + * location API host, per Box Now's own documentation. + */ + public function locationRequest(string $path, array $query = []): array + { + $response = Http::withToken($this->token()) + ->timeout($this->config['timeout']) + ->get("{$this->config['location_api_url']}{$path}", $query); + + if ($response->failed()) { + throw new BoxNowApiException( + "Box Now location API error ({$response->status()}) on GET {$path}", + $response->json() ?? [], + ); + } + + return $response->json() ?? []; + } + + /** + * Fetch raw bytes (e.g. a PDF label) rather than JSON. + */ + public function requestRaw(string $path): string + { + $response = Http::withToken($this->token()) + ->timeout($this->config['timeout']) + ->get("{$this->config['base_url']}{$path}"); + + if ($response->failed()) { + throw new BoxNowApiException("Box Now API error ({$response->status()}) on GET {$path}"); + } + + return $response->body(); + } + + private function token(): string + { + return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(55), function () { + $response = Http::timeout($this->config['timeout']) + ->post("{$this->config['base_url']}/auth-sessions", [ + 'grant_type' => 'client_credentials', + 'client_id' => $this->config['client_id'], + 'client_secret' => $this->config['client_secret'], + ]); + + if ($response->failed()) { + throw new BoxNowApiException('Box Now authentication failed', $response->json() ?? []); + } + + return $response->json('access_token'); + }); + } +} diff --git a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php new file mode 100644 index 0000000..ea32ea7 --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php @@ -0,0 +1,150 @@ +shippingAddress; + $destinationLocationId = $request->destinationLocationId; + + if (! $destinationLocationId) { + throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.'); + } + + $isCod = $request->paymentMode === 'cod'; + + $response = $this->client->request('post', '/delivery-requests', [ + 'orderNumber' => $order->reference.'-'.$order->id, + 'invoiceValue' => number_format($order->total->decimal, 2, '.', ''), + 'paymentMode' => $isCod ? 'cod' : 'prepaid', + 'amountToBeCollected' => $isCod + ? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '') + : '0.00', + 'origin' => [ + 'contactNumber' => config('boxnow.sender.phone'), + 'contactEmail' => config('boxnow.sender.email'), + 'contactName' => config('boxnow.sender.name'), + 'locationId' => config('boxnow.origin_location_id'), + ], + 'destination' => [ + 'contactNumber' => $address->contact_phone, + 'contactEmail' => $address->contact_email, + 'contactName' => trim("{$address->first_name} {$address->last_name}"), + 'locationId' => $destinationLocationId, + ], + 'items' => [ + [ + 'id' => (string) $order->id, + 'name' => 'Order '.$order->reference, + 'value' => '0.00', + 'compartmentSize' => 1, + 'weight' => $request->weight ?? 0, + ], + ], + ]); + + $parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException( + 'Box Now delivery request succeeded but returned no parcel id.', + $response, + )); + + return Shipment::create([ + 'order_id' => $order->id, + 'carrier' => 'box-now', + 'tracking_reference' => $parcelId, + 'meta' => [ + 'delivery_request_id' => $response['id'] ?? null, + 'locker_id' => $destinationLocationId, + ], + ]); + } + + public function printLabel(Shipment $shipment): string + { + $bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf"); + + $shipment->update(['label_printed_at' => now()]); + + return $bytes; + } + + public function cancelShipment(Shipment $shipment): void + { + $this->client->request('post', "/parcels/{$shipment->tracking_reference}:cancel"); + + $shipment->update(['cancelled_at' => now()]); + } + + public function trackShipment(Shipment $shipment): Collection + { + $response = $this->client->request('get', '/parcels', [ + 'parcelId' => $shipment->tracking_reference, + ]); + + $parcel = $response['data'][0] ?? null; + + if (! $parcel) { + return collect(); + } + + $events = $parcel['events'] ?? []; + + // Fall back to a single checkpoint from the parcel's current state + // if Box Now didn't return a detailed events history. + if (empty($events)) { + $events = [[ + 'type' => $parcel['state'] ?? 'new', + 'locationDisplayName' => null, + 'createTime' => $parcel['updateTime'] ?? $parcel['createTime'] ?? now()->toIso8601String(), + ]]; + } + + return collect($events)->map(fn (array $event) => new TrackingCheckpoint( + status: $this->mapState($event['type'] ?? $parcel['state'] ?? 'new'), + carrierStatus: $event['type'] ?? $parcel['state'] ?? null, + message: null, + location: $event['locationDisplayName'] ?? null, + occurredAt: Carbon::parse($event['createTime']), + meta: $event, + )); + } + + private function mapState(string $state): TrackingStatus + { + return match ($state) { + 'new' => TrackingStatus::Pending, + 'in-transit', 'in-depot' => TrackingStatus::InTransit, + 'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery, + 'delivered' => TrackingStatus::Delivered, + 'returned', 'accepted-for-return' => TrackingStatus::Returned, + 'cancelled' => TrackingStatus::Cancelled, + 'expired-return', 'missing' => TrackingStatus::Failed, + default => TrackingStatus::Unknown, + }; + } +} diff --git a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php new file mode 100644 index 0000000..46fe211 --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php @@ -0,0 +1,48 @@ +resolveFixedPrice( + $shippingOptionRequest->shippingRate, + $shippingOptionRequest->shippingRate->shippingMethod, + $shippingOptionRequest->cart, + ); + } + + public function on(ShippingRate $shippingRate): self + { + $this->shippingRate = $shippingRate; + + return $this; + } +} diff --git a/src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php b/src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php new file mode 100644 index 0000000..59bc0c9 --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php @@ -0,0 +1,13 @@ +data['charge_by'] ?? 'cart_total'; + + $tier = $chargeBy === 'weight' + ? $cart->lines->load('purchasable')->sum(fn ($line) => ($line->purchasable->weight_value ?? 0) * $line->quantity) + : $cart->lines->sum('subTotal.value'); + + $pricing = Pricing::for($shippingRate)->qty($tier)->get(); + + if (! $pricing->matched) { + return null; + } + + return new ShippingOption( + name: $shippingMethod->name ?: $this->name(), + description: $shippingMethod->description ?: $this->description(), + identifier: $shippingRate->getIdentifier(), + price: $pricing->matched->price, + taxClass: $shippingRate->getTaxClass(), + taxReference: $shippingRate->getTaxReference(), + ); + } +} diff --git a/src/Shipping/Contracts/CarrierFulfillmentInterface.php b/src/Shipping/Contracts/CarrierFulfillmentInterface.php new file mode 100644 index 0000000..d76d8d4 --- /dev/null +++ b/src/Shipping/Contracts/CarrierFulfillmentInterface.php @@ -0,0 +1,25 @@ + + */ + public function trackShipment(Shipment $shipment): Collection; +} diff --git a/src/Shipping/DataTransferObjects/ManifestResult.php b/src/Shipping/DataTransferObjects/ManifestResult.php new file mode 100644 index 0000000..a855421 --- /dev/null +++ b/src/Shipping/DataTransferObjects/ManifestResult.php @@ -0,0 +1,26 @@ + true, + default => false, + }; + } +} diff --git a/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php b/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php new file mode 100644 index 0000000..590dfd9 --- /dev/null +++ b/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php @@ -0,0 +1,17 @@ +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\TextInput::make('weight') + ->label('Package weight (kg)') + ->numeric() + ->minValue(0) + ->helperText('Leave blank to use the carrier\'s default.'), + Forms\Components\TextInput::make('destination_location_id') + ->label('Box Now locker ID') + ->helperText('Only required for Box Now shipments.') + ->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null), + 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, array $data, 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; + } + + $request = new ShipmentRequest( + weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, + destinationLocationId: $data['destination_location_id'] ?? null, + ); + + try { + $service->createShipment($record, $request); + } 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..5cfadc7 --- /dev/null +++ b/src/Shipping/Extensions/ShippingMethodResourceExtension.php @@ -0,0 +1,152 @@ +schema( + $this->replaceChargeByField( + $this->replaceDriverField($form->getComponents()) + ) + ); + } + + /** + * Extend the vendor's cart_total/weight charge_by Select with a third + * "live" option — only offered when the currently selected driver + * supports live pricing (see SupportsLivePricing). Picking it is what + * tells the driver to call its carrier API instead of resolving a + * price break. + */ + private function replaceChargeByField(array $components): array + { + return array_map(function (Component $component) { + if (method_exists($component, 'getName') && $component->getName() === 'charge_by') { + return $this->chargeBySelect(); + } + + if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { + $component->schema( + $this->replaceChargeByField($component->getChildComponents()) + ); + } + + return $component; + }, $components); + } + + private function chargeBySelect(): Select + { + return Select::make('charge_by') + ->label('Charge by') + ->options(function (Get $get) { + $options = [ + 'cart_total' => 'Cart Total', + 'weight' => 'Weight', + ]; + + // "charge_by" is nested inside a Group with + // ->statePath('data'), while "driver" sits one level up, at + // the form root. Note: an *absolute* path here would need to + // additionally account for the page's own form wrapper + // (EditRecord::getFormStatePath() === 'data'), which relative + // paths never cross — so "../driver" (relative) is the + // correct, page-independent way to reach it, not an + // absolute 'driver' string. + if ($this->driverSupportsLivePricing($get('../driver'))) { + $options['live'] = 'Live API pricing'; + } + + return $options; + }) + ->live(); + } + + private function driverSupportsLivePricing(?string $driver): bool + { + if (! $driver) { + return false; + } + + try { + return Shipping::driver($driver) instanceof SupportsLivePricing; + } catch (\InvalidArgumentException) { + return false; + } + } + + 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') + ->live(); + } +} 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]); + } +} diff --git a/src/Shipping/Filament/Pages/ManageShippingRates.php b/src/Shipping/Filament/Pages/ManageShippingRates.php new file mode 100644 index 0000000..bf6cdb3 --- /dev/null +++ b/src/Shipping/Filament/Pages/ManageShippingRates.php @@ -0,0 +1,104 @@ +schema( + $this->hidePriceFieldsWhenLive($form->getComponents()) + ); + } + + private function hidePriceFieldsWhenLive(array $components): array + { + $isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live'; + + foreach ($components as $component) { + if (! method_exists($component, 'getName')) { + continue; + } + + if ($component->getName() === 'price') { + $component->visible($isNotLive)->required($isNotLive)->dehydrated(true); + } + + if ($component->getName() === 'prices') { + $component->visible($isNotLive)->dehydrated(true); + } + } + + return $components; + } + + public function table(Table $table): Table + { + $table = parent::table($table); + + return $table->columns( + array_map(function ($column) { + if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') { + return TextColumn::make('basePrices.0') + ->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label')) + ->formatStateUsing(function ($state, ShippingRate $record) { + if (static::methodChargeBy($record->shipping_method_id) === 'live') { + return 'Live API pricing'; + } + + return $state?->price->formatted; + }); + } + + return $column; + }, $table->getColumns()) + ); + } + + protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void + { + if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') { + return; + } + + parent::saveShippingRate($shippingRate, $data); + } + + protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string + { + if (blank($method)) { + return null; + } + + if (! $method instanceof ShippingMethod) { + $method = ShippingMethod::find($method); + } + + return $method?->data['charge_by'] ?? null; + } +} diff --git a/src/Shipping/Jobs/PollShipmentTrackingJob.php b/src/Shipping/Jobs/PollShipmentTrackingJob.php new file mode 100644 index 0000000..7714e22 --- /dev/null +++ b/src/Shipping/Jobs/PollShipmentTrackingJob.php @@ -0,0 +1,107 @@ +keys()->filter( + fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsTracking + ); + + if ($trackableCarriers->isEmpty()) { + return; + } + + Shipment::query() + ->whereIn('carrier', $trackableCarriers) + ->whereNull('cancelled_at') + ->whereDoesntHave('shipmentInfo', function ($query) { + $query->whereIn('status', [ + TrackingStatus::Delivered->value, + TrackingStatus::Returned->value, + TrackingStatus::Cancelled->value, + ]); + }) + ->chunkById(50, function ($shipments) { + $shipments->groupBy('carrier')->each( + fn ($group, $carrier) => $this->pollCarrierShipments($carrier, $group) + ); + }); + } + + private function pollCarrierShipments(string $carrier, $shipments): void + { + $service = $this->fulfillmentService($carrier); + + if (! $service instanceof SupportsTracking) { + return; + } + + foreach ($shipments as $shipment) { + $this->recordNewCheckpoints($shipment, $service->trackShipment($shipment)); + } + } + + private function recordNewCheckpoints(Shipment $shipment, $checkpoints): void + { + $existing = $shipment->shipmentInfo() + ->get(['status', 'occurred_at']) + ->map(fn ($info) => $info->status->value.'|'.$info->occurred_at->toIso8601String()) + ->flip(); + + foreach ($checkpoints as $checkpoint) { + $fingerprint = $checkpoint->status->value.'|'.$checkpoint->occurredAt->toIso8601String(); + + if ($existing->has($fingerprint)) { + continue; + } + + $info = ShipmentInfo::create([ + 'shipment_id' => $shipment->id, + 'status' => $checkpoint->status, + 'carrier_status' => $checkpoint->carrierStatus, + 'message' => $checkpoint->message, + 'location' => $checkpoint->location, + 'occurred_at' => $checkpoint->occurredAt, + 'meta' => $checkpoint->meta, + ]); + + ShipmentStatusUpdatedByCarrier::dispatch($info); + } + } + + private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface + { + return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); + } +} diff --git a/src/Shipping/Models/Shipment.php b/src/Shipping/Models/Shipment.php new file mode 100644 index 0000000..c278f4c --- /dev/null +++ b/src/Shipping/Models/Shipment.php @@ -0,0 +1,35 @@ + AsArrayObject::class, + 'label_printed_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function shipmentInfo(): HasMany + { + return $this->hasMany(ShipmentInfo::class); + } + + public function latestShipmentInfo(): ?ShipmentInfo + { + return $this->shipmentInfo()->latest('occurred_at')->first(); + } +} diff --git a/src/Shipping/Models/ShipmentInfo.php b/src/Shipping/Models/ShipmentInfo.php new file mode 100644 index 0000000..b2a401d --- /dev/null +++ b/src/Shipping/Models/ShipmentInfo.php @@ -0,0 +1,31 @@ + TrackingStatus::class, + 'occurred_at' => 'datetime', + 'meta' => AsArrayObject::class, + ]; + + public function shipment(): BelongsTo + { + return $this->belongsTo(Shipment::class); + } +}