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.
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| ACS Courier credentials
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
|
||||||
|
| HTTP header (gates the REST gateway itself) and four account fields
|
||||||
|
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
|
||||||
|
| request body. Both are supplied by ACS when your account is set up.
|
||||||
|
|
|
||||||
|
| Set these via environment variables — never commit real values.
|
||||||
|
|
|
||||||
|
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
|
||||||
|
| every ACSAlias call).
|
||||||
|
| ACS_API_KEY The AcsApiKey header value.
|
||||||
|
| ACS_COMPANY_ID Company_ID body field.
|
||||||
|
| ACS_COMPANY_PASSWORD Company_Password body field.
|
||||||
|
| ACS_USER_ID User_ID body field.
|
||||||
|
| ACS_USER_PASSWORD User_Password body field.
|
||||||
|
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
|
||||||
|
| calculation and voucher creation.
|
||||||
|
| ACS_SENDER_* Static sender details reused on every voucher.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'base_url' => 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),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
class AcsArea
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $stationId,
|
||||||
|
public readonly int $branchId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class AcsClient
|
||||||
|
{
|
||||||
|
public function __construct(private readonly array $config) {}
|
||||||
|
|
||||||
|
public function call(string $alias, array $parameters = []): AcsResponse
|
||||||
|
{
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'AcsApiKey' => $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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
|
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||||
|
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
|
||||||
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
|
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AcsClient $client,
|
||||||
|
private readonly AreaResolver $areaResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function createShipment(Order $order, array $overrides = []): Shipment
|
||||||
|
{
|
||||||
|
$address = $order->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() ?? [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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\Contracts\SupportsLivePricing;
|
||||||
|
|
||||||
|
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
||||||
|
{
|
||||||
|
public ShippingRate $shippingRate;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AcsClient $client,
|
||||||
|
private readonly AreaResolver $areaResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'ACS Courier';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Live rate quote from ACS Courier.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
|
||||||
|
{
|
||||||
|
$shippingRate = $shippingOptionRequest->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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||||
|
|
||||||
|
class AcsResponse
|
||||||
|
{
|
||||||
|
private function __construct(
|
||||||
|
public readonly bool $hasError,
|
||||||
|
public readonly ?string $errorMessage,
|
||||||
|
public readonly array $valueOutput,
|
||||||
|
public readonly array $tableOutput,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromHttpResponse(Response $response): self
|
||||||
|
{
|
||||||
|
$body = $response->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||||
|
|
||||||
|
class AreaResolver
|
||||||
|
{
|
||||||
|
public const CACHE_KEY = 'acs.areas';
|
||||||
|
|
||||||
|
public function __construct(private readonly AcsClient $client) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a Greek postcode to its ACS station/branch codes.
|
||||||
|
*
|
||||||
|
* Reads from the table warmed daily by WarmAcsAreaCacheJob. Falls
|
||||||
|
* back to a live lookup for that single postcode if the warmed cache is
|
||||||
|
* missing (e.g. the daily job never ran or failed) or doesn't contain it.
|
||||||
|
*/
|
||||||
|
public function resolve(string $postcode): AcsArea
|
||||||
|
{
|
||||||
|
$areas = Cache::get(self::CACHE_KEY);
|
||||||
|
|
||||||
|
if ($areas !== null && isset($areas[$postcode])) {
|
||||||
|
return $this->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'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class AcsApiException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(string $message, public readonly array $tableOutput = [])
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs\Jobs;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\AreaResolver;
|
||||||
|
|
||||||
|
class WarmAcsAreaCacheJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public int $backoff = 60;
|
||||||
|
|
||||||
|
public function handle(AreaResolver $areaResolver): void
|
||||||
|
{
|
||||||
|
$areaResolver->warmAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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\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
|
||||||
|
* (see ShippingServiceProvider), since that page has no extension hook of
|
||||||
|
* its own. Every reference to the vendor class name — routes, sub-nav,
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
class ManageShippingRates extends BaseManageShippingRates
|
||||||
|
{
|
||||||
|
public function form(Form $form): Form
|
||||||
|
{
|
||||||
|
$form = parent::form($form);
|
||||||
|
|
||||||
|
return $form->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user