From e18b2fa44c1468cfe7735f0c6d64dd7741454b33 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 16 Jul 2026 17:14:23 +0300 Subject: [PATCH 01/10] Feature: Creating Shipment migration and model --- ...26_07_16_000001_create_shipments_table.php | 29 +++++++++++++++++++ src/Shipping/Models/Shipment.php | 24 +++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 database/migrations/2026_07_16_000001_create_shipments_table.php create mode 100644 src/Shipping/Models/Shipment.php 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/src/Shipping/Models/Shipment.php b/src/Shipping/Models/Shipment.php new file mode 100644 index 0000000..368a110 --- /dev/null +++ b/src/Shipping/Models/Shipment.php @@ -0,0 +1,24 @@ + AsArrayObject::class, + 'label_printed_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} From 08135c4c8f8debef368327fa7386bd3b792a8956 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 16 Jul 2026 17:14:41 +0300 Subject: [PATCH 02/10] Feature: Creating Shipping Carrier Contracts --- .../Contracts/CarrierFulfillmentInterface.php | 24 +++++++++++++++++++ .../Contracts/SupportsLivePricing.php | 12 ++++++++++ .../Contracts/SupportsManifestBatching.php | 24 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 src/Shipping/Contracts/CarrierFulfillmentInterface.php create mode 100644 src/Shipping/Contracts/SupportsLivePricing.php create mode 100644 src/Shipping/Contracts/SupportsManifestBatching.php diff --git a/src/Shipping/Contracts/CarrierFulfillmentInterface.php b/src/Shipping/Contracts/CarrierFulfillmentInterface.php new file mode 100644 index 0000000..68f1b30 --- /dev/null +++ b/src/Shipping/Contracts/CarrierFulfillmentInterface.php @@ -0,0 +1,24 @@ + Date: Sun, 19 Jul 2026 00:42:13 +0300 Subject: [PATCH 03/10] Feature: Creating Migration For Adding Pricing Mode to Shipping Rates --- ...d_pricing_mode_to_shipping_rates_table.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php diff --git a/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php b/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php new file mode 100644 index 0000000..5442c1b --- /dev/null +++ b/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php @@ -0,0 +1,26 @@ +string('pricing_mode')->default('live')->after('enabled'); + }); + } + + public function down(): void + { + $prefix = config('lunar.database.table_prefix'); + + Schema::table("{$prefix}shipping_rates", function (Blueprint $table) { + $table->dropColumn('pricing_mode'); + }); + } +}; From 3599329b57caab7e8ff6d3aba0412bde778cd26d Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 00:50:15 +0300 Subject: [PATCH 04/10] Feature: Add ManifestResult DTO for carrier manifest batching Completes the carrier-agnostic shipping abstraction (CarrierFulfillmentInterface, SupportsManifestBatching, Shipment model) with the result type SupportsManifestBatching::issueManifest() returns. --- .../DataTransferObjects/ManifestResult.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/Shipping/DataTransferObjects/ManifestResult.php 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 @@ + Date: Sun, 19 Jul 2026 00:50:51 +0300 Subject: [PATCH 05/10] Feature: Add ACS courier integration ACS rate driver (live price quotes via ACS_Price_Calculation, cached postcode-to-station lookups) and fulfillment service (voucher creation, label printing, end-of-day pickup manifest). Adds a per-rate pricing_mode column so admins can choose live API pricing vs. a fixed price on ACS-driven shipping rates, surfaced via a custom Rates page. --- config/shippingCarriers/acs.php | 50 ++++++ src/Shipping/Carriers/Acs/AcsArea.php | 11 ++ src/Shipping/Carriers/Acs/AcsClient.php | 34 ++++ .../Carriers/Acs/AcsFulfillmentService.php | 133 ++++++++++++++ src/Shipping/Carriers/Acs/AcsRateDriver.php | 134 ++++++++++++++ src/Shipping/Carriers/Acs/AcsResponse.php | 43 +++++ src/Shipping/Carriers/Acs/AreaResolver.php | 81 +++++++++ .../Acs/Exceptions/AcsApiException.php | 13 ++ .../Carriers/Acs/Jobs/WarmAcsAreaCacheJob.php | 27 +++ .../Filament/Pages/ManageShippingRates.php | 165 ++++++++++++++++++ 10 files changed, 691 insertions(+) create mode 100644 config/shippingCarriers/acs.php create mode 100644 src/Shipping/Carriers/Acs/AcsArea.php create mode 100644 src/Shipping/Carriers/Acs/AcsClient.php create mode 100644 src/Shipping/Carriers/Acs/AcsFulfillmentService.php create mode 100644 src/Shipping/Carriers/Acs/AcsRateDriver.php create mode 100644 src/Shipping/Carriers/Acs/AcsResponse.php create mode 100644 src/Shipping/Carriers/Acs/AreaResolver.php create mode 100644 src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php create mode 100644 src/Shipping/Carriers/Acs/Jobs/WarmAcsAreaCacheJob.php create mode 100644 src/Shipping/Filament/Pages/ManageShippingRates.php 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/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..1635408 --- /dev/null +++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php @@ -0,0 +1,133 @@ +shippingAddress; + $destination = $this->areaResolver->resolve($address->postcode); + + $response = $this->client->call('ACS_Create_Voucher', array_merge([ + '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' => 1, + 'Weight' => 0.5, + ], $overrides))->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' => $overrides['Weight'] ?? 0.5, + 'pickup_date' => now()->toDateString(), + ], + ]); + + if (($overrides['Item_Quantity'] ?? 1) > 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); + } + + 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..0d92a48 --- /dev/null +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -0,0 +1,134 @@ +shippingRate; + $shippingMethod = $shippingRate->shippingMethod; + $cart = $shippingOptionRequest->cart; + + $postcode = $cart->shippingAddress?->postcode; + + if (! $postcode) { + return null; + } + + if (($shippingRate->pricing_mode ?? 'live') === 'fixed') { + return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); + } + + return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode); + } + + private function resolveFixedPrice(ShippingRate $shippingRate, $shippingMethod, $cart): ?ShippingOption + { + $subTotal = $cart->lines->sum('subTotal.value'); + + $pricing = Pricing::for($shippingRate)->qty($subTotal)->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(), + ); + } + + 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/Filament/Pages/ManageShippingRates.php b/src/Shipping/Filament/Pages/ManageShippingRates.php new file mode 100644 index 0000000..d91b287 --- /dev/null +++ b/src/Shipping/Filament/Pages/ManageShippingRates.php @@ -0,0 +1,165 @@ +schema( + $this->insertPricingModeFieldAfterShippingMethod( + $this->hidePriceFieldsWhenLive($form->getComponents()) + ) + ); + } + + /** + * Insert the "Pricing" field immediately after "shipping_method_id" so + * it reads as a pair, rather than appending it elsewhere in the form. + */ + private function insertPricingModeFieldAfterShippingMethod(array $components): array + { + $result = []; + + foreach ($components as $component) { + $result[] = $component; + + if (method_exists($component, 'getName') && $component->getName() === 'shipping_method_id') { + $result[] = $this->pricingModeField(); + } + } + + return $result; + } + + private function pricingModeField(): Select + { + return Select::make('pricing_mode') + ->label('Pricing') + ->options([ + 'live' => 'Use live API pricing', + 'fixed' => 'Use a fixed price', + ]) + ->default('live') + ->live() + ->visible(fn (Get $get) => static::methodHasLivePricing($get('shipping_method_id'))) + ->columnSpan(2); + } + + /** + * Hide the vendor's price / price-break fields whenever this rate is + * set to live pricing — they'd otherwise be dead configuration, + * silently ignored by the driver. Only the base "price" field was ever + * required by the vendor form; the "prices" repeater (price breaks) is + * always optional, so its required() state is left untouched. + */ + private function hidePriceFieldsWhenLive(array $components): array + { + $isFixedOrNotLiveCapable = fn (Get $get) => ! static::methodHasLivePricing($get('shipping_method_id')) + || $get('pricing_mode') === 'fixed'; + + foreach ($components as $component) { + if (! method_exists($component, 'getName')) { + continue; + } + + if ($component->getName() === 'price') { + $component->visible($isFixedOrNotLiveCapable) + ->required($isFixedOrNotLiveCapable) + ->dehydrated(true); + } + + if ($component->getName() === 'prices') { + $component->visible($isFixedOrNotLiveCapable) + ->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::methodHasLivePricing($record->shipping_method_id) && $record->pricing_mode === 'live') { + return 'Live API pricing'; + } + + return $state?->price->formatted; + }); + } + + return $column; + }, $table->getColumns()) + ); + } + + protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void + { + $isLive = static::methodHasLivePricing($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) + && ($data['pricing_mode'] ?? 'live') === 'live'; + + $shippingRate->pricing_mode = $isLive ? 'live' : 'fixed'; + $shippingRate->save(); + + if ($isLive) { + return; + } + + parent::saveShippingRate($shippingRate, $data); + } + + protected static function methodHasLivePricing(ShippingMethod|int|string|null $method): bool + { + if (blank($method)) { + return false; + } + + if (! $method instanceof ShippingMethod) { + $method = ShippingMethod::find($method); + } + + if (! $method) { + return false; + } + + try { + return Shipping::driver($method->driver) instanceof SupportsLivePricing; + } catch (\InvalidArgumentException) { + return false; + } + } +} From 37c2e1194c965ac92092702e4eeee39a09e883ed Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 00:51:23 +0300 Subject: [PATCH 06/10] Feature: Add Box Now locker delivery integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Box Now rate driver (fixed pricing only — no live pricing API) and fulfillment service (delivery request creation, label printing, cancellation). Unlike ACS, Box Now books courier pickup automatically on delivery request creation, so no manifest/pickup-list step is implemented. Storefront locker selection is not yet built; createShipment() expects the chosen locker's locationId to be supplied by the caller. --- config/shippingCarriers/boxnow.php | 47 +++++++++ src/Shipping/Carriers/BoxNow/BoxNowClient.php | 97 +++++++++++++++++++ .../BoxNow/BoxNowFulfillmentService.php | 92 ++++++++++++++++++ .../Carriers/BoxNow/BoxNowRateDriver.php | 61 ++++++++++++ .../BoxNow/Exceptions/BoxNowApiException.php | 13 +++ 5 files changed, 310 insertions(+) create mode 100644 config/shippingCarriers/boxnow.php create mode 100644 src/Shipping/Carriers/BoxNow/BoxNowClient.php create mode 100644 src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php create mode 100644 src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php create mode 100644 src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php 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/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..05266c5 --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php @@ -0,0 +1,92 @@ +shippingAddress; + $destinationLocationId = $overrides['locationId'] ?? null; + + if (! $destinationLocationId) { + throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.'); + } + + $response = $this->client->request('post', '/delivery-requests', [ + 'orderNumber' => $order->reference.'-'.$order->id, + 'invoiceValue' => number_format($order->total->decimal, 2, '.', ''), + 'paymentMode' => 'prepaid', + 'amountToBeCollected' => '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' => $overrides['compartmentSize'] ?? 1, + 'weight' => $overrides['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()]); + } +} diff --git a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php new file mode 100644 index 0000000..4897657 --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php @@ -0,0 +1,61 @@ +shippingRate; + $shippingMethod = $shippingRate->shippingMethod; + $cart = $shippingOptionRequest->cart; + + $subTotal = $cart->lines->sum('subTotal.value'); + + $pricing = Pricing::for($shippingRate)->qty($subTotal)->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(), + ); + } + + 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 @@ + Date: Sun, 19 Jul 2026 00:52:01 +0300 Subject: [PATCH 07/10] 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]); + } +} From d34e4505267dd3ae9da2460fb418ae09a2677ef1 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 02:24:10 +0300 Subject: [PATCH 08/10] Fix: Move carrier live-pricing choice onto ShippingMethod.charge_by Reverts the earlier per-rate pricing_mode column in favor of extending Lunar's existing charge_by field (cart_total/weight) with a third "live" option, gated by a SupportsLivePricing capability check on the driver. Adds a shared ResolvesFixedPricing trait so any carrier driver can fall back to Lunar's normal price-break resolution, matching the vendor ShipBy driver's own charge_by handling instead of introducing a separate mechanism. Also fixes an incorrect Get() path in the admin form that silently hid the new "live" option. --- ...d_pricing_mode_to_shipping_rates_table.php | 26 ----- src/Shipping/Carriers/Acs/AcsRateDriver.php | 32 ++----- .../Carriers/BoxNow/BoxNowRateDriver.php | 35 +++---- .../Concerns/ResolvesFixedPricing.php | 42 ++++++++ .../ShippingMethodResourceExtension.php | 73 +++++++++++++- .../Filament/Pages/ManageShippingRates.php | 95 ++++--------------- 6 files changed, 148 insertions(+), 155 deletions(-) delete mode 100644 database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php create mode 100644 src/Shipping/Concerns/ResolvesFixedPricing.php diff --git a/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php b/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php deleted file mode 100644 index 5442c1b..0000000 --- a/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php +++ /dev/null @@ -1,26 +0,0 @@ -string('pricing_mode')->default('live')->after('enabled'); - }); - } - - public function down(): void - { - $prefix = config('lunar.database.table_prefix'); - - Schema::table("{$prefix}shipping_rates", function (Blueprint $table) { - $table->dropColumn('pricing_mode'); - }); - } -}; diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php index 0d92a48..6c8af72 100644 --- a/src/Shipping/Carriers/Acs/AcsRateDriver.php +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -4,15 +4,17 @@ namespace Modules\Core\Shipping\Carriers\Acs; use Lunar\DataTypes\Price; use Lunar\DataTypes\ShippingOption; -use Lunar\Facades\Pricing; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Models\ShippingRate; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; +use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing; class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing { + use ResolvesFixedPricing; + public ShippingRate $shippingRate; public function __construct( @@ -36,39 +38,19 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing $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; } - if (($shippingRate->pricing_mode ?? 'live') === 'fixed') { - return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); - } - return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode); } - private function resolveFixedPrice(ShippingRate $shippingRate, $shippingMethod, $cart): ?ShippingOption - { - $subTotal = $cart->lines->sum('subTotal.value'); - - $pricing = Pricing::for($shippingRate)->qty($subTotal)->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(), - ); - } - private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption { try { diff --git a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php index 4897657..46fe211 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php @@ -3,19 +3,21 @@ namespace Modules\Core\Shipping\Carriers\BoxNow; use Lunar\DataTypes\ShippingOption; -use Lunar\Facades\Pricing; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Models\ShippingRate; +use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; /** - * Box Now has no pricing API, so this always resolves the admin-configured - * price/price-breaks on the ShippingRate — the same mechanism the built-in - * flat-rate driver uses. Unlike AcsRateDriver, this does not implement - * SupportsLivePricing: there is nothing to toggle between. + * Box Now has no pricing API, so this always resolves the method's normal + * charge_by + price-break configuration — the same mechanism the built-in + * flat-rate/ship-by drivers use. Does not implement SupportsLivePricing: + * there is no live option to offer. */ class BoxNowRateDriver implements ShippingRateInterface { + use ResolvesFixedPricing; + public ShippingRate $shippingRate; public function name(): string @@ -30,25 +32,10 @@ class BoxNowRateDriver implements ShippingRateInterface public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption { - $shippingRate = $shippingOptionRequest->shippingRate; - $shippingMethod = $shippingRate->shippingMethod; - $cart = $shippingOptionRequest->cart; - - $subTotal = $cart->lines->sum('subTotal.value'); - - $pricing = Pricing::for($shippingRate)->qty($subTotal)->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(), + return $this->resolveFixedPrice( + $shippingOptionRequest->shippingRate, + $shippingOptionRequest->shippingRate->shippingMethod, + $shippingOptionRequest->cart, ); } diff --git a/src/Shipping/Concerns/ResolvesFixedPricing.php b/src/Shipping/Concerns/ResolvesFixedPricing.php new file mode 100644 index 0000000..d0a31bd --- /dev/null +++ b/src/Shipping/Concerns/ResolvesFixedPricing.php @@ -0,0 +1,42 @@ +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/Extensions/ShippingMethodResourceExtension.php b/src/Shipping/Extensions/ShippingMethodResourceExtension.php index 0aa8cd6..5cfadc7 100644 --- a/src/Shipping/Extensions/ShippingMethodResourceExtension.php +++ b/src/Shipping/Extensions/ShippingMethodResourceExtension.php @@ -6,20 +6,88 @@ use Filament\Forms\Components\Component; use Filament\Forms\Components\Concerns\HasChildComponents; use Filament\Forms\Components\Select; use Filament\Forms\Form; +use Filament\Forms\Get; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Shipping\Facades\Shipping; +use Modules\Core\Shipping\Contracts\SupportsLivePricing; class ShippingMethodResourceExtension extends ResourceExtension { public function extendForm(Form $form): Form { return $form->schema( - $this->replaceDriverField($form->getComponents()) + $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( @@ -78,6 +146,7 @@ class ShippingMethodResourceExtension extends ResourceExtension ->label('Type') ->options(fn () => collect(Shipping::getSupportedDrivers()) ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) - ->default('flat-rate'); + ->default('flat-rate') + ->live(); } } diff --git a/src/Shipping/Filament/Pages/ManageShippingRates.php b/src/Shipping/Filament/Pages/ManageShippingRates.php index d91b287..bf6cdb3 100644 --- a/src/Shipping/Filament/Pages/ManageShippingRates.php +++ b/src/Shipping/Filament/Pages/ManageShippingRates.php @@ -2,16 +2,13 @@ namespace Modules\Core\Shipping\Filament\Pages; -use Filament\Forms\Components\Select; use Filament\Forms\Form; use Filament\Forms\Get; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Lunar\Shipping\Facades\Shipping; +use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates; use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingRate; -use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates; -use Modules\Core\Shipping\Contracts\SupportsLivePricing; /** * Bound in place of the vendor ManageShippingRates page via the container @@ -20,11 +17,13 @@ use Modules\Core\Shipping\Contracts\SupportsLivePricing; * ShippingZoneResource::getPages() — is untouched; the container simply * hands back this subclass whenever the vendor class is resolved. * - * Adds a per-rate "Pricing" toggle (live API vs. fixed price) for methods - * whose driver supports live pricing (see SupportsLivePricing). Rates on - * methods without live pricing behave exactly as the vendor page always did - * — no toggle shown, price fields always required, vendor save logic used - * as-is. + * Hides the price / price-break fields for a rate whose method has + * charge_by = "live" (see ShippingMethodResourceExtension, which adds that + * option to methods whose driver supports live pricing) — those fields + * would otherwise be dead configuration the driver never reads. Pricing + * strategy (cart_total / weight / live) stays entirely on the Shipping + * Method, matching Lunar's own existing charge_by convention; nothing new + * is stored on the rate itself. */ class ManageShippingRates extends BaseManageShippingRates { @@ -33,56 +32,13 @@ class ManageShippingRates extends BaseManageShippingRates $form = parent::form($form); return $form->schema( - $this->insertPricingModeFieldAfterShippingMethod( - $this->hidePriceFieldsWhenLive($form->getComponents()) - ) + $this->hidePriceFieldsWhenLive($form->getComponents()) ); } - /** - * Insert the "Pricing" field immediately after "shipping_method_id" so - * it reads as a pair, rather than appending it elsewhere in the form. - */ - private function insertPricingModeFieldAfterShippingMethod(array $components): array - { - $result = []; - - foreach ($components as $component) { - $result[] = $component; - - if (method_exists($component, 'getName') && $component->getName() === 'shipping_method_id') { - $result[] = $this->pricingModeField(); - } - } - - return $result; - } - - private function pricingModeField(): Select - { - return Select::make('pricing_mode') - ->label('Pricing') - ->options([ - 'live' => 'Use live API pricing', - 'fixed' => 'Use a fixed price', - ]) - ->default('live') - ->live() - ->visible(fn (Get $get) => static::methodHasLivePricing($get('shipping_method_id'))) - ->columnSpan(2); - } - - /** - * Hide the vendor's price / price-break fields whenever this rate is - * set to live pricing — they'd otherwise be dead configuration, - * silently ignored by the driver. Only the base "price" field was ever - * required by the vendor form; the "prices" repeater (price breaks) is - * always optional, so its required() state is left untouched. - */ private function hidePriceFieldsWhenLive(array $components): array { - $isFixedOrNotLiveCapable = fn (Get $get) => ! static::methodHasLivePricing($get('shipping_method_id')) - || $get('pricing_mode') === 'fixed'; + $isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live'; foreach ($components as $component) { if (! method_exists($component, 'getName')) { @@ -90,14 +46,11 @@ class ManageShippingRates extends BaseManageShippingRates } if ($component->getName() === 'price') { - $component->visible($isFixedOrNotLiveCapable) - ->required($isFixedOrNotLiveCapable) - ->dehydrated(true); + $component->visible($isNotLive)->required($isNotLive)->dehydrated(true); } if ($component->getName() === 'prices') { - $component->visible($isFixedOrNotLiveCapable) - ->dehydrated(true); + $component->visible($isNotLive)->dehydrated(true); } } @@ -114,7 +67,7 @@ class ManageShippingRates extends BaseManageShippingRates return TextColumn::make('basePrices.0') ->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label')) ->formatStateUsing(function ($state, ShippingRate $record) { - if (static::methodHasLivePricing($record->shipping_method_id) && $record->pricing_mode === 'live') { + if (static::methodChargeBy($record->shipping_method_id) === 'live') { return 'Live API pricing'; } @@ -129,37 +82,23 @@ class ManageShippingRates extends BaseManageShippingRates protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void { - $isLive = static::methodHasLivePricing($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) - && ($data['pricing_mode'] ?? 'live') === 'live'; - - $shippingRate->pricing_mode = $isLive ? 'live' : 'fixed'; - $shippingRate->save(); - - if ($isLive) { + if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') { return; } parent::saveShippingRate($shippingRate, $data); } - protected static function methodHasLivePricing(ShippingMethod|int|string|null $method): bool + protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string { if (blank($method)) { - return false; + return null; } if (! $method instanceof ShippingMethod) { $method = ShippingMethod::find($method); } - if (! $method) { - return false; - } - - try { - return Shipping::driver($method->driver) instanceof SupportsLivePricing; - } catch (\InvalidArgumentException) { - return false; - } + return $method?->data['charge_by'] ?? null; } } From 435a4dd290891fc497facdfccbadf51c7959c480 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 17:41:09 +0300 Subject: [PATCH 09/10] Feat: Adding Shippment Tracking --- ...7_21_000001_create_shipment_info_table.php | 28 +++++ src/Providers/ShippingServiceProvider.php | 5 + src/Shipping/Contracts/SupportsTracking.php | 23 ++++ .../TrackingCheckpoint.php | 22 ++++ src/Shipping/Enums/TrackingStatus.php | 29 +++++ .../Events/ShipmentStatusUpdatedByCarrier.php | 17 +++ src/Shipping/Jobs/PollShipmentTrackingJob.php | 107 ++++++++++++++++++ src/Shipping/Models/Shipment.php | 11 ++ src/Shipping/Models/ShipmentInfo.php | 31 +++++ 9 files changed, 273 insertions(+) create mode 100644 database/migrations/2026_07_21_000001_create_shipment_info_table.php create mode 100644 src/Shipping/Contracts/SupportsTracking.php create mode 100644 src/Shipping/DataTransferObjects/TrackingCheckpoint.php create mode 100644 src/Shipping/Enums/TrackingStatus.php create mode 100644 src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php create mode 100644 src/Shipping/Jobs/PollShipmentTrackingJob.php create mode 100644 src/Shipping/Models/ShipmentInfo.php 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/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index 0adecbb..c93b899 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -19,6 +19,7 @@ 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\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Models\Shipment; class ShippingServiceProvider extends ServiceProvider @@ -73,6 +74,10 @@ class ShippingServiceProvider extends ServiceProvider ->dailyAt('06:00') ->when(fn () => ShippingMethod::where('driver', 'acs')->exists()); + $this->app->make(ConsoleSchedule::class) + ->job(new PollShipmentTrackingJob) + ->everyThirtyMinutes(); + $this->overrideRatesPageLivewireComponent(); }); } diff --git a/src/Shipping/Contracts/SupportsTracking.php b/src/Shipping/Contracts/SupportsTracking.php new file mode 100644 index 0000000..4ec4457 --- /dev/null +++ b/src/Shipping/Contracts/SupportsTracking.php @@ -0,0 +1,23 @@ + + */ + public function trackShipment(Shipment $shipment): Collection; +} diff --git a/src/Shipping/DataTransferObjects/TrackingCheckpoint.php b/src/Shipping/DataTransferObjects/TrackingCheckpoint.php new file mode 100644 index 0000000..3ce5e94 --- /dev/null +++ b/src/Shipping/DataTransferObjects/TrackingCheckpoint.php @@ -0,0 +1,22 @@ + 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 @@ +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 index 368a110..c278f4c 100644 --- a/src/Shipping/Models/Shipment.php +++ b/src/Shipping/Models/Shipment.php @@ -5,6 +5,7 @@ namespace Modules\Core\Shipping\Models; use Illuminate\Database\Eloquent\Casts\AsArrayObject; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Lunar\Models\Order; class Shipment extends Model @@ -21,4 +22,14 @@ class Shipment extends Model { 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); + } +} From 9f30a7324e594849d396874328e41489a68a2b8a Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 18:27:24 +0300 Subject: [PATCH 10/10] Feature: Shipment Updates to handle COD --- .../Carriers/Acs/AcsFulfillmentService.php | 85 +++++++++++++++++-- .../BoxNow/BoxNowFulfillmentService.php | 78 ++++++++++++++--- .../Contracts/CarrierFulfillmentInterface.php | 3 +- .../DataTransferObjects/ShipmentRequest.php | 20 +++++ .../Extensions/OrderViewExtension.php | 19 ++++- 5 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 src/Shipping/DataTransferObjects/ShipmentRequest.php diff --git a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php index 1635408..a40b9fb 100644 --- a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php +++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php @@ -2,26 +2,33 @@ namespace Modules\Core\Shipping\Carriers\Acs; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Lunar\Models\Order; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\SupportsManifestBatching; +use Modules\Core\Shipping\Contracts\SupportsTracking; +use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\DataTransferObjects\ManifestResult; +use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; +use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint; +use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Models\Shipment; -class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching +class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking { public function __construct( private readonly AcsClient $client, private readonly AreaResolver $areaResolver, ) {} - public function createShipment(Order $order, array $overrides = []): Shipment + public function createShipment(Order $order, ShipmentRequest $request): Shipment { $address = $order->shippingAddress; $destination = $this->areaResolver->resolve($address->postcode); + $weight = $request->weight ?? 0.5; - $response = $this->client->call('ACS_Create_Voucher', array_merge([ + $params = [ 'Pickup_Date' => now()->toDateString(), 'Sender' => config('acs.sender.name'), 'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"), @@ -33,9 +40,17 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani 'Acs_Station_Branch_Destination' => $destination->branchId, 'Billing_Code' => config('acs.billing_code'), 'Charge_Type' => 2, - 'Item_Quantity' => 1, - 'Weight' => 0.5, - ], $overrides))->throwIfError(); + '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']; @@ -45,12 +60,12 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani 'tracking_reference' => $voucherNo, 'meta' => [ 'station_destination' => $destination->stationId, - 'weight' => $overrides['Weight'] ?? 0.5, + 'weight' => $weight, 'pickup_date' => now()->toDateString(), ], ]); - if (($overrides['Item_Quantity'] ?? 1) > 1) { + if ($request->packageCount > 1) { $this->persistMultipartVouchers($shipment); } @@ -114,6 +129,60 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani 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', [ diff --git a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php index 05266c5..ea32ea7 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php @@ -2,9 +2,15 @@ namespace Modules\Core\Shipping\Carriers\BoxNow; +use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Lunar\Models\Order; use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; +use Modules\Core\Shipping\Contracts\SupportsTracking; +use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; +use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint; +use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Models\Shipment; /** @@ -13,28 +19,32 @@ use Modules\Core\Shipping\Models\Shipment; * CarrierFulfillmentInterface (not SupportsManifestBatching). * * Box Now delivers to lockers, not addresses. The storefront locker-picker - * is out of scope for this pass — createShipment() expects the chosen - * locker's Box Now locationId via $overrides['locationId'] (e.g. set - * manually by admin staff until checkout UI exists). + * is out of scope for this pass — createShipment() requires the chosen + * locker's Box Now locationId via ShipmentRequest::$destinationLocationId + * (e.g. set manually by admin staff until checkout UI exists). */ -class BoxNowFulfillmentService implements CarrierFulfillmentInterface +class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking { public function __construct(private readonly BoxNowClient $client) {} - public function createShipment(Order $order, array $overrides = []): Shipment + public function createShipment(Order $order, ShipmentRequest $request): Shipment { $address = $order->shippingAddress; - $destinationLocationId = $overrides['locationId'] ?? null; + $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' => 'prepaid', - 'amountToBeCollected' => '0.00', + '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'), @@ -52,8 +62,8 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface 'id' => (string) $order->id, 'name' => 'Order '.$order->reference, 'value' => '0.00', - 'compartmentSize' => $overrides['compartmentSize'] ?? 1, - 'weight' => $overrides['weight'] ?? 0, + 'compartmentSize' => 1, + 'weight' => $request->weight ?? 0, ], ], ]); @@ -89,4 +99,52 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface $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/Contracts/CarrierFulfillmentInterface.php b/src/Shipping/Contracts/CarrierFulfillmentInterface.php index 68f1b30..d76d8d4 100644 --- a/src/Shipping/Contracts/CarrierFulfillmentInterface.php +++ b/src/Shipping/Contracts/CarrierFulfillmentInterface.php @@ -3,6 +3,7 @@ namespace Modules\Core\Shipping\Contracts; use Lunar\Models\Order; +use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; use Modules\Core\Shipping\Models\Shipment; interface CarrierFulfillmentInterface @@ -10,7 +11,7 @@ interface CarrierFulfillmentInterface /** * Create a shipment with the carrier for the given order. */ - public function createShipment(Order $order, array $overrides = []): Shipment; + public function createShipment(Order $order, ShipmentRequest $request): Shipment; /** * Fetch the printable label for a shipment (raw file bytes). diff --git a/src/Shipping/DataTransferObjects/ShipmentRequest.php b/src/Shipping/DataTransferObjects/ShipmentRequest.php new file mode 100644 index 0000000..f63b4a0 --- /dev/null +++ b/src/Shipping/DataTransferObjects/ShipmentRequest.php @@ -0,0 +1,20 @@ +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.') @@ -39,7 +49,7 @@ class OrderViewExtension extends ViewPageExtension }, ]), ]) - ->action(function (Order $record, Actions\Action $action) { + ->action(function (Order $record, array $data, Actions\Action $action) { $service = $this->resolveFulfillmentService($record); if (! $service) { @@ -53,8 +63,13 @@ class OrderViewExtension extends ViewPageExtension return; } + $request = new ShipmentRequest( + weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, + destinationLocationId: $data['destination_location_id'] ?? null, + ); + try { - $service->createShipment($record); + $service->createShipment($record, $request); } catch (\Throwable $e) { report($e);