PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 All 50 releases
fluent-cart / app / Models / ProductVariation.php

ProductVariation.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.0, at app/Models/ProductVariation.php

536 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Models;
4
5 use FluentCart\Api\ModuleSettings;
6 use FluentCart\App\App;
7 use FluentCart\App\Helpers\AttributeHelper;
8 use FluentCart\App\Helpers\Helper;
9 use FluentCart\App\Helpers\Status;
10 use FluentCart\App\Models\Concerns\CanSearch;
11 use FluentCart\App\Models\Concerns\CanUpdateBatch;
12 use FluentCart\App\Models\Relations\BundleChildrenRelation;
13 use FluentCart\App\Services\PlanUpgradeService;
14 use FluentCart\App\Services\URL;
15 use FluentCart\Framework\Database\Orm\Builder;
16 use FluentCart\Framework\Support\Arr;
17
18 /**
19 * Product Details Model - DB Model for Product Details
20 *
21 * Database Model
22 *
23 *
24 * @package FluentCart\App\Models
25 *
26 * @version 1.0.0
27 */
28 class ProductVariation extends Model
29 {
30
31 use CanSearch, CanUpdateBatch;
32
33 protected $table = 'fct_product_variations';
34
35 protected $fillable = [
36 'post_id',
37 'media_id',
38 'serial_index',
39 'sold_individually',
40 'variation_title',
41 'variation_identifier',
42 'sku',
43 'manage_stock',
44 'payment_type',
45 'stock_status',
46 'backorders',
47 'total_stock',
48 'available',
49 'committed',
50 'on_hold',
51 'fulfillment_type',
52 'item_status',
53 'manage_cost',
54 'item_price',
55 'item_cost',
56 'compare_price',
57 'other_info',
58 'downloadable',
59 'shipping_class'
60 ];
61
62
63 /**
64 * The attributes that should be cast.
65 *
66 * @var array
67 */
68 protected $casts = [
69 'post_id' => 'integer',
70 'media_id' => 'integer',
71 'item_cost' => 'double',
72 'item_price' => 'double',
73 'compare_price' => 'double',
74 'backorders' => 'integer',
75 'total_stock' => 'integer',
76 'available' => 'integer',
77 'committed' => 'integer',
78 'on_hold' => 'integer',
79 'sold_individually' => 'integer',
80 'serial_index' => 'integer',
81 'other_info' => 'array',
82 ];
83
84 protected $appends = ['thumbnail'];
85
86
87 public function getOtherInfoAttribute($value)
88 {
89 $value = !empty($value) ? json_decode($value, true) : [];
90
91 if($this->payment_type === 'subscription'){
92 $isInstallment = (Arr::get($value, 'installment', 'no') === 'yes' && App::isProActive())?
93 'yes':'no';
94 $value['payment_type'] = 'subscription';
95 $value['installment'] = $isInstallment;
96 $value['repeat_interval'] = Arr::get($value, 'repeat_interval', 'yearly');
97 $value['times'] = Arr::get($value, 'times', 0);
98 $value['trial_days'] = Arr::get($value, 'trial_days', 0);
99 $value['manage_setup_fee'] = Arr::get($value, 'manage_setup_fee', 'no');
100 }
101 return $value;
102 }
103
104
105
106 protected static function booted()
107 {
108 static::retrieved(function ($product) {
109 $product->append('formatted_total');
110 });
111 }
112
113 protected function getFormattedTotalAttribute()
114 {
115 return Helper::toDecimal($this->item_price);
116 }
117
118
119 /**
120 * One2One: Product Variation belongs to one Product
121 *
122 * @return \FluentCart\Framework\Database\Orm\Relations\BelongsTo
123 */
124 public function product(): \FluentCart\Framework\Database\Orm\Relations\BelongsTo
125 {
126 return $this->belongsTo(Product::class, 'post_id', 'ID');
127 }
128
129 public function shippingClass(): \FluentCart\Framework\Database\Orm\Relations\BelongsTo
130 {
131 return $this->belongsTo(ShippingClass::class, 'shipping_class', 'id');
132 }
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 /**
194 * One2One: Product Variation belongs to one Product detail
195 *
196 * @return \FluentCart\Framework\Database\Orm\Relations\BelongsTo
197 */
198 public function product_detail()
199 {
200 return $this->belongsTo(ProductDetail::class, 'post_id', 'post_id');
201 }
202
203 public function media()
204 {
205 return $this->hasOne(ProductMeta::class, 'object_id', 'id')->select('id', 'object_id', 'meta_value')->where('meta_key', 'product_thumbnail');
206 }
207
208 public function product_downloads(): \FluentCart\Framework\Database\Orm\Relations\HasMany
209 {
210 return $this
211 ->hasMany(ProductDownload::class, 'post_id', 'post_id')
212 ->where('product_variation_id', 'like', '%' . $this->id . '%')
213 ->orWhereNull('product_variation_id')
214 ->orWhere('product_variation_id', '[]');
215
216 }
217
218 public function attrRelations(): \FluentCart\Framework\Database\Orm\Relations\HasMany
219 {
220 return $this->hasMany(AttributeRelation::class, 'object_id', 'id');
221 }
222
223 public function order_items()
224 {
225 return $this->hasMany(OrderItem::class, 'object_id', 'id');
226 }
227
228 public function downloadable_files()
229 {
230 return $this->hasMany(ProductDownload::class, 'product_variation_id', 'id');
231 }
232
233 public function upgrade_paths(): \FluentCart\Framework\Database\Orm\Relations\HasMany
234 {
235 return $this->hasMany(Meta::class, 'object_id', 'id')
236 ->where('object_type', PlanUpgradeService::$metaType)
237 ->where('meta_key', PlanUpgradeService::$metaKey);
238 }
239
240 public function attrMap()
241 {
242 return $this->hasMany(AttributeRelation::class, 'object_id', 'id');
243 }
244
245 public function getThumbnailAttribute()
246 {
247 if (empty($this->media) || !is_array($this->media->meta_value)) {
248 return null;
249 }
250 // Ensure the first element exists and has a non-empty 'url' key
251 if (empty($this->media->meta_value[0]['url'])) {
252 return null;
253 }
254
255 return $this->media->meta_value[0]['url'];
256 }
257
258 public function scopeGetWithShippingClass(Builder $query)
259 {
260 $variations = $query->get();
261
262 $shippingMethodIds = $variations->pluck('other_info.shipping_class')->filter(function ($item) {
263 return !empty($item);
264 })->toArray();
265
266
267 $shippingMethods = ShippingMethod::query()->whereIn('id', $shippingMethodIds)->get()->keyBy('id');
268
269 $variations->map(function ($variation) use ($shippingMethods) {
270 $shippingClassId = Arr::get($variation, 'other_info.shipping_class');
271 if (!$shippingClassId) {
272 return $variation;
273 }
274 $method = $shippingMethods->get($shippingClassId);
275
276 if (!$method) {
277 return $variation;
278 }
279 $variation->attributes['shipping_method'] = $method;
280 return $variation;
281 });
282
283
284 return $query->get();
285 }
286
287
288 public static function boot()
289 {
290 parent::boot();
291 static::deleting(function ($model) {
292 \FluentCart\Api\Meta::deleteVariationMedia($model->id);
293 $model->attrMap()->delete();
294 });
295 }
296
297 /**
298 * Check if the product variation can be purchased
299 *
300 * @param int $quantity
301 * @return bool|\WP_Error
302 */
303 public function canPurchase($quantity = 1)
304 {
305 if ($this->item_status !== 'active' || !in_array($this->product->post_status, ['publish', 'private'])) {
306 return new \WP_Error('unpublished', __('This product is not available for purchase.', 'fluent-cart'));
307 }
308
309 if ($this->payment_type === 'subscription' && $quantity > 1) {
310 return new \WP_Error('invalid_subscription_quantity', __('You cannot purchase more than one subscription at a time.', 'fluent-cart'));
311 }
312
313 $productDetail = $this->product_detail;
314 if (!$productDetail) {
315 return new \WP_Error('unpublished', __('This product is not available for purchase', 'fluent-cart'));
316 }
317
318 if (ModuleSettings::isActive('stock_management')) {
319 if (($productDetail->manage_stock && $this->manage_stock) && $quantity > $this->available) {
320 return new \WP_Error('insufficient_stock', __('Sorry, this product is currently out of stock.', 'fluent-cart'));
321 }
322 }
323
324
325 if ($this->product->isBundleProduct() && !App::isProActive()) {
326 return new \WP_Error('invalid_bundle_product', __('Sorry, this product is not available for purchase.', 'fluent-cart'));
327
328 }
329
330 $bundleCheck = apply_filters('fluent_cart/variation/can_purchase_bundle', null, [
331 'variation' => $this,
332 'quantity' => (int)$quantity
333 ]);
334 if (is_wp_error($bundleCheck)) {
335 return $bundleCheck;
336 } elseif ($bundleCheck === false) {
337 return new \WP_Error('insufficient_stock', __('Sorry, this product is currently out of stock.', 'fluent-cart'));
338 }
339
340 return true;
341 }
342
343 public function getSubscriptionTermsText($withComparePrice = false)
344 {
345 if ($this->payment_type !== 'subscription') {
346 return '';
347 }
348
349 $otherInfo = $this->other_info;
350
351 $formattedData = [
352 'trial_days' => Arr::get($otherInfo, 'trial_days', 0),
353 'interval' => Arr::get($otherInfo, 'repeat_interval', 'yearly'),
354 'times' => Arr::get($otherInfo, 'times', 0), // 0 means infinite
355 'signup_fee' => Arr::get($otherInfo, 'signup_fee', 0) ? Helper::toDecimal(Arr::get($otherInfo, 'signup_fee', 0)) : 0,
356 'signup_fee_label' => Arr::get($otherInfo, 'signup_fee_name', ''),
357 'price' => Helper::toDecimal($this->item_price),
358 'compare_price' => ($withComparePrice && $this->compare_price > $this->item_price) ? Helper::toDecimal($this->compare_price) : 0,
359 ];
360
361 return Helper::getSubscriptionTermText($formattedData);
362 }
363
364 public function getPurchaseUrl()
365 {
366 return site_url('?fluent-cart=instant_checkout&item_id=' . $this->id . '&quantity=1');
367 }
368
369 public function soldIndividually()
370 {
371 if ($this->product) {
372 return $this->product->soldIndividually();
373 }
374 return false;
375 }
376
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
384 {
385 // Check if variation is active
386 if ($this->item_status !== 'active') {
387 return false;
388 }
389
390 // Check if this is a bundle product variation
391 $isBundleProduct = $this->product && $this->product->isBundleProduct();
392
393 // If stock management is disabled for this variation
394 if (!$this->manage_stock) {
395 // For bundle products, still check child items
396 if ($isBundleProduct) {
397 return $this->isBundleChildrenInStock($bundleChildren);
398 }
399 // For regular products without stock management, check status
400 return $this->stock_status === Helper::IN_STOCK;
401 }
402
403 // Stock management is enabled - check availability and status
404 $hasStock = ($this->available > 0 && $this->stock_status === Helper::IN_STOCK);
405
406 // For non-bundle products, return stock status
407 if (!$isBundleProduct) {
408 return $hasStock;
409 }
410
411 // For bundle products, parent must be in stock AND all children must be in stock
412 if (!$hasStock) {
413 return false;
414 }
415
416 return $this->isBundleChildrenInStock($bundleChildren);
417 }
418
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 /**
456 * Check if all bundle children are in stock
457 *
458 * @return bool
459 */
460 protected function isBundleChildrenInStock($preloadedChildren = null): bool
461 {
462 $childIds = Arr::get($this->other_info, 'bundle_child_ids', []);
463
464 // No bundle children, consider as in stock
465 if (empty($childIds) || !is_array($childIds)) {
466 return true;
467 }
468
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 }
494
495 // Check each child
496 foreach ($children as $child) {
497 // Child must be active
498 if ($child->item_status !== 'active') {
499 return false;
500 }
501
502 // Only check stock for children that manage inventory.
503 // Children without stock management (e.g. digital products) are always considered in stock.
504 if ((int)$child->manage_stock === 1) {
505 if ((int)$child->available <= 0 || $child->stock_status !== Helper::IN_STOCK) {
506 return false;
507 }
508 }
509
510 // Nested bundle check commented out: bundle_child_ids live on the bundle's own variation,
511 // not on the leaf child variation referenced here. Each bundle's isStock() handles its own
512 // children independently, so this recursive call is redundant and causes N+1 queries.
513 // if ($child->product && $child->product->isBundleProduct()) {
514 // $nestedChildIds = Arr::get($child->other_info, 'bundle_child_ids', []);
515 // if (!empty($nestedChildIds)) {
516 // if (!$child->isBundleChildrenInStock()) {
517 // return false;
518 // }
519 // }
520 // }
521 }
522
523 return true;
524 }
525
526 public function bundleChildren(): BundleChildrenRelation
527 {
528 return new BundleChildrenRelation(
529 $this->newQuery(),
530 $this,
531 'other_info', // JSON column name
532 'bundle_child_ids' // JSON key name
533 );
534 }
535 }
536