Fix: Correctly resolving shipping methods
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Support;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Models\State;
|
||||
use Lunar\Shipping\DataTransferObjects\PostcodeLookup;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
use Lunar\Shipping\Models\ShippingZone;
|
||||
use Lunar\Shipping\Resolvers\ShippingRateResolver;
|
||||
|
||||
/**
|
||||
* Resolves shipping by place, from the innermost place outwards: postcode →
|
||||
* state → country → unrestricted. The first place that has shipping
|
||||
* zones with usable rates is the whole answer — outer places are not
|
||||
* consulted at all, so a cheaper Athens postcode rule replaces the
|
||||
* Greece-wide one for those postcodes instead of competing with it.
|
||||
*
|
||||
* Lunar's own resolver gathers every matching zone of every type at once
|
||||
* and keeps the first rate per shipping method, so which price a customer
|
||||
* saw depended on the order the zones were inserted.
|
||||
*
|
||||
* Postcode zones are tried from the most specific match to the least, in
|
||||
* the order Lunar's postcode resolver lists the parts ("12345", then
|
||||
* shorter prefixes and their "*" wildcards), so an exact postcode beats a
|
||||
* prefix. Several zones matching at the same place are combined; if two of
|
||||
* them carry the same shipping method, the older zone's rate is used.
|
||||
*
|
||||
* "Usable" means passing the same checks as Lunar's resolver (rate
|
||||
* enabled, method in the customer's group and in schedule, stock, weight
|
||||
* limits) plus one of ours: the method's driver is still registered —
|
||||
* otherwise Lunar calls a missing driver to price it and the checkout
|
||||
* fails with "Driver [x] not supported". So a place whose rates all fail
|
||||
* those checks (e.g. the cart is too heavy for its only method) falls
|
||||
* through to the next place out.
|
||||
*
|
||||
* Exclusion lists deliberately do NOT make a place fall through. An
|
||||
* exclusion list on a zone means "these products can't be shipped there",
|
||||
* so the place is still the answer; each driver then drops that zone's
|
||||
* options for a cart holding an excluded product
|
||||
* (ExcludesRestrictedProducts). If nothing is left, the customer gets no
|
||||
* shipping options for that address rather than an outer place's rule.
|
||||
*
|
||||
* Queries stay fixed whatever the number of places or zones: every zone
|
||||
* containing the address comes back in one query (with its matching
|
||||
* postcodes, rates and methods eager-loaded), the customer-group check is
|
||||
* one query over all their methods, and the layering happens in PHP.
|
||||
*/
|
||||
class PlaceShippingRateResolver extends ShippingRateResolver
|
||||
{
|
||||
public function get(): Collection
|
||||
{
|
||||
if (! $this->postcode || ! $this->country) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$parts = (new PostcodeLookup(country: $this->country, postcode: $this->postcode))->getParts()->values();
|
||||
$state = $this->state ? State::whereName($this->state)->first() : null;
|
||||
|
||||
$zones = $this->candidateZones($parts, $state);
|
||||
$visibleMethodIds = $this->methodsVisibleToCustomer($zones);
|
||||
|
||||
foreach ($this->placesInsideOut($zones, $parts) as $placeZones) {
|
||||
$rates = $this->usableRates($placeZones, $visibleMethodIds);
|
||||
|
||||
if ($rates->isNotEmpty()) {
|
||||
return $rates;
|
||||
}
|
||||
}
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every zone containing the address, of every place, oldest first.
|
||||
*
|
||||
* @param Collection<int, string> $parts
|
||||
* @return Collection<int, ShippingZone>
|
||||
*/
|
||||
private function candidateZones(Collection $parts, ?State $state): Collection
|
||||
{
|
||||
$inCountry = fn (Builder $countries) => $countries->where('country_id', $this->country->id);
|
||||
|
||||
return ShippingZone::query()
|
||||
->where(function (Builder $query) use ($parts, $state, $inCountry) {
|
||||
$query
|
||||
->where(fn (Builder $zone) => $zone->where('type', 'postcodes')
|
||||
->whereHas('postcodes', fn (Builder $postcodes) => $postcodes->whereIn('postcode', $parts))
|
||||
->whereHas('countries', $inCountry))
|
||||
->when($state, fn (Builder $q) => $q->orWhere(fn (Builder $zone) => $zone->where('type', 'states')
|
||||
->whereHas('states', fn (Builder $states) => $states->where('state_id', $state->id))))
|
||||
->orWhere(fn (Builder $zone) => $zone->where('type', 'countries')
|
||||
->whereHas('countries', $inCountry))
|
||||
->orWhere('type', 'unrestricted');
|
||||
})
|
||||
->with([
|
||||
'postcodes' => fn ($postcodes) => $postcodes->whereIn('postcode', $parts),
|
||||
'rates' => fn ($rates) => $rates->orderBy('id'),
|
||||
'rates.shippingMethod',
|
||||
])
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate zones grouped by place, innermost first. A postcode
|
||||
* zone belongs to the most specific part it matches.
|
||||
*
|
||||
* @param Collection<int, ShippingZone> $zones
|
||||
* @param Collection<int, string> $parts
|
||||
* @return iterable<int, Collection<int, ShippingZone>>
|
||||
*/
|
||||
private function placesInsideOut(Collection $zones, Collection $parts): iterable
|
||||
{
|
||||
$specificity = $parts->flip();
|
||||
|
||||
$postcodeZones = $zones
|
||||
->where('type', 'postcodes')
|
||||
->groupBy(fn (ShippingZone $zone) => $zone->postcodes
|
||||
->map(fn ($postcode) => $specificity[$postcode->postcode] ?? PHP_INT_MAX)
|
||||
->min());
|
||||
|
||||
foreach ($postcodeZones->sortKeys() as $placeZones) {
|
||||
yield $placeZones;
|
||||
}
|
||||
|
||||
foreach (['states', 'countries', 'unrestricted'] as $type) {
|
||||
yield $zones->where('type', $type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of the candidate zones' shipping methods that the customer's
|
||||
* group may use — Lunar's own customerGroup() check, once for all of
|
||||
* them instead of once per rate.
|
||||
*
|
||||
* @param Collection<int, ShippingZone> $zones
|
||||
* @return Collection<int, bool> keyed by method id
|
||||
*/
|
||||
private function methodsVisibleToCustomer(Collection $zones): Collection
|
||||
{
|
||||
$methodIds = $zones->flatMap->rates->pluck('shipping_method_id')->filter()->unique()->values();
|
||||
|
||||
if ($methodIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return ShippingMethod::query()
|
||||
->whereIn('id', $methodIds)
|
||||
->customerGroup($this->customerGroups)
|
||||
->pluck('id')
|
||||
->flip()
|
||||
->map(fn () => true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The zones' rates that pass Lunar's own checks and whose driver is
|
||||
* registered — one per shipping method.
|
||||
*
|
||||
* @param Collection<int, ShippingZone> $zones
|
||||
* @param Collection<int, bool> $visibleMethodIds
|
||||
* @return Collection<int, ShippingRate>
|
||||
*/
|
||||
private function usableRates(Collection $zones, Collection $visibleMethodIds): Collection
|
||||
{
|
||||
$registered = Shipping::getSupportedDrivers();
|
||||
|
||||
return $zones
|
||||
->flatMap(fn (ShippingZone $zone) => $zone->rates)
|
||||
->filter(function (ShippingRate $rate) use ($registered, $visibleMethodIds) {
|
||||
$method = $rate->shippingMethod;
|
||||
|
||||
if (! $rate->enabled || ! $method || ! $visibleMethodIds->has($method->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $method->isAvailable() || ! $registered->has($method->driver)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->allCartItemsAreInStock && ($method->stock_available ?? false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->withinWeightLimits($method);
|
||||
})
|
||||
->unique(fn (ShippingRate $rate) => $rate->shippingMethod->code)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function withinWeightLimits(ShippingMethod $method): bool
|
||||
{
|
||||
$minWeight = $method->min_weight;
|
||||
$maxWeight = $method->max_weight;
|
||||
|
||||
if ($minWeight === null && $maxWeight === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$cartWeight = $this->cartWeightIn($method->weight_unit ?? 'kg');
|
||||
|
||||
return ! ($minWeight !== null && $cartWeight < (float) $minWeight)
|
||||
&& ! ($maxWeight !== null && $cartWeight > (float) $maxWeight);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Support;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Lunar\Shipping\Resolvers\ShippingRateResolver;
|
||||
|
||||
/**
|
||||
* Lunar's rate resolver, minus rates whose shipping method points at a
|
||||
* driver that isn't registered anymore (e.g. a carrier commented out of
|
||||
* ShippingServiceProvider while its shipping method still exists). Lunar
|
||||
* would go on to call that driver to price the option and throw "Driver
|
||||
* [x] not supported", taking the whole checkout down; here the method is
|
||||
* simply not offered. It stays in the admin as it was.
|
||||
*/
|
||||
class RegisteredDriverRateResolver extends ShippingRateResolver
|
||||
{
|
||||
public function get(): Collection
|
||||
{
|
||||
$registered = Shipping::getSupportedDrivers();
|
||||
|
||||
return parent::get()
|
||||
->filter(fn ($rate) => $registered->has($rate->shippingMethod?->driver))
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,12 @@ use Lunar\Shipping\Resolvers\ShippingRateResolver;
|
||||
class ShippingManager extends VendorShippingManager
|
||||
{
|
||||
/**
|
||||
* Rates for a cart, leaving out shipping methods whose driver is no
|
||||
* longer registered (see RegisteredDriverRateResolver).
|
||||
* Rates for a cart, resolved by place from the inside out (see
|
||||
* PlaceShippingRateResolver).
|
||||
*/
|
||||
public function shippingRates(?CartContract $cart = null): ShippingRateResolver
|
||||
{
|
||||
return new RegisteredDriverRateResolver($cart);
|
||||
return new PlaceShippingRateResolver($cart);
|
||||
}
|
||||
|
||||
public function getSupportedDrivers(): \Illuminate\Support\Collection
|
||||
|
||||
Reference in New Issue
Block a user