'array', 'operating_hours' => 'array', 'is_active' => 'boolean', 'is_default' => 'boolean', 'min_lat' => 'decimal:7', 'max_lat' => 'decimal:7', 'min_lng' => 'decimal:7', 'max_lng' => 'decimal:7', 'max_delivery_distance' => 'decimal:2', ]; protected static function boot() { parent::boot(); static::saving(function ($zone) { if (! empty($zone->coordinates)) { $zone->calculateBoundingBox(); } }); } /* |-------------------------------------------------------------------------- | Relationships |-------------------------------------------------------------------------- */ public function pricingRules(): HasMany { return $this->hasMany(ZonePricingRule::class, 'zone_id') ->orderBy('priority'); } public function activePricingRule(): HasMany { return $this->hasMany(ZonePricingRule::class, 'zone_id') ->where('is_active', true) ->orderBy('priority') ->limit(1); } public function deliveries(): HasMany { return $this->hasMany(Delivery::class, 'zone_id'); } /* |-------------------------------------------------------------------------- | Scopes |-------------------------------------------------------------------------- */ public function scopeActive($query) { return $query->where('is_active', true); } public function scopeDefault($query) { return $query->where('is_default', true); } public function scopeByPriority($query) { return $query->orderByDesc('priority'); } /* |-------------------------------------------------------------------------- | Methods |-------------------------------------------------------------------------- */ public function calculateBoundingBox(): void { if (empty($this->coordinates)) { return; } $lats = array_column($this->coordinates, 0); $lngs = array_column($this->coordinates, 1); $this->min_lat = min($lats); $this->max_lat = max($lats); $this->min_lng = min($lngs); $this->max_lng = max($lngs); } public function containsPoint(float $latitude, float $longitude): bool { // Quick bounding box check first if ( $latitude < $this->min_lat || $latitude > $this->max_lat || $longitude < $this->min_lng || $longitude > $this->max_lng ) { return false; } // Ray casting algorithm for polygon containment return $this->pointInPolygon($latitude, $longitude, $this->coordinates); } protected function pointInPolygon(float $latitude, float $longitude, array $polygon): bool { $n = count($polygon); $inside = false; $x = $longitude; $y = $latitude; $p1x = $polygon[0][1]; $p1y = $polygon[0][0]; for ($i = 1; $i <= $n; $i++) { $p2x = $polygon[$i % $n][1]; $p2y = $polygon[$i % $n][0]; if ($y > min($p1y, $p2y)) { if ($y <= max($p1y, $p2y)) { if ($x <= max($p1x, $p2x)) { if ($p1y != $p2y) { $xinters = ($y - $p1y) * ($p2x - $p1x) / ($p2y - $p1y) + $p1x; } if ($p1x == $p2x || $x <= $xinters) { $inside = ! $inside; } } } } $p1x = $p2x; $p1y = $p2y; } return $inside; } public function isOperatingNow(): bool { if (empty($this->operating_hours)) { return true; // No restrictions } $now = now(); $dayName = strtolower($now->format('l')); if (! isset($this->operating_hours[$dayName])) { return false; } $hours = $this->operating_hours[$dayName]; if (isset($hours['closed']) && $hours['closed']) { return false; } $openTime = \Carbon\Carbon::createFromTimeString($hours['open']); $closeTime = \Carbon\Carbon::createFromTimeString($hours['close']); // Handle overnight hours if ($closeTime->lt($openTime)) { return $now->gte($openTime) || $now->lte($closeTime); } return $now->between($openTime, $closeTime); } public function getActivePricingRule(): ?ZonePricingRule { return $this->pricingRules() ->where('is_active', true) ->orderBy('priority') ->first(); } public function canDeliverTo(float $latitude, float $longitude): bool { if (! $this->is_active) { return false; } if (! $this->containsPoint($latitude, $longitude)) { return false; } if (! $this->isOperatingNow()) { return false; } return true; } public static function findForPoint(float $latitude, float $longitude): ?self { return static::active() ->byPriority() ->get() ->first(fn ($zone) => $zone->containsPoint($latitude, $longitude)); } public static function getDefault(): ?self { return static::active()->default()->first(); } }