PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Models / ProductVariation.php

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

473 lines 15.2 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 public function isStock(): bool
378 {
379 // Check if variation is active
380 if ($this->item_status !== 'active') {
381 return false;
382 }
383
384 // Check if this is a bundle product variation
385 $isBundleProduct = $this->product && $this->product->isBundleProduct();
386
387 // If stock management is disabled for this variation
388 if (!$this->manage_stock) {
389 // For bundle products, still check child items
390 if ($isBundleProduct) {
391 return $this->isBundleChildrenInStock();
392 }
393 // For regular products without stock management, check status
394 return $this->stock_status === Helper::IN_STOCK;
395 }
396
397 // Stock management is enabled - check availability and status
398 $hasStock = ($this->available > 0 && $this->stock_status === Helper::IN_STOCK);
399
400 // For non-bundle products, return stock status
401 if (!$isBundleProduct) {
402 return $hasStock;
403 }
404
405 // For bundle products, parent must be in stock AND all children must be in stock
406 if (!$hasStock) {
407 return false;
408 }
409
410 return $this->isBundleChildrenInStock();
411 }
412
413 /**
414 * Check if all bundle children are in stock
415 *
416 * @return bool
417 */
418 protected function isBundleChildrenInStock(): bool
419 {
420 $childIds = Arr::get($this->other_info, 'bundle_child_ids', []);
421
422 // No bundle children, consider as in stock
423 if (empty($childIds) || !is_array($childIds)) {
424 return true;
425 }
426
427 // Get all bundle children variations
428 $children = static::query()
429 ->whereIn('id', $childIds)
430 ->get(['id', 'manage_stock', 'available', 'stock_status', 'item_status', 'post_id', 'other_info']);
431
432 // Check each child
433 foreach ($children as $child) {
434 // Child must be active
435 if ($child->item_status !== 'active') {
436 return false;
437 }
438
439 // Only check stock for children that manage inventory.
440 // Children without stock management (e.g. digital products) are always considered in stock.
441 if ((int)$child->manage_stock === 1) {
442 if ((int)$child->available <= 0 || $child->stock_status !== Helper::IN_STOCK) {
443 return false;
444 }
445 }
446
447 // Nested bundle check commented out: bundle_child_ids live on the bundle's own variation,
448 // not on the leaf child variation referenced here. Each bundle's isStock() handles its own
449 // children independently, so this recursive call is redundant and causes N+1 queries.
450 // if ($child->product && $child->product->isBundleProduct()) {
451 // $nestedChildIds = Arr::get($child->other_info, 'bundle_child_ids', []);
452 // if (!empty($nestedChildIds)) {
453 // if (!$child->isBundleChildrenInStock()) {
454 // return false;
455 // }
456 // }
457 // }
458 }
459
460 return true;
461 }
462
463 public function bundleChildren(): BundleChildrenRelation
464 {
465 return new BundleChildrenRelation(
466 $this->newQuery(),
467 $this,
468 'other_info', // JSON column name
469 'bundle_child_ids' // JSON key name
470 );
471 }
472 }
473