diff --git a/src/Shipping/Support/PlaceShippingRateResolver.php b/src/Shipping/Support/PlaceShippingRateResolver.php new file mode 100644 index 0000000..b77c9ab --- /dev/null +++ b/src/Shipping/Support/PlaceShippingRateResolver.php @@ -0,0 +1,208 @@ +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 $parts + * @return Collection + */ + 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 $zones + * @param Collection $parts + * @return iterable> + */ + 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 $zones + * @return Collection 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 $zones + * @param Collection $visibleMethodIds + * @return Collection + */ + 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); + } +} diff --git a/src/Shipping/Support/RegisteredDriverRateResolver.php b/src/Shipping/Support/RegisteredDriverRateResolver.php deleted file mode 100644 index 4b3748b..0000000 --- a/src/Shipping/Support/RegisteredDriverRateResolver.php +++ /dev/null @@ -1,27 +0,0 @@ -filter(fn ($rate) => $registered->has($rate->shippingMethod?->driver)) - ->values(); - } -} diff --git a/src/Shipping/Support/ShippingManager.php b/src/Shipping/Support/ShippingManager.php index f0d0137..665f55d 100644 --- a/src/Shipping/Support/ShippingManager.php +++ b/src/Shipping/Support/ShippingManager.php @@ -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