| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Models; |
| 4 |
|
| 5 |
use FluentCart\App\CPT\FluentProducts; |
| 6 |
use FluentCart\App\Helpers\Helper; |
| 7 |
use FluentCart\App\Helpers\Status; |
| 8 |
use FluentCart\App\Models\Concerns\CanSearch; |
| 9 |
use FluentCart\App\Models\WpModels\PostMeta; |
| 10 |
use FluentCart\App\Models\WpModels\Term; |
| 11 |
use FluentCart\App\Models\WpModels\TermRelationship; |
| 12 |
use FluentCart\App\Models\WpModels\TermTaxonomy; |
| 13 |
use FluentCart\App\Vite; |
| 14 |
use FluentCart\Framework\Database\Orm\Builder; |
| 15 |
use FluentCart\Framework\Database\Orm\Relations\HasOne; |
| 16 |
use FluentCart\Framework\Support\Arr; |
| 17 |
use FluentCart\Framework\Support\Str; |
| 18 |
|
| 19 |
/** |
| 20 |
* Product Model - DB Model for Products |
| 21 |
* |
| 22 |
* Database Model |
| 23 |
* |
| 24 |
* This model is intended to be use for relationships and DB query |
| 25 |
* For insert update we will use WordPress's native functions |
| 26 |
* |
| 27 |
* @package FluentCart\App\Models |
| 28 |
* |
| 29 |
* @version 1.0.0 |
| 30 |
*/ |
| 31 |
class Product extends Model |
| 32 |
{ |
| 33 |
use CanSearch; |
| 34 |
|
| 35 |
protected $table = 'posts'; |
| 36 |
|
| 37 |
protected $primaryKey = 'ID'; |
| 38 |
|
| 39 |
protected $hidden = [ |
| 40 |
'post_content_filtered', |
| 41 |
'post_password', |
| 42 |
'post_author', |
| 43 |
'to_ping', |
| 44 |
'pinged', |
| 45 |
'post_parent', |
| 46 |
'menu_order', |
| 47 |
'post_mime_type', |
| 48 |
'comment_count', |
| 49 |
]; |
| 50 |
|
| 51 |
protected $fillable = [ |
| 52 |
'post_content', |
| 53 |
'post_title', |
| 54 |
'post_excerpt', |
| 55 |
'post_author', |
| 56 |
'post_date', |
| 57 |
'post_date_gmt', |
| 58 |
'post_content_filtered', |
| 59 |
'post_status', |
| 60 |
'post_type', |
| 61 |
'comment_status', |
| 62 |
'ping_status', |
| 63 |
'post_password', |
| 64 |
'post_name', |
| 65 |
'to_ping', |
| 66 |
'pinged', |
| 67 |
'post_modified', |
| 68 |
'post_modified_gmt', |
| 69 |
'post_parent', |
| 70 |
'menu_order', |
| 71 |
'post_mime_type', |
| 72 |
'guid', |
| 73 |
]; |
| 74 |
const UPDATED_AT = null; |
| 75 |
const CREATED_AT = null; |
| 76 |
|
| 77 |
protected $appends = [ |
| 78 |
'thumbnail', |
| 79 |
]; |
| 80 |
|
| 81 |
protected $searchable = [ |
| 82 |
'post_title', |
| 83 |
'post_status' |
| 84 |
]; |
| 85 |
|
| 86 |
public static function boot() |
| 87 |
{ |
| 88 |
parent::boot(); |
| 89 |
|
| 90 |
static::creating(function ($model) { |
| 91 |
$model->post_type = FluentProducts::CPT_NAME; |
| 92 |
}); |
| 93 |
|
| 94 |
static::addGlobalScope('post_type', function (Builder $builder) { |
| 95 |
$builder->where('post_type', '=', FluentProducts::CPT_NAME)->whereNot('post_status', 'auto-draft'); |
| 96 |
}); |
| 97 |
} |
| 98 |
|
| 99 |
public function scopePublished($query) |
| 100 |
{ |
| 101 |
return $query->where('post_status', 'publish'); |
| 102 |
} |
| 103 |
|
| 104 |
public function scopeStatusOf($query, $status) |
| 105 |
{ |
| 106 |
return $query->where('post_status', $status); |
| 107 |
} |
| 108 |
|
| 109 |
|
| 110 |
public function scopeAdminAll($query) |
| 111 |
{ |
| 112 |
return $query->whereIn('post_status', Status::productAdminAllStatuses()); |
| 113 |
} |
| 114 |
|
| 115 |
|
| 116 |
/** |
| 117 |
* One2One: Product Details belongs to one Product |
| 118 |
* @return HasOne |
| 119 |
*/ |
| 120 |
public function detail(): HasOne |
| 121 |
{ |
| 122 |
return $this->hasOne(ProductDetail::class, 'post_id', 'ID'); |
| 123 |
} |
| 124 |
|
| 125 |
public function variants(): \FluentCart\Framework\Database\Orm\Relations\HasMany |
| 126 |
{ |
| 127 |
return $this->hasMany(ProductVariation::class, 'post_id', 'ID'); |
| 128 |
} |
| 129 |
|
| 130 |
public function getHasSubscriptionAttribute() |
| 131 |
{ |
| 132 |
// Ensure the variants relationship is loaded |
| 133 |
$variants = $this->variants; |
| 134 |
|
| 135 |
foreach ($variants as $variation) { |
| 136 |
if (isset($variation->other_info['payment_type']) && |
| 137 |
$variation->other_info['payment_type'] === 'subscription') { |
| 138 |
return true; |
| 139 |
} |
| 140 |
} |
| 141 |
|
| 142 |
return false; |
| 143 |
} |
| 144 |
|
| 145 |
public function downloadable_files(): \FluentCart\Framework\Database\Orm\Relations\HasMany |
| 146 |
{ |
| 147 |
return $this->hasMany(ProductDownload::class, 'post_id', 'ID'); |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* One2One: Product belongs to one Post meta which is : Gallery Image |
| 152 |
* @return HasOne |
| 153 |
*/ |
| 154 |
public function postmeta(): HasOne |
| 155 |
{ |
| 156 |
return $this->hasOne(PostMeta::class, 'post_id', 'ID') |
| 157 |
->where('postmeta.meta_key', 'fluent-products-gallery-image'); |
| 158 |
} |
| 159 |
|
| 160 |
public function wp_terms(): \FluentCart\Framework\Database\Orm\Relations\HasMany |
| 161 |
{ |
| 162 |
return $this->hasMany( |
| 163 |
TermRelationship::class, |
| 164 |
'object_id', |
| 165 |
'ID', |
| 166 |
); |
| 167 |
} |
| 168 |
|
| 169 |
public function orderItems(): \FluentCart\Framework\Database\Orm\Relations\HasMany |
| 170 |
{ |
| 171 |
return $this->hasMany(OrderItem::class, 'post_id', 'ID'); |
| 172 |
} |
| 173 |
|
| 174 |
public function getCategories() |
| 175 |
{ |
| 176 |
return get_the_terms($this->ID, 'product-categories'); |
| 177 |
} |
| 178 |
|
| 179 |
|
| 180 |
public function getMediaUrl($size = 'thumbnail') |
| 181 |
{ |
| 182 |
return get_the_post_thumbnail_url($this->ID, $size); |
| 183 |
} |
| 184 |
|
| 185 |
|
| 186 |
/* |
| 187 |
* Transforming old getters with accessor |
| 188 |
* Todo check |
| 189 |
*/ |
| 190 |
public function getCategoriesAttribute($value) |
| 191 |
{ |
| 192 |
return get_the_terms($this->ID, 'product-categories'); |
| 193 |
} |
| 194 |
|
| 195 |
|
| 196 |
public function getThumbnailAttribute() |
| 197 |
{ |
| 198 |
if (empty($this->detail) || empty($this->detail->featured_media)) { |
| 199 |
return Vite::getAssetUrl('images/placeholder.svg'); |
| 200 |
} |
| 201 |
return Arr::get($this->detail->featured_media, 'url'); |
| 202 |
} |
| 203 |
|
| 204 |
|
| 205 |
public function getViewUrlAttribute() |
| 206 |
{ |
| 207 |
return get_permalink($this->ID); |
| 208 |
} |
| 209 |
|
| 210 |
|
| 211 |
public function getEditUrlAttribute() |
| 212 |
{ |
| 213 |
return admin_url('post.php?post=' . $this->ID . '&action=edit'); |
| 214 |
} |
| 215 |
|
| 216 |
|
| 217 |
public function wpTerms() |
| 218 |
{ |
| 219 |
return $this->hasManyThrough( |
| 220 |
TermTaxonomy::class, |
| 221 |
TermRelationship::class, |
| 222 |
'object_id', // Product ID In TermRelationShip Table |
| 223 |
'term_taxonomy_id', |
| 224 |
'ID', |
| 225 |
'term_taxonomy_id', |
| 226 |
); |
| 227 |
} |
| 228 |
|
| 229 |
public function getTermByType($type) |
| 230 |
{ |
| 231 |
return $this |
| 232 |
->hasMany(TermRelationship::class, 'object_id') |
| 233 |
->whereHas('taxonomy', function ($query) use ($type) { |
| 234 |
return $query->where('taxonomy', $type); |
| 235 |
}) |
| 236 |
->join('term_taxonomy', 'term_taxonomy.term_taxonomy_id', '=', 'term_relationships.term_taxonomy_id') |
| 237 |
->join('terms', 'terms.term_id', '=', 'term_taxonomy.term_id') |
| 238 |
->addSelect('terms.*', 'term_relationships.*'); |
| 239 |
} |
| 240 |
|
| 241 |
|
| 242 |
/* |
| 243 |
// Get Category Relationship |
| 244 |
*/ |
| 245 |
public function categories() |
| 246 |
{ |
| 247 |
return $this->getTermByType('product-categories'); |
| 248 |
} |
| 249 |
|
| 250 |
|
| 251 |
/* |
| 252 |
* Todo: Discuss on below relation |
| 253 |
*/ |
| 254 |
public function thumbUrl(): HasOne |
| 255 |
{ |
| 256 |
return $this |
| 257 |
->hasOne(PostMeta::class, 'post_id') |
| 258 |
->where('postmeta.meta_key', '_thumbnail_id') |
| 259 |
->leftJoin('postmeta as image_table', function ($join) { |
| 260 |
$join->on('postmeta.meta_value', '=', 'image_table.post_id') |
| 261 |
->where('image_table.meta_key', '=', '_wp_attached_file'); |
| 262 |
}) |
| 263 |
->addSelect('postmeta.*', 'image_table.meta_value as image'); |
| 264 |
} |
| 265 |
|
| 266 |
public function licensesMeta(): HasOne |
| 267 |
{ |
| 268 |
return $this->hasOne(ProductMeta::class, 'object_id', 'ID') |
| 269 |
->where('meta_key', 'license_settings'); |
| 270 |
} |
| 271 |
|
| 272 |
public function scopeCartable(Builder $query): Builder |
| 273 |
{ |
| 274 |
return $query->whereDoesntHave('licensesMeta') |
| 275 |
->withWhereHas('variants', function ($query) { |
| 276 |
$query->where('payment_type', '!=', 'subscription') |
| 277 |
->with('media'); |
| 278 |
}); |
| 279 |
} |
| 280 |
|
| 281 |
public function getProductMeta($metaKey, $objectType = null, $default = null) |
| 282 |
{ |
| 283 |
$query = ProductMeta::query() |
| 284 |
->where('object_id', $this->ID) |
| 285 |
->where('meta_key', $metaKey); |
| 286 |
|
| 287 |
if (!is_null($objectType)) { |
| 288 |
$query->where('object_type', $objectType); |
| 289 |
} |
| 290 |
|
| 291 |
$meta = $query->first(); |
| 292 |
|
| 293 |
if ($meta) { |
| 294 |
return $meta->meta_value; |
| 295 |
} |
| 296 |
|
| 297 |
return $default; |
| 298 |
} |
| 299 |
|
| 300 |
public function updateProductMeta($metaKey, $metaValue, $objectType = null) |
| 301 |
{ |
| 302 |
$query = ProductMeta::query() |
| 303 |
->where('object_id', $this->ID) |
| 304 |
->where('meta_key', $metaKey); |
| 305 |
|
| 306 |
|
| 307 |
if (!is_null($objectType)) { |
| 308 |
$query->where('object_type', $objectType); |
| 309 |
} |
| 310 |
|
| 311 |
$exist = $query->first(); |
| 312 |
|
| 313 |
if ($exist) { |
| 314 |
$exist->meta_value = $metaValue; |
| 315 |
$exist->save(); |
| 316 |
return $exist; |
| 317 |
} |
| 318 |
|
| 319 |
|
| 320 |
$meta = new ProductMeta(); |
| 321 |
$meta->object_id = $this->ID; |
| 322 |
$meta->meta_key = $metaKey; |
| 323 |
$meta->meta_value = $metaValue; |
| 324 |
$meta->object_type = $objectType; |
| 325 |
$meta->save(); |
| 326 |
|
| 327 |
return $meta; |
| 328 |
} |
| 329 |
|
| 330 |
public function scopeApplyCustomSortBy($query, $sortKey, $sortType = 'DESC') |
| 331 |
{ |
| 332 |
//id|date|title|price |
| 333 |
$validKeys = [ |
| 334 |
'id' => 'ID', |
| 335 |
'date' => 'post_date', |
| 336 |
'title' => 'post_title', |
| 337 |
'price' => 'item_price', |
| 338 |
]; |
| 339 |
$sortBy = Arr::get($validKeys, $sortKey, 'ID'); |
| 340 |
$sortType = in_array($sortType, ['ASC', 'DESC']) ? $sortType : 'DESC'; |
| 341 |
|
| 342 |
if ($sortBy === 'item_price') { |
| 343 |
return $query->leftJoin('fct_product_details as pd', 'posts.ID', '=', 'pd.post_id') |
| 344 |
->orderBy("pd.min_price", $sortType); |
| 345 |
} |
| 346 |
return $query->orderBy($sortBy, $sortType); |
| 347 |
} |
| 348 |
|
| 349 |
public function scopeByVariantTypes($query, $type = null) |
| 350 |
{ |
| 351 |
$validTypes = ['physical', 'digital', 'subscription', 'onetime', 'simple', 'variations']; |
| 352 |
if (!$type || !in_array($type, $validTypes)) { |
| 353 |
return $query; |
| 354 |
} |
| 355 |
if ($type === 'physical' || $type === 'digital') { |
| 356 |
return $query->whereHas('variants', function ($query) use ($type) { |
| 357 |
$query->where('fulfillment_type', $type); |
| 358 |
}); |
| 359 |
} |
| 360 |
if ($type === 'subscription' || $type === 'onetime') { |
| 361 |
return $query->whereHas('variants', function ($query) use ($type) { |
| 362 |
$query->where('payment_type', $type); |
| 363 |
}); |
| 364 |
} |
| 365 |
|
| 366 |
if ($type === 'simple') { |
| 367 |
//search from details |
| 368 |
return $query->whereHas('detail', function ($query) { |
| 369 |
$query->where('variation_type', Helper::PRODUCT_TYPE_SIMPLE); |
| 370 |
}); |
| 371 |
} |
| 372 |
if ($type === 'variations') { |
| 373 |
return $query->whereHas('detail', function ($query) { |
| 374 |
$query->whereIn('variation_type', [ |
| 375 |
Helper::PRODUCT_TYPE_SIMPLE_VARIATION, |
| 376 |
Helper::PRODUCT_TYPE_ADVANCE_VARIATION |
| 377 |
]); |
| 378 |
}); |
| 379 |
} |
| 380 |
|
| 381 |
return $query; |
| 382 |
} |
| 383 |
|
| 384 |
public function scopeFilterByTaxonomy($query, $taxonomies) |
| 385 |
{ |
| 386 |
|
| 387 |
//example $taxonomies |
| 388 |
// $taxonomies = [ |
| 389 |
// 'product-categories' => [1, 2, 3], |
| 390 |
// 'product-brands' => [4, 5, 6] |
| 391 |
// ]; |
| 392 |
$taxonomies = array_filter($taxonomies, function ($taxonomy) { |
| 393 |
return !empty($taxonomy) && is_array($taxonomy); |
| 394 |
}); |
| 395 |
|
| 396 |
if (empty($taxonomies)) { |
| 397 |
return $query; |
| 398 |
} |
| 399 |
|
| 400 |
foreach ($taxonomies as $taxonomy => $terms) { |
| 401 |
$query->whereHas('wpTerms', function ($query) use ($terms) { |
| 402 |
return $query->search(["term_id" => ["column" => "term_id", "operator" => "in", "value" => $terms]]); |
| 403 |
}); |
| 404 |
} |
| 405 |
|
| 406 |
return $query; |
| 407 |
} |
| 408 |
|
| 409 |
public function soldIndividually() |
| 410 |
{ |
| 411 |
if ( |
| 412 |
$this->detail && |
| 413 |
$this->detail->other_info && |
| 414 |
Arr::get($this->detail->other_info, 'sold_individually') === 'yes' |
| 415 |
) { |
| 416 |
return true; |
| 417 |
} |
| 418 |
|
| 419 |
return false; |
| 420 |
} |
| 421 |
|
| 422 |
public function isStock(): bool |
| 423 |
{ |
| 424 |
$detail = $this->detail; |
| 425 |
if (!$detail) { |
| 426 |
return true; |
| 427 |
} |
| 428 |
|
| 429 |
$isBundle = $this->isBundleProduct(); |
| 430 |
|
| 431 |
if (!$detail->manage_stock) { |
| 432 |
if ($isBundle) { |
| 433 |
$variation = $detail->default_variation_id |
| 434 |
? $this->variants->firstWhere('id', $detail->default_variation_id) |
| 435 |
: $this->variants->first(); |
| 436 |
|
| 437 |
$childIds = $variation ? Arr::get($variation->other_info, 'bundle_child_ids', []) : []; |
| 438 |
if (!empty($childIds)) { |
| 439 |
$children = ProductVariation::query() |
| 440 |
->whereIn('id', $childIds) |
| 441 |
->get(['manage_stock', 'available', 'stock_status']); |
| 442 |
|
| 443 |
foreach ($children as $child) { |
| 444 |
if ((int)$child->manage_stock === 1) { |
| 445 |
if ((int)$child->available <= 0 || $child->stock_status !== Helper::IN_STOCK) { |
| 446 |
return false; |
| 447 |
} |
| 448 |
} |
| 449 |
} |
| 450 |
} |
| 451 |
} |
| 452 |
return true; |
| 453 |
} |
| 454 |
|
| 455 |
$parentInStock = ($detail->stock_availability === Helper::IN_STOCK); |
| 456 |
if (!$isBundle) { |
| 457 |
return $parentInStock; |
| 458 |
} |
| 459 |
if (!$parentInStock) { |
| 460 |
return false; |
| 461 |
} |
| 462 |
|
| 463 |
$variation = $detail->default_variation_id |
| 464 |
? $this->variants->firstWhere('id', $detail->default_variation_id) |
| 465 |
: $this->variants->first(); |
| 466 |
|
| 467 |
if (!$variation) { |
| 468 |
return $parentInStock; |
| 469 |
} |
| 470 |
|
| 471 |
$childIds = Arr::get($variation->other_info, 'bundle_child_ids', []); |
| 472 |
if (empty($childIds)) { |
| 473 |
return $parentInStock; |
| 474 |
} |
| 475 |
|
| 476 |
$children = ProductVariation::query() |
| 477 |
->whereIn('id', $childIds) |
| 478 |
->get(['manage_stock', 'available', 'stock_status']); |
| 479 |
|
| 480 |
foreach ($children as $child) { |
| 481 |
if ((int)$child->manage_stock === 1) { |
| 482 |
if ((int)$child->available <= 0 || $child->stock_status !== Helper::IN_STOCK) { |
| 483 |
return false; |
| 484 |
} |
| 485 |
} |
| 486 |
} |
| 487 |
|
| 488 |
return true; |
| 489 |
} |
| 490 |
|
| 491 |
|
| 492 |
public function images(): array |
| 493 |
{ |
| 494 |
$images = []; |
| 495 |
$thumbnailImage = $this->thumbnail ?? Vite::getAssetUrl('images/placeholder.svg'); |
| 496 |
|
| 497 |
$galleryImages = get_post_meta($this->ID, 'fluent-products-gallery-image', true); |
| 498 |
|
| 499 |
|
| 500 |
if (!empty($galleryImages)) { |
| 501 |
foreach ($galleryImages as $image) { |
| 502 |
$images[] = [ |
| 503 |
'type' => 'gallery_image', |
| 504 |
'url' => Arr::get($image, 'url', ''), |
| 505 |
'alt' => Arr::get($image, 'title', ''), |
| 506 |
'product_title' => $this->post_title, |
| 507 |
'attachment_id' => Arr::get($image, 'id', ''), |
| 508 |
]; |
| 509 |
} |
| 510 |
} else { |
| 511 |
$images[] = [ |
| 512 |
'type' => 'thumbnail', |
| 513 |
'url' => $thumbnailImage, |
| 514 |
'alt' => $this->post_title, |
| 515 |
'product_title' => $this->post_title, |
| 516 |
'attachment_id' => null, |
| 517 |
]; |
| 518 |
} |
| 519 |
|
| 520 |
foreach ($this->variants as $variant) { |
| 521 |
if (!empty($variant['media']['meta_value'])) { |
| 522 |
foreach ($variant['media']['meta_value'] as $image) { |
| 523 |
$images[] = [ |
| 524 |
'type' => 'variation_image', |
| 525 |
'url' => Arr::get($image, 'url', ''), |
| 526 |
'alt' => Arr::get($image, 'title', ''), |
| 527 |
'variation_title' => Arr::get($variant, 'variation_title', ''), |
| 528 |
'variation_id' => Arr::get($variant, 'id', ''), |
| 529 |
'attachment_id' => Arr::get($image, 'id', ''), |
| 530 |
]; |
| 531 |
} |
| 532 |
|
| 533 |
} |
| 534 |
} |
| 535 |
return $images; |
| 536 |
} |
| 537 |
|
| 538 |
public function isBundleProduct(): bool |
| 539 |
{ |
| 540 |
return $this->detail && $this->detail->other_info && Arr::get($this->detail->other_info, 'is_bundle_product') === 'yes'; |
| 541 |
} |
| 542 |
|
| 543 |
|
| 544 |
|
| 545 |
public function scopeBundle($query) |
| 546 |
{ |
| 547 |
return $query->whereHas('detail', function ($q) { |
| 548 |
$q->whereNotNull('other_info') |
| 549 |
->whereRaw("JSON_EXTRACT(other_info, '$.is_bundle_product') = 'yes'"); |
| 550 |
}); |
| 551 |
} |
| 552 |
|
| 553 |
|
| 554 |
public function scopeNonBundle($query) |
| 555 |
{ |
| 556 |
return $query->whereHas('detail', function ($q) { |
| 557 |
$q->where(function ($subQuery) { |
| 558 |
$subQuery->whereNull('other_info') |
| 559 |
->orWhereRaw("JSON_EXTRACT(other_info, '$.is_bundle_product') != 'yes'") |
| 560 |
->orWhereRaw("JSON_EXTRACT(other_info, '$.is_bundle_product') IS NULL"); |
| 561 |
}); |
| 562 |
}); |
| 563 |
} |
| 564 |
|
| 565 |
public static function duplicateProduct($productId, array $options = []): int |
| 566 |
{ |
| 567 |
$originalProduct = static::with([ |
| 568 |
'detail', |
| 569 |
'variants' => function ($query) { |
| 570 |
$query->with(['media'])->orderBy('serial_index', 'ASC'); |
| 571 |
}, |
| 572 |
'downloadable_files' |
| 573 |
])->find($productId); |
| 574 |
|
| 575 |
if (!$originalProduct) { |
| 576 |
throw new \RuntimeException(\__('Product not found', 'fluent-cart'), 404); |
| 577 |
} |
| 578 |
|
| 579 |
return $originalProduct->performDuplicate($options); |
| 580 |
} |
| 581 |
|
| 582 |
protected function performDuplicate(array $options = []): int |
| 583 |
{ |
| 584 |
$importStockManagement = (bool)Arr::get($options, 'import_stock_management', false); |
| 585 |
$importLicenseSettings = (bool)Arr::get($options, 'import_license_settings', false); |
| 586 |
$importDownloadableFiles = (bool)Arr::get($options, 'import_downloadable_files', false); |
| 587 |
|
| 588 |
$productId = (int)$this->ID; |
| 589 |
|
| 590 |
global $wpdb; |
| 591 |
$wpdb->query('START TRANSACTION'); |
| 592 |
|
| 593 |
try { |
| 594 |
$newPostData = [ |
| 595 |
'post_title' => $this->post_title . ' (' . \__('Copy', 'fluent-cart') . ')', |
| 596 |
'post_name' => \sanitize_title($this->post_title . '-copy-' . time()), |
| 597 |
'post_content' => $this->post_content, |
| 598 |
'post_excerpt' => $this->post_excerpt, |
| 599 |
'post_status' => 'draft', |
| 600 |
'post_type' => FluentProducts::CPT_NAME, |
| 601 |
'post_author' => \get_current_user_id(), |
| 602 |
]; |
| 603 |
|
| 604 |
$newProductId = \wp_insert_post($newPostData); |
| 605 |
|
| 606 |
if (\is_wp_error($newProductId)) { |
| 607 |
throw new \RuntimeException($newProductId->get_error_message()); |
| 608 |
} |
| 609 |
|
| 610 |
if ($this->detail) { |
| 611 |
$detailData = $this->detail->toArray(); |
| 612 |
|
| 613 |
unset($detailData['id'], $detailData['created_at'], $detailData['updated_at']); |
| 614 |
// Remove appended/computed attributes and relations that are not actual DB columns |
| 615 |
unset($detailData['featured_media'], $detailData['formatted_min_price'], $detailData['formatted_max_price'], $detailData['gallery_image']); |
| 616 |
$detailData['post_id'] = $newProductId; |
| 617 |
|
| 618 |
// Ensure JSON columns have valid JSON values (MySQL rejects empty strings) |
| 619 |
if (empty($detailData['default_media'])) { |
| 620 |
$detailData['default_media'] = []; |
| 621 |
} |
| 622 |
if (empty($detailData['other_info'])) { |
| 623 |
$detailData['other_info'] = []; |
| 624 |
} |
| 625 |
|
| 626 |
if (!$importStockManagement) { |
| 627 |
$detailData['manage_stock'] = 0; |
| 628 |
$detailData['stock_status'] = 'in-stock'; |
| 629 |
|
| 630 |
if (isset($detailData['other_info'])) { |
| 631 |
$otherInfo = $detailData['other_info']; |
| 632 |
$detailData['other_info'] = $otherInfo; |
| 633 |
} |
| 634 |
} |
| 635 |
|
| 636 |
if ($importLicenseSettings) { |
| 637 |
$licenseSettings = ProductMeta::query() |
| 638 |
->where('object_id', $productId) |
| 639 |
->where('object_type', null) |
| 640 |
->where('meta_key', 'license_settings') |
| 641 |
->first(); |
| 642 |
|
| 643 |
if ($licenseSettings) { |
| 644 |
ProductMeta::query()->create([ |
| 645 |
'object_id' => $newProductId, |
| 646 |
'object_type' => null, |
| 647 |
'meta_key' => 'license_settings', |
| 648 |
'meta_value' => $licenseSettings->meta_value |
| 649 |
]); |
| 650 |
} |
| 651 |
} |
| 652 |
|
| 653 |
if (!$importDownloadableFiles) { |
| 654 |
$detailData['manage_downloadable'] = 0; |
| 655 |
} |
| 656 |
|
| 657 |
ProductDetail::query()->create($detailData); |
| 658 |
} |
| 659 |
|
| 660 |
$variationIdMap = []; |
| 661 |
if ($this->variants) { |
| 662 |
foreach ($this->variants as $originalVariant) { |
| 663 |
$variantData = $originalVariant->toArray(); |
| 664 |
|
| 665 |
unset($variantData['id'], $variantData['created_at'], $variantData['updated_at']); |
| 666 |
// Remove appended/computed attributes and relations that are not actual DB columns |
| 667 |
unset($variantData['thumbnail'], $variantData['media']); |
| 668 |
$variantData['post_id'] = $newProductId; |
| 669 |
unset($variantData['sku']); // Remove SKU to avoid unique constraint violations |
| 670 |
|
| 671 |
if (!$importStockManagement) { |
| 672 |
$variantData['manage_stock'] = 0; |
| 673 |
$variantData['stock_status'] = 'in-stock'; |
| 674 |
$variantData['total_stock'] = 0; |
| 675 |
$variantData['available'] = 0; |
| 676 |
$variantData['on_hold'] = 0; |
| 677 |
$variantData['committed'] = 0; |
| 678 |
} |
| 679 |
|
| 680 |
$newVariant = ProductVariation::query()->create($variantData); |
| 681 |
$variationIdMap[$originalVariant->id] = $newVariant->id; |
| 682 |
|
| 683 |
// Copy the variant's thumbnail meta to the new variant. |
| 684 |
// $originalVariant->media is hasOne on fct_product_meta |
| 685 |
// where meta_key = 'product_thumbnail'; the old code |
| 686 |
// foreach'd over it (treating a single Model as a |
| 687 |
// collection — iterating its attribute scalars) and |
| 688 |
// mis-routed the data through wp_set_object_terms (the |
| 689 |
// WordPress taxonomy API, unrelated to fct_product_meta). |
| 690 |
// Mirror the Pro AdvancedVariationService pattern: a |
| 691 |
// fresh fct_product_meta row owned by the new variant id |
| 692 |
// with the same meta_value payload. |
| 693 |
if ($originalVariant->media && $newVariant) { |
| 694 |
$media = $originalVariant->media; |
| 695 |
ProductMeta::query()->create([ |
| 696 |
'object_id' => $newVariant->id, |
| 697 |
'object_type' => 'product_variant_info', |
| 698 |
'meta_key' => 'product_thumbnail', |
| 699 |
'meta_value' => $media->meta_value, |
| 700 |
]); |
| 701 |
} |
| 702 |
} |
| 703 |
} |
| 704 |
|
| 705 |
// Mirror advanced-variation attribute relations onto the new |
| 706 |
// variants. Without this, fct_atts_relations stays empty for |
| 707 |
// the duplicated product so variant.attr_map is empty |
| 708 |
// downstream — AdvancedVariationTable can't group by an |
| 709 |
// attribute, the per-variant breadcrumb collapses to "—", and |
| 710 |
// (with the Pro variation_title snapshot) future re-saves |
| 711 |
// would compose blank titles. Re-key on the variationIdMap |
| 712 |
// built above so each (old_variant_id, group_id, term_id) |
| 713 |
// tuple becomes a (new_variant_id, group_id, term_id) tuple. |
| 714 |
if (!empty($variationIdMap)) { |
| 715 |
$originalVariantIds = array_keys($variationIdMap); |
| 716 |
$relations = AttributeRelation::query() |
| 717 |
->whereIn('object_id', $originalVariantIds) |
| 718 |
->get(); |
| 719 |
if ($relations->isNotEmpty()) { |
| 720 |
$rows = []; |
| 721 |
foreach ($relations as $rel) { |
| 722 |
$newVariantId = Arr::get($variationIdMap, $rel->object_id); |
| 723 |
if (!$newVariantId) { |
| 724 |
continue; |
| 725 |
} |
| 726 |
$rows[] = [ |
| 727 |
'group_id' => (int) $rel->group_id, |
| 728 |
'term_id' => (int) $rel->term_id, |
| 729 |
'object_id' => (int) $newVariantId, |
| 730 |
]; |
| 731 |
} |
| 732 |
if (!empty($rows)) { |
| 733 |
AttributeRelation::query()->insert($rows); |
| 734 |
} |
| 735 |
} |
| 736 |
} |
| 737 |
|
| 738 |
if ($importDownloadableFiles && $this->downloadable_files) { |
| 739 |
foreach ($this->downloadable_files as $file) { |
| 740 |
$fileData = $file->toArray(); |
| 741 |
|
| 742 |
unset($fileData['id'], $fileData['created_at'], $fileData['updated_at']); |
| 743 |
$fileData['post_id'] = $newProductId; |
| 744 |
$fileData['download_identifier'] = Str::uuid(); |
| 745 |
|
| 746 |
if (!empty($fileData['product_variation_id'])) { |
| 747 |
$productVariationIds = []; |
| 748 |
foreach ($fileData['product_variation_id'] as $variationId) { |
| 749 |
$productVariationIds[] = Arr::get($variationIdMap, $variationId); |
| 750 |
} |
| 751 |
$fileData['product_variation_id'] = $productVariationIds; |
| 752 |
} |
| 753 |
|
| 754 |
ProductDownload::query()->create($fileData); |
| 755 |
} |
| 756 |
} |
| 757 |
|
| 758 |
$featuredImageId = \get_post_thumbnail_id($productId); |
| 759 |
if ($featuredImageId) { |
| 760 |
\set_post_thumbnail($newProductId, $featuredImageId); |
| 761 |
} |
| 762 |
|
| 763 |
$taxonomies = \get_object_taxonomies(FluentProducts::CPT_NAME); |
| 764 |
foreach ($taxonomies as $taxonomy) { |
| 765 |
$terms = \wp_get_object_terms($productId, $taxonomy, ['fields' => 'ids']); |
| 766 |
if (!empty($terms) && !\is_wp_error($terms)) { |
| 767 |
\wp_set_object_terms($newProductId, $terms, $taxonomy); |
| 768 |
} |
| 769 |
} |
| 770 |
|
| 771 |
$postMeta = \get_post_meta($productId); |
| 772 |
if ($postMeta) { |
| 773 |
foreach ($postMeta as $key => $values) { |
| 774 |
$skipKeys = ['_edit_lock', '_edit_last']; |
| 775 |
if (in_array($key, $skipKeys)) { |
| 776 |
continue; |
| 777 |
} |
| 778 |
|
| 779 |
foreach ($values as $value) { |
| 780 |
\add_post_meta($newProductId, $key, \maybe_unserialize($value)); |
| 781 |
} |
| 782 |
} |
| 783 |
} |
| 784 |
|
| 785 |
$wpdb->query('COMMIT'); |
| 786 |
|
| 787 |
\do_action('fluent_cart/product_duplicated', [ |
| 788 |
'original_product_id' => $productId, |
| 789 |
'new_product_id' => $newProductId, |
| 790 |
'options' => [ |
| 791 |
'import_stock_management' => $importStockManagement, |
| 792 |
'import_license_settings' => $importLicenseSettings, |
| 793 |
'import_downloadable_files' => $importDownloadableFiles, |
| 794 |
] |
| 795 |
]); |
| 796 |
|
| 797 |
return (int)$newProductId; |
| 798 |
} catch (\Throwable $e) { |
| 799 |
$wpdb->query('ROLLBACK'); |
| 800 |
throw $e; |
| 801 |
} |
| 802 |
} |
| 803 |
|
| 804 |
public function integrations(): \FluentCart\Framework\Database\Orm\Relations\HasMany |
| 805 |
{ |
| 806 |
return $this->hasMany(ProductMeta::class, 'object_id')->where('object_type', 'product_integration'); |
| 807 |
} |
| 808 |
} |
| 809 |
|