Fix: Correctly resolving shipping methods

This commit is contained in:
2026-09-30 18:39:02 +03:00
parent dbea4a8d69
commit eadaf3a580
3 changed files with 211 additions and 30 deletions
@@ -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();
}
}
+3 -3
View File
@@ -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