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
|
class ShippingManager extends VendorShippingManager
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Rates for a cart, leaving out shipping methods whose driver is no
|
* Rates for a cart, resolved by place from the inside out (see
|
||||||
* longer registered (see RegisteredDriverRateResolver).
|
* PlaceShippingRateResolver).
|
||||||
*/
|
*/
|
||||||
public function shippingRates(?CartContract $cart = null): ShippingRateResolver
|
public function shippingRates(?CartContract $cart = null): ShippingRateResolver
|
||||||
{
|
{
|
||||||
return new RegisteredDriverRateResolver($cart);
|
return new PlaceShippingRateResolver($cart);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSupportedDrivers(): \Illuminate\Support\Collection
|
public function getSupportedDrivers(): \Illuminate\Support\Collection
|
||||||
|
|||||||
Reference in New Issue
Block a user