the browser-facing portion of osu!
at master 8.0 kB view raw
1<?php 2 3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4// See the LICENCE file in the repository root for full licence text. 5 6namespace App\Models\Store; 7 8use App\Exceptions\InsufficientStockException; 9use Carbon\Carbon; 10 11/** 12 * @property bool $allow_multiple 13 * @property \Carbon\Carbon|null $available_until 14 * @property float $base_shipping 15 * @property float|null $cost 16 * @property \Carbon\Carbon $created_at 17 * @property string|null $custom_class 18 * @property \Carbon\Carbon|null $deleted_at 19 * @property string|null $description 20 * @property int $display_order 21 * @property bool $enabled 22 * @property string|null $header_description 23 * @property string|null $header_image 24 * @property string|null $image 25 * @property string|null $images_json 26 * @property self $masterProduct 27 * @property int|null $master_product_id 28 * @property int $max_quantity 29 * @property string $name 30 * @property float $next_shipping 31 * @property \Illuminate\Database\Eloquent\Collection $notificationRequests NotificationRequest 32 * @property int $product_id 33 * @property bool $promoted 34 * @property string|null $shopify_id 35 * @property int|null $stock 36 * @property string|null $type_mappings_json 37 * @property \Carbon\Carbon|null $updated_at 38 * @property \Illuminate\Database\Eloquent\Collection $variations static 39 * @property int|null $weight 40 */ 41class Product extends Model 42{ 43 const BUTTON_DISABLED = [self::SUPPORTER_TAG_NAME, self::USERNAME_CHANGE]; 44 const REDIRECT_PLACEHOLDER = 'redirect'; 45 const SUPPORTER_TAG_NAME = 'supporter-tag'; 46 const USERNAME_CHANGE = 'username-change'; 47 48 protected $primaryKey = 'product_id'; 49 50 protected $casts = [ 51 'available_until' => 'datetime', 52 'cost' => 'float', 53 'base_shipping' => 'float', 54 'next_shipping' => 'float', 55 'promoted' => 'boolean', 56 'enabled' => 'boolean', 57 'allow_multiple' => 'boolean', 58 ]; 59 60 private $images; 61 private $types; 62 63 public function masterProduct() 64 { 65 return $this->belongsTo(static::class, 'master_product_id'); 66 } 67 68 public function variations() 69 { 70 return $this->hasMany(static::class, 'master_product_id'); 71 } 72 73 public function notificationRequests() 74 { 75 return $this->hasMany(NotificationRequest::class); 76 } 77 78 public function isRedirectPlaceholder() 79 { 80 return $this->custom_class === static::REDIRECT_PLACEHOLDER; 81 } 82 83 public function inStock($quantity = 1, $includeVariations = false) 84 { 85 $inStock = $this->stock === null || $this->stock >= $quantity; 86 87 if ($inStock === false && $includeVariations === true) { 88 $inStock = ($this->masterProduct ?? $this) 89 ->variations 90 ->contains(function ($variation) use ($quantity) { 91 return $variation->inStock($quantity); 92 }); 93 } 94 95 return $inStock; 96 } 97 98 public function getHeaderImageAttribute($value) 99 { 100 if ($this->masterProduct) { 101 return $this->masterProduct->header_image; 102 } else { 103 return $value; 104 } 105 } 106 107 public function getHeaderDescriptionAttribute($value) 108 { 109 if ($this->masterProduct) { 110 return $this->masterProduct->header_description; 111 } else { 112 return $value; 113 } 114 } 115 116 public function getDescriptionAttribute($value) 117 { 118 return presence($value) ?? $this->masterProduct?->description; 119 } 120 121 public function isAvailable(): bool 122 { 123 return $this->enabled 124 && ($this->available_until === null ? true : $this->available_until->isFuture()); 125 } 126 127 public function typeMappings() 128 { 129 if ($this->masterProduct) { 130 return $this->masterProduct->typeMappings(); 131 } else { 132 return json_decode($this->type_mappings_json, true); 133 } 134 } 135 136 public function images() 137 { 138 if (!$this->images_json && $this->masterProduct) { 139 return $this->masterProduct->images(); 140 } else { 141 if (!$this->images && $this->images_json) { 142 $this->images = json_decode($this->images_json, true); 143 } 144 145 return $this->images ?? []; 146 } 147 } 148 149 public function requiresShipping() 150 { 151 return $this->weight !== null; 152 } 153 154 public function scopeAvailable($query) 155 { 156 return $query 157 ->where('enabled', true) 158 ->where(function ($q) { 159 return $q->whereNull('available_until')->orWhere('available_until', '>=', Carbon::now()); 160 }); 161 } 162 163 public function scopeNotAvailable($query) 164 { 165 return $query->where('available_until', '<', Carbon::now()); 166 } 167 168 public function scopeListing($query) 169 { 170 return $query 171 ->available() 172 ->where('master_product_id', null) 173 ->with('masterProduct') 174 ->with('variations') 175 ->orderBy('promoted', 'desc') 176 ->orderBy('display_order', 'desc'); 177 } 178 179 public function scopeCustomClass($query, $name) 180 { 181 return $query 182 ->where('custom_class', $name); 183 } 184 185 public function scopeEnabled($query) 186 { 187 return $query->where('enabled', true); 188 } 189 190 public function scopeHasShipping($query) 191 { 192 return $query->whereNotNull('weight'); 193 } 194 195 /** 196 * Returns the Shopify product variant GraphQL gid for this Product, null if it is not a Shopify item. 197 * This is currently implemented as convenience for checking the gid matches the one from the Storefront API. 198 * 199 * @return string|null 200 */ 201 public function getShopifyVariantGid(): ?string 202 { 203 return $this->isShopify() ? base64_encode("gid://shopify/ProductVariant/{$this->shopify_id}") : null; 204 } 205 206 public function isShopify(): bool 207 { 208 return $this->shopify_id !== null; 209 } 210 211 public function productsInRange() 212 { 213 if (!($mappings = $this->typeMappings())) { 214 return []; 215 } 216 217 return self::whereIn('product_id', array_keys($mappings))->get(); 218 } 219 220 public function release($quantity) 221 { 222 if ( 223 $this->stock === null 224 // stock may have been directly updated to 0. 225 // TODO: should count reservations and available stock separately or something. 226 || $this->stock <= 0 227 ) { 228 return; 229 } 230 231 $this->incrementInstance('stock', $quantity); 232 } 233 234 public function reserve($quantity) 235 { 236 if ($this->stock === null) { 237 return; 238 } 239 240 $this->decrementInstance('stock', $quantity); 241 242 // operating under the assumtion that the caller will prevent concurrent updates. 243 if ($this->stock < 0) { 244 throw new InsufficientStockException(); 245 } 246 } 247 248 public function types() 249 { 250 $mappings = $this->typeMappings(); 251 if ($mappings === null) { 252 return; 253 } 254 255 if ($this->types !== null) { 256 return $this->types; 257 } 258 259 $currentMapping = $mappings[strval($this->product_id)]; 260 $this->types = []; 261 262 foreach ($mappings as $product_id => $mapping) { 263 foreach ($mapping as $type => $value) { 264 if (!isset($this->types[$type])) { 265 $this->types[$type] = []; 266 } 267 $mappingDiff = array_diff_assoc($mapping, $currentMapping); 268 if ((count($mappingDiff) === 0) || (count($mappingDiff) === 1 && isset($mappingDiff[$type]))) { 269 $this->types[$type][$value] = intval($product_id); 270 } 271 } 272 } 273 274 return $this->types; 275 } 276 277 public function url(): string 278 { 279 return $this->isRedirectPlaceholder() 280 ? $this->description 281 : route('store.products.show', $this); 282 } 283}