Feature: Adding Caches and Fallbacks to Live Pricing Requests
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
use Livewire\Mechanisms\ComponentRegistry;
|
||||
@@ -10,6 +11,11 @@ use Lunar\Models\Order;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Modules\Core\Cart\Events\CartCleared;
|
||||
use Modules\Core\Cart\Events\CartLineAdded;
|
||||
use Modules\Core\Cart\Events\CartLineRemoved;
|
||||
use Modules\Core\Cart\Events\CartLineUpdated;
|
||||
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
||||
use Modules\Core\Shipping\Carriers\Acs\AcsClient;
|
||||
use Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService;
|
||||
use Modules\Core\Shipping\Carriers\Acs\AcsRateDriver;
|
||||
@@ -20,6 +26,7 @@ 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\Listeners\FlushLivePricingCache;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
class ShippingServiceProvider extends ServiceProvider
|
||||
@@ -62,6 +69,10 @@ class ShippingServiceProvider extends ServiceProvider
|
||||
return $order->hasMany(Shipment::class);
|
||||
});
|
||||
|
||||
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
|
||||
Event::listen($event, [FlushLivePricingCache::class, 'handle']);
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -8,12 +8,14 @@ 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\CachesLivePricing;
|
||||
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||
|
||||
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
||||
{
|
||||
use ResolvesFixedPricing;
|
||||
use CachesLivePricing;
|
||||
|
||||
public ShippingRate $shippingRate;
|
||||
|
||||
@@ -51,8 +53,17 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
||||
return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapped in CachesLivePricing's cache so a live-pricing outage within
|
||||
* the cache window still serves the last successful quote instead of
|
||||
* immediately falling back. A cold cache during an outage falls back
|
||||
* to the rate's own configured static price (resolveFixedPrice()) —
|
||||
* see ManageShippingRates, which now allows a static price to be
|
||||
* configured on a "live" rate specifically for this fallback.
|
||||
*/
|
||||
private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption
|
||||
{
|
||||
return $this->cached($shippingRate, $cart, function () use ($shippingRate, $shippingMethod, $cart, $postcode) {
|
||||
try {
|
||||
$destination = $this->areaResolver->resolve($postcode);
|
||||
|
||||
@@ -66,7 +77,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
||||
} catch (AcsApiException $e) {
|
||||
report($e);
|
||||
|
||||
return null;
|
||||
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
|
||||
}
|
||||
|
||||
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
|
||||
@@ -80,6 +91,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
||||
taxReference: $shippingRate->getTaxReference(),
|
||||
meta: ['acs_station_destination' => $destination->stationId],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function on(ShippingRate $shippingRate): self
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lunar\DataTypes\ShippingOption;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
|
||||
/**
|
||||
* Shared by any Modules\Core\Shipping\Contracts\SupportsLivePricing driver —
|
||||
* a live carrier price quote is a real, billed API call, but stable from
|
||||
* one fetch to the next within a single checkout attempt. Keyed by rate id
|
||||
* + cart id, so two different live-priced rates (e.g. ACS and a future
|
||||
* carrier) never collide — each is its own ShippingRate row. Not shared
|
||||
* across carts: the quote depends on cart-specific weight/quantity/
|
||||
* destination (see docs/checkout.md).
|
||||
*
|
||||
* Invalidated by Modules\Core\Shipping\Listeners\FlushLivePricingCache on
|
||||
* the only two things that can change what this cart's quote should be: a
|
||||
* cart line changing (add/update/remove/clear) or the shipping address
|
||||
* changing. Deliberately NOT invalidated on order placement — the price
|
||||
* the shopper was quoted must still be there if anything re-reads it after
|
||||
* the order exists; Cart::createOrder() persists the resolved price onto
|
||||
* the order anyway, so nothing should be re-querying the live driver for
|
||||
* that cart again regardless of cache state.
|
||||
*/
|
||||
trait CachesLivePricing
|
||||
{
|
||||
private function cached(ShippingRate $shippingRate, Cart $cart, \Closure $resolve): ?ShippingOption
|
||||
{
|
||||
return Cache::remember(
|
||||
"shipping.live_price.{$shippingRate->id}.{$cart->id}",
|
||||
now()->addMinutes(30),
|
||||
$resolve,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Pages;
|
||||
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
@@ -17,13 +19,20 @@ use Lunar\Shipping\Models\ShippingRate;
|
||||
* ShippingZoneResource::getPages() — is untouched; the container simply
|
||||
* hands back this subclass whenever the vendor class is resolved.
|
||||
*
|
||||
* Hides the price / price-break fields for a rate whose method has
|
||||
* Relabels 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.
|
||||
* option to methods whose driver supports live pricing) — they stay
|
||||
* visible and editable, but as the fallback price used when the live API
|
||||
* call fails (see AcsRateDriver::resolveLivePrice()), not the primary
|
||||
* price. 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.
|
||||
*
|
||||
* Also re-binds the vendor price field's afterStateHydrated(): the vendor
|
||||
* callback reads $record->basePrices->first()->price->decimal with no
|
||||
* null-guard, which crashes on any rate with no basePrices row — routine
|
||||
* for a live rate that has never had a fallback price configured. Same
|
||||
* logic, just null-safe.
|
||||
*/
|
||||
class ManageShippingRates extends BaseManageShippingRates
|
||||
{
|
||||
@@ -32,13 +41,13 @@ class ManageShippingRates extends BaseManageShippingRates
|
||||
$form = parent::form($form);
|
||||
|
||||
return $form->schema(
|
||||
$this->hidePriceFieldsWhenLive($form->getComponents())
|
||||
$this->labelPriceFieldsAsFallbackWhenLive($form->getComponents())
|
||||
);
|
||||
}
|
||||
|
||||
private function hidePriceFieldsWhenLive(array $components): array
|
||||
private function labelPriceFieldsAsFallbackWhenLive(array $components): array
|
||||
{
|
||||
$isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live';
|
||||
$isLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) === 'live';
|
||||
|
||||
foreach ($components as $component) {
|
||||
if (! method_exists($component, 'getName')) {
|
||||
@@ -46,11 +55,25 @@ class ManageShippingRates extends BaseManageShippingRates
|
||||
}
|
||||
|
||||
if ($component->getName() === 'price') {
|
||||
$component->visible($isNotLive)->required($isNotLive)->dehydrated(true);
|
||||
$component->required(fn (Get $get) => ! $isLive($get))
|
||||
->helperText(fn (Get $get) => $isLive($get)
|
||||
? 'Used only if the live API call fails.'
|
||||
: null)
|
||||
->afterStateHydrated(static function (TextInput $component, ?Model $record = null): void {
|
||||
if (! $record) {
|
||||
return;
|
||||
}
|
||||
|
||||
$basePrice = $record->basePrices->first();
|
||||
|
||||
$component->state($basePrice?->price->decimal);
|
||||
});
|
||||
}
|
||||
|
||||
if ($component->getName() === 'prices') {
|
||||
$component->visible($isNotLive)->dehydrated(true);
|
||||
$component->helperText(fn (Get $get) => $isLive($get)
|
||||
? 'Used only if the live API call fails.'
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +91,9 @@ class ManageShippingRates extends BaseManageShippingRates
|
||||
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
|
||||
->formatStateUsing(function ($state, ShippingRate $record) {
|
||||
if (static::methodChargeBy($record->shipping_method_id) === 'live') {
|
||||
return 'Live API pricing';
|
||||
return $state === null
|
||||
? 'Live API pricing, no fallback set'
|
||||
: $state->price->formatted.' (fallback)';
|
||||
}
|
||||
|
||||
return $state?->price->formatted;
|
||||
@@ -80,15 +105,6 @@ class ManageShippingRates extends BaseManageShippingRates
|
||||
);
|
||||
}
|
||||
|
||||
protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void
|
||||
{
|
||||
if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') {
|
||||
return;
|
||||
}
|
||||
|
||||
parent::saveShippingRate($shippingRate, $data);
|
||||
}
|
||||
|
||||
protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string
|
||||
{
|
||||
if (blank($method)) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Listeners;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
use Modules\Core\Cart\Events\CartCleared;
|
||||
use Modules\Core\Cart\Events\CartLineAdded;
|
||||
use Modules\Core\Cart\Events\CartLineRemoved;
|
||||
use Modules\Core\Cart\Events\CartLineUpdated;
|
||||
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||
|
||||
/**
|
||||
* Flushes Modules\Core\Shipping\Concerns\CachesLivePricing's cached quotes
|
||||
* for a cart on the only two things that can change what they should be: a
|
||||
* cart line changing (weight/quantity) or the shipping address changing
|
||||
* (destination). See that trait's docblock for why order placement is
|
||||
* deliberately not a trigger here.
|
||||
*
|
||||
* Only rates whose method's driver implements SupportsLivePricing are ever
|
||||
* cached by CachesLivePricing, so only their ids need a forget() call —
|
||||
* no need to touch every ShippingRate row on every cart change.
|
||||
*/
|
||||
class FlushLivePricingCache
|
||||
{
|
||||
public function handle(CartLineAdded|CartLineUpdated|CartLineRemoved|CartCleared|ShippingAddressSet $event): void
|
||||
{
|
||||
$cart = $event->cart;
|
||||
|
||||
foreach ($this->livePricingRateIds() as $rateId) {
|
||||
Cache::forget("shipping.live_price.{$rateId}.{$cart->id}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int>
|
||||
*/
|
||||
private function livePricingRateIds(): array
|
||||
{
|
||||
return ShippingRate::query()
|
||||
->whereHas('shippingMethod', fn ($query) => $query->whereIn(
|
||||
'driver',
|
||||
$this->liveDriverKeys(),
|
||||
))
|
||||
->pluck('id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
private function liveDriverKeys(): array
|
||||
{
|
||||
return Shipping::getSupportedDrivers()
|
||||
->filter(fn ($driver) => $driver instanceof SupportsLivePricing)
|
||||
->keys()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user