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