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:
2026-07-19 00:50:51 +03:00
parent 3599329b57
commit 89255687a1
10 changed files with 691 additions and 0 deletions
+50
View File
@@ -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),
];
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
class AcsArea
{
public function __construct(
public readonly string $stationId,
public readonly int $branchId,
) {}
}
+34
View File
@@ -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() ?? [],
]);
}
}
}
+134
View File
@@ -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
}
}
+43
View File
@@ -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;
}
}
}