| @@ -3,8 +3,9 @@ | ||
| 3 | 3 | namespace FluentCart\App\Models; |
| 4 | 4 | |
| 5 | 5 | use FluentCart\Api\ModuleSettings; |
| 6 | 6 | use FluentCart\App\App; |
| 7 | +use FluentCart\App\Helpers\AttributeHelper; | |
| 7 | 8 | use FluentCart\App\Helpers\Helper; |
| 8 | 9 | use FluentCart\App\Helpers\Status; |
| 9 | 10 | use FluentCart\App\Models\Concerns\CanSearch; |
| 10 | 11 | use FluentCart\App\Models\Concerns\CanUpdateBatch; |
| @@ -130,8 +131,67 @@ | ||
| 130 | 131 | return $this->belongsTo(ShippingClass::class, 'shipping_class', 'id'); |
| 131 | 132 | } |
| 132 | 133 | |
| 133 | 134 | /** |
| 135 | + * Labeled attribute combination for this variation, e.g. "Color: Red | Size: XS". | |
| 136 | + * | |
| 137 | + * Uses the batched `variation_display_title` when | |
| 138 | + * AttributeHelper::attachVariationDisplayTitles() has already resolved it, | |
| 139 | + * and otherwise resolves on demand — so callers get a correct value either | |
| 140 | + * way, and a correctly batched caller costs no extra queries. | |
| 141 | + * | |
| 142 | + * @return string Falls back to the stored variation title. | |
| 143 | + */ | |
| 144 | + public function getVariationLabel(): string | |
| 145 | + { | |
| 146 | + $resolved = (string) ($this->variation_display_title ?? ''); | |
| 147 | + | |
| 148 | + if ($resolved !== '') { | |
| 149 | + return $resolved; | |
| 150 | + } | |
| 151 | + | |
| 152 | + $variationType = $this->product_detail ? $this->product_detail->variation_type : ''; | |
| 153 | + | |
| 154 | + $label = AttributeHelper::getDisplayAttributesString( | |
| 155 | + AttributeHelper::getProductItemAttributes($this->id, $this->post_id), | |
| 156 | + [ | |
| 157 | + 'title' => $this->variation_title, | |
| 158 | + 'variation_type' => $variationType, | |
| 159 | + 'other_info' => ['variation_type' => $variationType], | |
| 160 | + ], | |
| 161 | + 'order_item' | |
| 162 | + ); | |
| 163 | + | |
| 164 | + return $label !== '' ? $label : (string) $this->variation_title; | |
| 165 | + } | |
| 166 | + | |
| 167 | + /** | |
| 168 | + * Full display title for this variation: "<product> - <attribute combination>". | |
| 169 | + * | |
| 170 | + * The catalogue-side counterpart to OrderItem::getDisplayTitle(), which | |
| 171 | + * composes the same shape from a frozen line item. Product feeds, plan | |
| 172 | + * names and CLI output all need the product name alongside the variation, | |
| 173 | + * and each was concatenating it themselves. | |
| 174 | + * | |
| 175 | + * @return string | |
| 176 | + */ | |
| 177 | + public function getDisplayTitle(): string | |
| 178 | + { | |
| 179 | + $label = $this->getVariationLabel(); | |
| 180 | + $productTitle = $this->product ? (string) $this->product->post_title : ''; | |
| 181 | + | |
| 182 | + if ($productTitle === '' || $productTitle === $label) { | |
| 183 | + return $label; | |
| 184 | + } | |
| 185 | + | |
| 186 | + if ($label === '') { | |
| 187 | + return $productTitle; | |
| 188 | + } | |
| 189 | + | |
| 190 | + return $productTitle . ' - ' . $label; | |
| 191 | + } | |
| 192 | + | |
| 193 | + /** | |
| 134 | 194 | * One2One: Product Variation belongs to one Product detail |
| 135 | 195 | * |
| 136 | 196 | * @return \FluentCart\Framework\Database\Orm\Relations\BelongsTo |
| 137 | 197 | */ |
| @@ -313,9 +373,15 @@ | ||
| 313 | 373 | } |
| 314 | 374 | return false; |
| 315 | 375 | } |
| 316 | 376 | |
| 317 | - public function isStock(): bool | |
| 377 | + /** | |
| 378 | + * @param array|null $bundleChildren Bundle child rows keyed by id, from | |
| 379 | + * loadBundleChildren(), when the caller has several bundle variations | |
| 380 | + * to ask about and wants one query for all their children instead of | |
| 381 | + * one per variation. Null queries this variation's own children. | |
| 382 | + */ | |
| 383 | + public function isStock($bundleChildren = null): bool | |
| 318 | 384 | { |
| 319 | 385 | // Check if variation is active |
| 320 | 386 | if ($this->item_status !== 'active') { |
| 321 | 387 | return false; |
| @@ -327,9 +393,9 @@ | ||
| 327 | 393 | // If stock management is disabled for this variation |
| 328 | 394 | if (!$this->manage_stock) { |
| 329 | 395 | // For bundle products, still check child items |
| 330 | 396 | if ($isBundleProduct) { |
| 331 | - return $this->isBundleChildrenInStock(); | |
| 397 | + return $this->isBundleChildrenInStock($bundleChildren); | |
| 332 | 398 | } |
| 333 | 399 | // For regular products without stock management, check status |
| 334 | 400 | return $this->stock_status === Helper::IN_STOCK; |
| 335 | 401 | } |
| @@ -346,17 +412,53 @@ | ||
| 346 | 412 | if (!$hasStock) { |
| 347 | 413 | return false; |
| 348 | 414 | } |
| 349 | 415 | |
| 350 | - return $this->isBundleChildrenInStock(); | |
| 416 | + return $this->isBundleChildrenInStock($bundleChildren); | |
| 351 | 417 | } |
| 352 | 418 | |
| 353 | 419 | /** |
| 420 | + * The bundle children of several variations in one query, keyed by id, | |
| 421 | + * for isStock($bundleChildren). Only the columns the stock check reads. | |
| 422 | + * | |
| 423 | + * @param iterable $variations | |
| 424 | + * @return array<int, static> | |
| 425 | + */ | |
| 426 | + public static function loadBundleChildren($variations): array | |
| 427 | + { | |
| 428 | + $childIds = []; | |
| 429 | + foreach ($variations as $variation) { | |
| 430 | + $ids = Arr::get($variation->other_info, 'bundle_child_ids', []); | |
| 431 | + if (is_array($ids)) { | |
| 432 | + foreach ($ids as $id) { | |
| 433 | + $childIds[(int) $id] = true; | |
| 434 | + } | |
| 435 | + } | |
| 436 | + } | |
| 437 | + unset($childIds[0]); | |
| 438 | + | |
| 439 | + if (!$childIds) { | |
| 440 | + return []; | |
| 441 | + } | |
| 442 | + | |
| 443 | + $children = static::query() | |
| 444 | + ->whereIn('id', array_keys($childIds)) | |
| 445 | + ->get(['id', 'manage_stock', 'available', 'stock_status', 'item_status', 'post_id', 'other_info']); | |
| 446 | + | |
| 447 | + $byId = []; | |
| 448 | + foreach ($children as $child) { | |
| 449 | + $byId[(int) $child->id] = $child; | |
| 450 | + } | |
| 451 | + | |
| 452 | + return $byId; | |
| 453 | + } | |
| 454 | + | |
| 455 | + /** | |
| 354 | 456 | * Check if all bundle children are in stock |
| 355 | 457 | * |
| 356 | 458 | * @return bool |
| 357 | 459 | */ |
| 358 | - protected function isBundleChildrenInStock(): bool | |
| 460 | + protected function isBundleChildrenInStock($preloadedChildren = null): bool | |
| 359 | 461 | { |
| 360 | 462 | $childIds = Arr::get($this->other_info, 'bundle_child_ids', []); |
| 361 | 463 | |
| 362 | 464 | // No bundle children, consider as in stock |
| @@ -363,12 +465,33 @@ | ||
| 363 | 465 | if (empty($childIds) || !is_array($childIds)) { |
| 364 | 466 | return true; |
| 365 | 467 | } |
| 366 | 468 | |
| 367 | - // Get all bundle children variations | |
| 368 | - $children = static::query() | |
| 369 | - ->whereIn('id', $childIds) | |
| 370 | - ->get(['id', 'manage_stock', 'available', 'stock_status', 'item_status', 'post_id', 'other_info']); | |
| 469 | + $childIds = array_values(array_unique(array_map('intval', $childIds))); | |
| 470 | + | |
| 471 | + if (is_array($preloadedChildren)) { | |
| 472 | + // From loadBundleChildren(). | |
| 473 | + $children = []; | |
| 474 | + foreach ($childIds as $childId) { | |
| 475 | + if (isset($preloadedChildren[$childId])) { | |
| 476 | + $children[] = $preloadedChildren[$childId]; | |
| 477 | + } | |
| 478 | + } | |
| 479 | + } else { | |
| 480 | + // Get all bundle children variations | |
| 481 | + $children = static::query() | |
| 482 | + ->whereIn('id', $childIds) | |
| 483 | + ->get(['id', 'manage_stock', 'available', 'stock_status', 'item_status', 'post_id', 'other_info']) | |
| 484 | + ->all(); | |
| 485 | + } | |
| 486 | + | |
| 487 | + // A child the bundle names but that no longer exists cannot be | |
| 488 | + // supplied, so the bundle is not in stock. Same rule on both paths: | |
| 489 | + // a row missing from the map and a row missing from the query are | |
| 490 | + // the same deleted variation. | |
| 491 | + if (count($children) !== count($childIds)) { | |
| 492 | + return false; | |
| 493 | + } | |
| 371 | 494 | |
| 372 | 495 | // Check each child |
| 373 | 496 | foreach ($children as $child) { |
| 374 | 497 | // Child must be active |