| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\Api\Resource; |
| 4 |
|
| 5 |
use FluentCart\Api\Taxonomy; |
| 6 |
use FluentCart\App\App; |
| 7 |
use FluentCart\App\CPT\FluentProducts; |
| 8 |
use FluentCart\App\Events\StockChanged; |
| 9 |
use FluentCart\App\Helpers\Helper; |
| 10 |
use FluentCart\App\Helpers\Status; |
| 11 |
use FluentCart\App\Models\Product; |
| 12 |
use FluentCart\App\Models\ProductDetail; |
| 13 |
use FluentCart\App\Models\ProductMeta; |
| 14 |
use FluentCart\App\Models\AttributeRelation; |
| 15 |
use FluentCart\App\Models\ProductVariation; |
| 16 |
use FluentCart\App\Services\DateTime\DateTime; |
| 17 |
use FluentCart\Framework\Database\Orm\Builder; |
| 18 |
use FluentCart\Framework\Support\Arr; |
| 19 |
use FluentCart\Framework\Support\Collection; |
| 20 |
|
| 21 |
class ProductResource extends BaseResourceApi |
| 22 |
{ |
| 23 |
|
| 24 |
public static function getQuery(): Builder |
| 25 |
{ |
| 26 |
return Product::query(); |
| 27 |
} |
| 28 |
|
| 29 |
public static function get(array $params = []): array |
| 30 |
{ |
| 31 |
return []; |
| 32 |
} |
| 33 |
|
| 34 |
public static function getProducttitle($productId) |
| 35 |
{ |
| 36 |
$product = static::getQuery()->find($productId); |
| 37 |
if ($product) { |
| 38 |
return $product->post_title; |
| 39 |
} |
| 40 |
return ''; |
| 41 |
} |
| 42 |
|
| 43 |
|
| 44 |
/** |
| 45 |
* Find product by its ID. |
| 46 |
* |
| 47 |
* @param int|string $id The ID of the post. |
| 48 |
* @param array $data Additional data for finding product (optional). |
| 49 |
* |
| 50 |
*/ |
| 51 |
public static function find($id, $data = []): ?array |
| 52 |
{ |
| 53 |
return ShopResource::find($id, $data = []); |
| 54 |
} |
| 55 |
|
| 56 |
/** |
| 57 |
* Create a new product with the given data. |
| 58 |
* |
| 59 |
* @param array $data Array containing the necessary parameters. |
| 60 |
* |
| 61 |
* $data = [ |
| 62 |
* 'post_title' => (string) Required. The title of the product. |
| 63 |
* 'post_status' => (string) Optional. The status of the product default:draft. |
| 64 |
* 'post_content' => (string) Optional. The content of the product. |
| 65 |
* 'post_date' => (date) Optional. The date of the product. |
| 66 |
* 'detail' => (array) Required. Details of the product. |
| 67 |
* 'fulfillment_type' => (string) Required. The fulfillment type default:physical. |
| 68 |
* 'variation_type' => (string) Required. The variation type default:simple. |
| 69 |
* 'manage_stock' => (int) Required. The manage stock default:1. |
| 70 |
* ]; |
| 71 |
*/ |
| 72 |
public static function create($data, $params = []) |
| 73 |
{ |
| 74 |
$postData = array_filter(Arr::only($data, [ |
| 75 |
'post_title', |
| 76 |
'post_excerpt', |
| 77 |
'post_content', |
| 78 |
'post_status', |
| 79 |
])); |
| 80 |
$postData['post_type'] = FluentProducts::CPT_NAME; |
| 81 |
|
| 82 |
$createdPostId = wp_insert_post($postData); |
| 83 |
if (!$createdPostId) { |
| 84 |
return static::makeErrorResponse([ |
| 85 |
[ |
| 86 |
'code' => 403, |
| 87 |
'message' => esc_html($createdPostId->get_error_message()), |
| 88 |
] |
| 89 |
]); |
| 90 |
|
| 91 |
} |
| 92 |
|
| 93 |
$detail = Arr::get($data, 'detail'); |
| 94 |
$detail['post_id'] = $createdPostId; |
| 95 |
$createdProductDetail = ProductDetailResource::create($detail); |
| 96 |
|
| 97 |
if ($createdProductDetail) { |
| 98 |
return static::makeSuccessResponse( |
| 99 |
[ |
| 100 |
'ID' => $createdPostId, |
| 101 |
'product_details' => Arr::get($createdProductDetail, 'data'), |
| 102 |
], |
| 103 |
__('Product has been created successfully', 'fluent-cart') |
| 104 |
); |
| 105 |
} |
| 106 |
|
| 107 |
return static::makeErrorResponse([ |
| 108 |
['code' => 400, 'message' => __('Product creation failed!', 'fluent-cart')] |
| 109 |
]); |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* Update a product with the given data. |
| 114 |
* |
| 115 |
* @param array $product Array containing the necessary parameters. |
| 116 |
* |
| 117 |
* $product = [ |
| 118 |
* 'ID' => (int) Required. The product ID. |
| 119 |
* 'post_title' => (string) Required. The title of the product. |
| 120 |
* 'post_status' => (string) Optional. The status of the product. |
| 121 |
* 'post_content' => (string) Optional. The content of the product. |
| 122 |
* 'post_date' => (string) Optional. The date of the product. |
| 123 |
* 'detail' => (array) Required. Details of the product. |
| 124 |
* 'id' => (int) Required. The detail ID. |
| 125 |
* 'post_id' => (int) Required. The product ID. |
| 126 |
* 'fulfillment_type' => (string) Required. The fulfillment type. |
| 127 |
* 'variation_type' => (string) Required. The variation type. |
| 128 |
* 'default_variation_id' => (int) Required. The default variation ID. |
| 129 |
* 'variants' => (array) Required. Variants of the product. |
| 130 |
* 'id' => (int) Required. The variant ID. |
| 131 |
* 'post_id' => (int) Required. The product ID. |
| 132 |
* 'variant_title' => (string) Required. The variant title. |
| 133 |
* 'item_price' => (int) Required. The item price in CENTS (129900 = $1,299.00). |
| 134 |
* 'compare_price' => (int) Required. The compare price, in cents. |
| 135 |
* 'manage_cost' => (string) Optional. Whether to manage costs. |
| 136 |
* 'item_cost' => (int) Required if manage cost is yes. The item cost, in cents. |
| 137 |
* 'manage_stock' => (string) Required. Whether to manage stock. |
| 138 |
* 'stock_status' => (string) Required. The stock status. |
| 139 |
* 'stock' => (int) Required. The stock quantity. |
| 140 |
* 'media' => (array) Optional. Info of media files for each variant. |
| 141 |
* 'id' => (string) Required if upload any media. The media ID. |
| 142 |
* 'url' => (string) Required if upload any media. The media URL. |
| 143 |
* 'title' => (string) Required if upload any media. The media title. |
| 144 |
* 'other_info' => (array) Optional. Other information for the variant. |
| 145 |
* 'payment_type' => (string) Required. The payment type. |
| 146 |
* 'times' => (string) Required. The number of times. |
| 147 |
* 'repeat_interval' => (string) Required. The repeat interval unit. |
| 148 |
* 'signup_fee' => (string) Required. The signup fee. |
| 149 |
* 'downloadable_files' => (array) Required if downloadable is true. |
| 150 |
* 'download_limit' => (string) Required. The download limit. |
| 151 |
* 'download_expiry' => (string) Required. The download expiry. |
| 152 |
* 'downloadable' => (bool) Optional. Whether the product is downloadable. |
| 153 |
* 'files' => (array) Optional. Info of downloadable files for each variant. |
| 154 |
* 'title' => (string) Required if files. The file title. |
| 155 |
* 'type' => (string) Required if files. The file type. |
| 156 |
* 'file_name' => (string) Required if files. The file name. |
| 157 |
* 'file_path' => (string) Required if files. The file path. |
| 158 |
* 'file_url' => (string) Required if files. The file URL. |
| 159 |
* 'serial' => (string) Required if files. The file serial |
| 160 |
* 'product_terms' => (array) Optional. Terms of the product. |
| 161 |
* 'product-categories' => (array) Required if categories. Product categories. |
| 162 |
* [0] => (int) Optional. The category ID. |
| 163 |
* 'product-brands' => (array) Required if brands. Product brands. |
| 164 |
* [0] => (int) Optional. The tag ID. |
| 165 |
* ]; |
| 166 |
*/ |
| 167 |
public static function update($product, $postId = '', $params = []) |
| 168 |
{ |
| 169 |
|
| 170 |
$product ??= []; |
| 171 |
$variants = Arr::get($product, 'variants', []); |
| 172 |
$detail = Arr::get($product, 'detail'); |
| 173 |
$gallery = Arr::get($product, 'gallery', []); |
| 174 |
$variants = Arr::except($variants, ['*']); |
| 175 |
|
| 176 |
|
| 177 |
if (count($variants) > 0) { |
| 178 |
|
| 179 |
$variationType = Arr::get($detail, 'variation_type', 'simple'); |
| 180 |
|
| 181 |
if ($variationType === 'simple') { |
| 182 |
$variant = $variants[0]; |
| 183 |
$otherInfo = Arr::get($variant, 'other_info', []); |
| 184 |
|
| 185 |
$priceColumns = [ |
| 186 |
'item_price', |
| 187 |
'compare_price', |
| 188 |
'item_cost', |
| 189 |
]; |
| 190 |
|
| 191 |
// Amounts arrive in CENTS; normalize float artifacts without scaling. |
| 192 |
foreach ($priceColumns as $column) { |
| 193 |
if (Arr::has($variant, $column)) { |
| 194 |
$variant[$column] = Helper::roundCent(Arr::get($variant, $column)); |
| 195 |
} |
| 196 |
} |
| 197 |
|
| 198 |
unset($variant['rowId']); |
| 199 |
unset($variant['media']); |
| 200 |
|
| 201 |
// Recalculate stock_status and sync total_stock from available |
| 202 |
if (isset($variant['manage_stock'])) { |
| 203 |
if ($variant['manage_stock']) { |
| 204 |
$avail = intval(Arr::get($variant, 'available', 0)); |
| 205 |
$variant['stock_status'] = $avail > 0 ? Helper::IN_STOCK : Helper::OUT_OF_STOCK; |
| 206 |
$variant['total_stock'] = $avail; |
| 207 |
} else { |
| 208 |
$variant['stock_status'] = Helper::IN_STOCK; |
| 209 |
} |
| 210 |
} |
| 211 |
|
| 212 |
$variantData = $variant; |
| 213 |
|
| 214 |
// An explicitly cleared sku ('' or null) must persist as NULL so the |
| 215 |
// stored value is actually cleared, while still avoiding the sku_unique |
| 216 |
// constraint (MySQL treats multiple NULLs as distinct, unlike ''). |
| 217 |
if (array_key_exists('sku', $variantData) && ($variantData['sku'] === '' || $variantData['sku'] === null)) { |
| 218 |
$variantData['sku'] = null; |
| 219 |
} |
| 220 |
// Remove empty shipping_class to prevent unique constraint violation |
| 221 |
if (array_key_exists('shipping_class', $variantData) && empty($variantData['shipping_class'])) { |
| 222 |
unset($variantData['shipping_class']); |
| 223 |
} |
| 224 |
|
| 225 |
// Handle other_info |
| 226 |
if (!empty($otherInfo)) { |
| 227 |
if (Arr::get($otherInfo, 'payment_type') == 'subscription') { |
| 228 |
if (Arr::get($otherInfo, 'manage_setup_fee') == 'yes') { |
| 229 |
$signupFee = Helper::roundCent(Arr::get($otherInfo, 'signup_fee', 0)); |
| 230 |
Arr::set($otherInfo, 'signup_fee', $signupFee); |
| 231 |
} |
| 232 |
$variantData['payment_type'] = 'subscription'; |
| 233 |
} else { |
| 234 |
$variantData['payment_type'] = 'onetime'; |
| 235 |
} |
| 236 |
$variantData['other_info'] = $otherInfo; |
| 237 |
} |
| 238 |
|
| 239 |
|
| 240 |
// Only update if there's data to update |
| 241 |
if (!empty($variantData)) { |
| 242 |
ProductVariation::query()->where('id', Arr::get($variant, 'id'))->update($variantData); |
| 243 |
} |
| 244 |
|
| 245 |
} else { |
| 246 |
$variantData = []; |
| 247 |
|
| 248 |
foreach ($variants as $index => $variant) { |
| 249 |
$otherInfo = Arr::get($variant, 'other_info', []); |
| 250 |
|
| 251 |
$priceColumns = [ |
| 252 |
'item_price', |
| 253 |
'compare_price', |
| 254 |
'item_cost', |
| 255 |
]; |
| 256 |
|
| 257 |
// Amounts arrive in CENTS; normalize without scaling. |
| 258 |
foreach ($priceColumns as $column) { |
| 259 |
if (Arr::has($variant, $column)) { |
| 260 |
$variant[$column] = Helper::roundCent(Arr::get($variant, $column)); |
| 261 |
} |
| 262 |
} |
| 263 |
unset($variant['rowId']); |
| 264 |
|
| 265 |
// serial_index is display ordering the caller owns, not a column derived |
| 266 |
// from this loop. An advanced-variation save carries only the rows the |
| 267 |
// merchant actually touched, so deriving it from the payload index |
| 268 |
// renumbered an edited row to the front and left two variations sharing a |
| 269 |
// position. The reorder path sends an explicit serial_index for every row |
| 270 |
// and bulk edit round-trips the stored one, so an absent value means |
| 271 |
// "unchanged": drop the column and let batchUpdate's `ELSE serial_index` |
| 272 |
// keep what is stored. |
| 273 |
if (!isset($variant['serial_index']) || $variant['serial_index'] === '') { |
| 274 |
unset($variant['serial_index']); |
| 275 |
} |
| 276 |
|
| 277 |
// An explicitly cleared sku ('' or null) must persist as NULL so the |
| 278 |
// stored value is actually cleared, while still avoiding the sku_unique |
| 279 |
// constraint (MySQL treats multiple NULLs as distinct, unlike ''). |
| 280 |
if (array_key_exists('sku', $variant) && ($variant['sku'] === '' || $variant['sku'] === null)) { |
| 281 |
$variant['sku'] = null; |
| 282 |
} |
| 283 |
|
| 284 |
// Recalculate stock_status from available and manage_stock |
| 285 |
if (isset($variant['manage_stock'])) { |
| 286 |
if ($variant['manage_stock']) { |
| 287 |
$avail = intval(Arr::get($variant, 'available', 0)); |
| 288 |
$variant['stock_status'] = $avail > 0 ? Helper::IN_STOCK : Helper::OUT_OF_STOCK; |
| 289 |
$variant['total_stock'] = $avail; |
| 290 |
} else { |
| 291 |
$variant['stock_status'] = Helper::IN_STOCK; |
| 292 |
} |
| 293 |
} |
| 294 |
|
| 295 |
if (!empty($otherInfo)) { |
| 296 |
if (Arr::get($otherInfo, 'payment_type') == 'subscription') { |
| 297 |
if (Arr::get($otherInfo, 'manage_setup_fee') == 'yes') { |
| 298 |
$signupFee = Helper::roundCent(Arr::get($otherInfo, 'signup_fee', 0)); |
| 299 |
Arr::set($otherInfo, 'signup_fee', $signupFee); |
| 300 |
} |
| 301 |
} |
| 302 |
$variant['other_info'] = $otherInfo; |
| 303 |
} |
| 304 |
$variantData[] = apply_filters('fluent_cart/product/variant_save_data', $variant, $postId); |
| 305 |
} |
| 306 |
|
| 307 |
// Only batch update if there's data. Wrap the write in a transaction so |
| 308 |
// a mid-batch failure (UNIQUE constraint, deadlock, etc.) rolls back the |
| 309 |
// entire variant update instead of leaving the product in a partially-saved |
| 310 |
// state. The variants_updated action fires only after a successful commit |
| 311 |
// so listeners never observe a rolled-back state. |
| 312 |
if (!empty($variantData)) { |
| 313 |
$db = App::db(); |
| 314 |
$db->beginTransaction(); |
| 315 |
try { |
| 316 |
ProductVariation::query()->batchUpdate($variantData); |
| 317 |
$db->commit(); |
| 318 |
} catch (\Exception $e) { |
| 319 |
$db->rollBack(); |
| 320 |
throw $e; |
| 321 |
} |
| 322 |
do_action('fluent_cart/product/variants_updated', [ |
| 323 |
'post_id' => $postId, |
| 324 |
'variants' => $variantData, |
| 325 |
]); |
| 326 |
} |
| 327 |
} |
| 328 |
|
| 329 |
|
| 330 |
} |
| 331 |
|
| 332 |
// Deliberately NOT defaulted here. $detail is a partial row — the editor |
| 333 |
// stages only what the merchant touched — so materialising this key as null |
| 334 |
// told ProductDetailResource::update() to clear the stored Default Variant |
| 335 |
// on every unrelated save (an inline price edit was enough). Absent now |
| 336 |
// means "unchanged"; an explicit empty value still clears it. |
| 337 |
|
| 338 |
// Recalculate min_price / max_price from current variant prices |
| 339 |
$variantPriceRange = ProductVariation::query() |
| 340 |
->where('post_id', $postId) |
| 341 |
->selectRaw('MIN(item_price) as min_price, MAX(item_price) as max_price') |
| 342 |
->first(); |
| 343 |
|
| 344 |
if ($variantPriceRange) { |
| 345 |
$detail['min_price'] = $variantPriceRange->min_price ?: 0; |
| 346 |
$detail['max_price'] = $variantPriceRange->max_price ?: 0; |
| 347 |
} |
| 348 |
|
| 349 |
ProductDetailResource::update($detail, Arr::get($detail, 'id'), ['action' => 'variant_modified']); |
| 350 |
|
| 351 |
(new StockChanged([$postId]))->dispatch(); |
| 352 |
|
| 353 |
static::updateWpPost($postId, $product); |
| 354 |
if (Arr::has($product, 'gallery')) { |
| 355 |
update_post_meta($postId, FluentProducts::CPT_NAME . '-gallery-image', $gallery); |
| 356 |
|
| 357 |
if (isset($gallery[0])) { |
| 358 |
set_post_thumbnail($postId, Arr::get($gallery, '0.id')); |
| 359 |
$thumbnailImageId = get_post_meta($postId, '_thumbnail_id', true); |
| 360 |
$thumbnail = wp_prepare_attachment_for_js($thumbnailImageId); |
| 361 |
$thumbUrl = Arr::get($thumbnail, 'url'); |
| 362 |
if (!empty($thumbUrl) && Arr::get($gallery, '0.id') !== $thumbnailImageId) { |
| 363 |
update_post_meta($postId, '_thumbnail_id', $thumbnailImageId); |
| 364 |
} |
| 365 |
} else { |
| 366 |
delete_post_thumbnail($postId); |
| 367 |
} |
| 368 |
} |
| 369 |
|
| 370 |
$product = static::getQuery()->with('variants')->addAppends([ |
| 371 |
'viewUrl' |
| 372 |
])->find($postId); |
| 373 |
|
| 374 |
|
| 375 |
return static::makeSuccessResponse( |
| 376 |
$product, |
| 377 |
__('Product has been updated', 'fluent-cart') |
| 378 |
); |
| 379 |
} |
| 380 |
|
| 381 |
public static function partialUpdate(array $data, $postId) |
| 382 |
{ |
| 383 |
$product = get_post($postId); |
| 384 |
|
| 385 |
if (!$product || $product->post_type !== 'fluent-products') { |
| 386 |
return new \WP_Error('not_found', __('Product not found', 'fluent-cart')); |
| 387 |
} |
| 388 |
|
| 389 |
$postData = ['ID' => (int) $postId]; |
| 390 |
|
| 391 |
$allowedFields = ['post_title', 'post_content', 'post_excerpt', 'post_status', 'post_date']; |
| 392 |
|
| 393 |
foreach ($allowedFields as $field) { |
| 394 |
if (\array_key_exists($field, $data)) { |
| 395 |
$postData[$field] = $data[$field]; |
| 396 |
} |
| 397 |
} |
| 398 |
|
| 399 |
if (\count($postData) === 1) { |
| 400 |
return new \WP_Error('no_fields', __('No valid fields provided for update', 'fluent-cart')); |
| 401 |
} |
| 402 |
|
| 403 |
$newStatus = $postData['post_status'] ?? null; |
| 404 |
$syncOrmDates = false; |
| 405 |
|
| 406 |
if ($newStatus === 'future') { |
| 407 |
$postDate = DateTime::anyTimeToGmt($postData['post_date'])->format('Y-m-d H:i:s'); |
| 408 |
$postData['post_date'] = $postDate; |
| 409 |
$postData['post_date_gmt'] = $postDate; |
| 410 |
$syncOrmDates = true; |
| 411 |
} elseif ($newStatus === 'publish' && $product->post_status === 'future') { |
| 412 |
$now = DateTime::gmtNow()->format('Y-m-d H:i:s'); |
| 413 |
$postData['post_date'] = $now; |
| 414 |
$postData['post_date_gmt'] = $now; |
| 415 |
$syncOrmDates = true; |
| 416 |
} |
| 417 |
|
| 418 |
$updated = wp_update_post($postData, true); |
| 419 |
|
| 420 |
if (is_wp_error($updated)) { |
| 421 |
return $updated; |
| 422 |
} |
| 423 |
|
| 424 |
if ($syncOrmDates) { |
| 425 |
Product::query()->where('ID', $postId)->update([ |
| 426 |
'post_status' => $newStatus, |
| 427 |
'post_date' => $postData['post_date'], |
| 428 |
'post_date_gmt' => $postData['post_date_gmt'], |
| 429 |
]); |
| 430 |
} |
| 431 |
|
| 432 |
$product = static::getQuery()->with('variants')->addAppends(['viewUrl'])->find($postId); |
| 433 |
|
| 434 |
return static::makeSuccessResponse($product, __('Product has been updated', 'fluent-cart')); |
| 435 |
} |
| 436 |
|
| 437 |
public static function updateWpPost($postId, $params = []) |
| 438 |
{ |
| 439 |
|
| 440 |
$postStatus = Arr::get($params, 'post_status'); |
| 441 |
$postTitle = Arr::get($params, 'post_title'); |
| 442 |
$postContent = Arr::get($params, 'post_content'); |
| 443 |
$postExcerpt = Arr::get($params, 'post_excerpt'); |
| 444 |
$commentStatus = Arr::get($params, 'comment_status'); |
| 445 |
$postName = Arr::get($params, 'post_name'); |
| 446 |
|
| 447 |
$data = [ |
| 448 |
'ID' => $postId, |
| 449 |
'post_title' => $postTitle, |
| 450 |
'post_status' => $postStatus, |
| 451 |
'comment_status' => $commentStatus, |
| 452 |
'post_name' => $postName, |
| 453 |
]; |
| 454 |
|
| 455 |
if (isset($postExcerpt)) { |
| 456 |
$data['post_excerpt'] = $postExcerpt; |
| 457 |
} |
| 458 |
|
| 459 |
$activeEditor = Arr::get($params, 'detail.other_info.active_editor', 'wp-editor'); |
| 460 |
if (empty($activeEditor)) { |
| 461 |
$activeEditor = 'wp-editor'; |
| 462 |
} |
| 463 |
if (isset($postContent)) { |
| 464 |
$data['post_content'] = $postContent; |
| 465 |
} |
| 466 |
|
| 467 |
// Only write post_date when the product is being scheduled (status |
| 468 |
// "future") — the admin editor exposes the date picker in that case |
| 469 |
// only. For ordinary edits we must NOT rewrite post_date/post_date_gmt, |
| 470 |
// otherwise the creation date changes on every save and "sort by newest" |
| 471 |
// breaks. WordPress updates post_modified on its own. |
| 472 |
$postDate = null; |
| 473 |
if ($postStatus === 'future') { |
| 474 |
$scheduledDate = Arr::get($params, 'post_date'); |
| 475 |
if (empty($scheduledDate)) { |
| 476 |
$scheduledDate = DateTime::gmtNow()->format('Y-m-d H:i:s'); |
| 477 |
} |
| 478 |
$postDate = DateTime::anyTimeToGmt($scheduledDate)->format('Y-m-d H:i:s'); |
| 479 |
$data['post_date'] = $postDate; |
| 480 |
$data['post_date_gmt'] = $postDate; |
| 481 |
} elseif ($postStatus === 'publish' && get_post_field('post_status', $postId) === 'future') { |
| 482 |
// Publishing a scheduled product early: stamp the creation date to |
| 483 |
// now so it doesn't go live with a future date (which would sort as |
| 484 |
// "newest"). Mirrors partialUpdate(). |
| 485 |
$postDate = DateTime::gmtNow()->format('Y-m-d H:i:s'); |
| 486 |
$data['post_date'] = $postDate; |
| 487 |
$data['post_date_gmt'] = $postDate; |
| 488 |
} |
| 489 |
|
| 490 |
$updated = wp_update_post($data); |
| 491 |
|
| 492 |
if ($updated) { |
| 493 |
$ormData = ['post_status' => $postStatus]; |
| 494 |
if ($postDate !== null) { |
| 495 |
$ormData['post_date'] = $postDate; |
| 496 |
$ormData['post_date_gmt'] = $postDate; |
| 497 |
} |
| 498 |
Product::query()->where('ID', $postId)->update($ormData); |
| 499 |
} |
| 500 |
|
| 501 |
return $updated; |
| 502 |
} |
| 503 |
|
| 504 |
/** |
| 505 |
* Delete a product and its associated data. |
| 506 |
* |
| 507 |
* @param int $postId The ID of the product to be deleted. |
| 508 |
* @param array $params Additional parameters for the deletion process. |
| 509 |
* |
| 510 |
*/ |
| 511 |
public static function delete($postId, $params = []) |
| 512 |
{ |
| 513 |
|
| 514 |
|
| 515 |
$product = static::getQuery() |
| 516 |
->with('variants') |
| 517 |
->with('orderItems', function ($query) use ($postId) { |
| 518 |
return $query->whereHas('order', function ($query) { |
| 519 |
return $query->search(["status" => ["column" => "status", "operator" => "in", "value" => [Status::ORDER_ON_HOLD, Status::ORDER_PROCESSING]]]); |
| 520 |
}); |
| 521 |
}) |
| 522 |
->find($postId); |
| 523 |
|
| 524 |
|
| 525 |
if (!empty($product)) { |
| 526 |
if (count($product->orderItems) > 0) { |
| 527 |
return static::makeErrorResponse([ |
| 528 |
['code' => 400, 'message' => __('This product cannot be deleted at the moment. There are pending orders associated with it. Deleting the product will disrupt the order processing and might cause inconvenience to our customers.', 'fluent-cart')] |
| 529 |
]); |
| 530 |
} |
| 531 |
$variantIds = $product->variants->pluck('id')->toArray(); |
| 532 |
foreach ($product->variants as $variant) { |
| 533 |
$variant->media()->delete(); |
| 534 |
} |
| 535 |
if ($variantIds) { |
| 536 |
AttributeRelation::query()->whereIn('object_id', $variantIds)->delete(); |
| 537 |
} |
| 538 |
$product->detail()->delete(); |
| 539 |
$product->variants()->delete(); |
| 540 |
$product->licensesMeta()->delete(); |
| 541 |
$product->downloadable_files()->delete(); |
| 542 |
|
| 543 |
$taxonomies = Taxonomy::getTaxonomies(); |
| 544 |
Collection::make($taxonomies) |
| 545 |
->each(function ($taxonomy) use (&$product) { |
| 546 |
$ids = Taxonomy::getTermIdsFromTerms($product->getTermByType($taxonomy)->get()->toArray()); |
| 547 |
foreach ($ids as $id) { |
| 548 |
Taxonomy::deleteTaxonomyTermFromProduct($product->ID, $taxonomy, $id); |
| 549 |
} |
| 550 |
}); |
| 551 |
$product->wp_terms()->delete(); |
| 552 |
|
| 553 |
$productTitle = $product->post_title; |
| 554 |
|
| 555 |
|
| 556 |
$deletedProduct = $product->delete(); |
| 557 |
|
| 558 |
if ($deletedProduct) { |
| 559 |
|
| 560 |
fluent_cart_success_log( |
| 561 |
__('Product deleted', 'fluent-cart'), |
| 562 |
sprintf( |
| 563 |
/* translators: %s is the product title */ |
| 564 |
__('Product %s is deleted', 'fluent-cart'), $productTitle), |
| 565 |
[ |
| 566 |
'module_name' => 'Product', |
| 567 |
'module_id' => 0, |
| 568 |
'module_type' => Product::class, |
| 569 |
] |
| 570 |
); |
| 571 |
return static::makeSuccessResponse( |
| 572 |
'', |
| 573 |
__('Selected product and associated data has been deleted', 'fluent-cart') |
| 574 |
); |
| 575 |
} |
| 576 |
return static::makeErrorResponse([ |
| 577 |
['code' => 400, 'message' => __('Product deletion failed!', 'fluent-cart')] |
| 578 |
]); |
| 579 |
} |
| 580 |
|
| 581 |
return static::makeErrorResponse([ |
| 582 |
['code' => 404, 'message' => __('Product not found in database.', 'fluent-cart')] |
| 583 |
]); |
| 584 |
|
| 585 |
} |
| 586 |
|
| 587 |
/** |
| 588 |
* |
| 589 |
* @param $productId |
| 590 |
* @param $data |
| 591 |
* @return mixed |
| 592 |
*/ |
| 593 |
public static function syncVariantOption($productId, $data = []) |
| 594 |
{ |
| 595 |
$preprocessed = apply_filters('fluent_cart/product/variant_option_payload', [ |
| 596 |
'product_id' => (int) $productId, |
| 597 |
'data' => $data, |
| 598 |
]); |
| 599 |
if (!is_array($preprocessed) || !isset($preprocessed['data'])) { |
| 600 |
$preprocessed = ['product_id' => (int) $productId, 'data' => $data]; |
| 601 |
} |
| 602 |
$data = $preprocessed['data']; |
| 603 |
|
| 604 |
$result = apply_filters('fluent_cart/product/variant_option_sync', [ |
| 605 |
'product_id' => (int) $productId, |
| 606 |
'data' => $data, |
| 607 |
'handled' => false, |
| 608 |
'response' => null, |
| 609 |
]); |
| 610 |
|
| 611 |
if (!empty($result['handled'])) { |
| 612 |
return $result['response']; |
| 613 |
} |
| 614 |
|
| 615 |
return static::makeErrorResponse([ |
| 616 |
['code' => 400, 'message' => __('Illegal data provided.', 'fluent-cart')] |
| 617 |
]); |
| 618 |
} |
| 619 |
|
| 620 |
/** |
| 621 |
* Manage products based on the provided action and product IDs. |
| 622 |
* |
| 623 |
* @param array $params Optional. Array containing the necessary parameters |
| 624 |
* [ |
| 625 |
* 'action' => (string) Required. The action to be performed on the selected products. |
| 626 |
* (e.g., Possible values: 'delete_products') |
| 627 |
* 'product_ids' => (array) Required. Product IDs whose action will be performed. |
| 628 |
* ] |
| 629 |
* |
| 630 |
*/ |
| 631 |
public static function manageBulkActions($params = []) |
| 632 |
{ |
| 633 |
$action = Arr::get($params, 'action', ''); |
| 634 |
$productIds = Arr::get($params, 'product_ids', []); |
| 635 |
|
| 636 |
$productIds = array_map(function ($id) { |
| 637 |
return (int)$id; |
| 638 |
}, $productIds); |
| 639 |
|
| 640 |
$productIds = array_filter($productIds); |
| 641 |
|
| 642 |
if (!$productIds) { |
| 643 |
return static::makeErrorResponse([ |
| 644 |
['code' => 403, 'message' => __('Products selection is required', 'fluent-cart')] |
| 645 |
]); |
| 646 |
} |
| 647 |
|
| 648 |
$products = Product::whereIn('ID', $productIds)->get(); |
| 649 |
|
| 650 |
if ($action == 'delete_products') { |
| 651 |
|
| 652 |
$failedProductIds = []; |
| 653 |
$deletedProductIds = []; |
| 654 |
|
| 655 |
foreach ($products as $product) { |
| 656 |
$isDeleted = static::delete($product->ID); |
| 657 |
|
| 658 |
if (is_wp_error($isDeleted)) { |
| 659 |
$failedProductIds[] = $product->ID; |
| 660 |
} else { |
| 661 |
$deletedProductIds[] = $product->ID; |
| 662 |
} |
| 663 |
} |
| 664 |
|
| 665 |
if (count($failedProductIds) > 0) { |
| 666 |
$failedProductIds = implode(' , ', $failedProductIds); |
| 667 |
/* translators: %s: The product ID(s) that could not be deleted. */ |
| 668 |
return count($deletedProductIds) > 0 |
| 669 |
? static::makeSuccessResponse( |
| 670 |
'', |
| 671 |
sprintf( |
| 672 |
/* translators: %s: The product ID(s) that could not be deleted. */ |
| 673 |
esc_html__( |
| 674 |
'The Product ID - %s cannot be deleted at the moment as there are pending orders associated with it. And remaining product and its associated data have been deleted.', |
| 675 |
'fluent-cart' |
| 676 |
), |
| 677 |
esc_html($failedProductIds) |
| 678 |
) |
| 679 |
) |
| 680 |
: static::makeErrorResponse([ |
| 681 |
[ |
| 682 |
'code' => 400, |
| 683 |
'message' => sprintf( |
| 684 |
/* translators: %s: The product ID(s) that could not be deleted. */ |
| 685 |
esc_html__( |
| 686 |
'The Product ID - %s cannot be deleted at the moment as there are pending orders associated with it.', |
| 687 |
'fluent-cart' |
| 688 |
), |
| 689 |
esc_html($failedProductIds) |
| 690 |
), |
| 691 |
], |
| 692 |
]); |
| 693 |
} |
| 694 |
|
| 695 |
if (count($deletedProductIds) > 0 && count($failedProductIds) < 1) { |
| 696 |
return static::makeSuccessResponse('', __('Selected product and associated data have been deleted', 'fluent-cart')); |
| 697 |
} |
| 698 |
} |
| 699 |
|
| 700 |
if ($action === 'duplicate_products') { |
| 701 |
$importStockManagement = filter_var( |
| 702 |
Arr::get($params, 'import_stock_management', false), |
| 703 |
FILTER_VALIDATE_BOOLEAN |
| 704 |
); |
| 705 |
$importLicenseSettings = filter_var( |
| 706 |
Arr::get($params, 'import_license_settings', false), |
| 707 |
FILTER_VALIDATE_BOOLEAN |
| 708 |
); |
| 709 |
$importDownloadableFiles = filter_var( |
| 710 |
Arr::get($params, 'import_downloadable_files', false), |
| 711 |
FILTER_VALIDATE_BOOLEAN |
| 712 |
); |
| 713 |
|
| 714 |
$options = [ |
| 715 |
'import_stock_management' => $importStockManagement, |
| 716 |
'import_license_settings' => $importLicenseSettings, |
| 717 |
'import_downloadable_files' => $importDownloadableFiles, |
| 718 |
]; |
| 719 |
|
| 720 |
$failedProductIds = []; |
| 721 |
$newProductIds = []; |
| 722 |
|
| 723 |
foreach ($productIds as $productId) { |
| 724 |
try { |
| 725 |
$newProductIds[] = Product::duplicateProduct($productId, $options); |
| 726 |
} catch (\Throwable $e) { |
| 727 |
$failedProductIds[] = $productId; |
| 728 |
} |
| 729 |
} |
| 730 |
|
| 731 |
if (count($failedProductIds) > 0) { |
| 732 |
$failedProductIdsText = implode(' , ', $failedProductIds); |
| 733 |
return count($newProductIds) > 0 |
| 734 |
? static::makeSuccessResponse( |
| 735 |
[ |
| 736 |
'new_product_ids' => $newProductIds |
| 737 |
], |
| 738 |
sprintf( |
| 739 |
esc_html__( |
| 740 |
'Some products could not be duplicated (Product ID: %s). Remaining selected products have been duplicated as drafts.', |
| 741 |
'fluent-cart' |
| 742 |
), |
| 743 |
esc_html($failedProductIdsText) |
| 744 |
) |
| 745 |
) |
| 746 |
: static::makeErrorResponse([ |
| 747 |
[ |
| 748 |
'code' => 400, |
| 749 |
'message' => sprintf( |
| 750 |
esc_html__( |
| 751 |
'Selected products could not be duplicated (Product ID: %s).', |
| 752 |
'fluent-cart' |
| 753 |
), |
| 754 |
esc_html($failedProductIdsText) |
| 755 |
), |
| 756 |
], |
| 757 |
]); |
| 758 |
} |
| 759 |
|
| 760 |
return static::makeSuccessResponse( |
| 761 |
[ |
| 762 |
'new_product_ids' => $newProductIds |
| 763 |
], |
| 764 |
__('Selected products have been duplicated as drafts', 'fluent-cart') |
| 765 |
); |
| 766 |
} |
| 767 |
|
| 768 |
return static::makeErrorResponse([ |
| 769 |
['code' => 400, 'message' => __('Selected action is invalid', 'fluent-cart')] |
| 770 |
]); |
| 771 |
} |
| 772 |
|
| 773 |
public static function validateDownloadableFiles($data) |
| 774 |
{ |
| 775 |
$downloadableFiles = Arr::except(Arr::get($data, 'downloadable_files', []), ['*']); |
| 776 |
$variants = Arr::except(Arr::get($data, 'variants', []), ['*']); |
| 777 |
$fulfilmentType = Arr::get($data, 'detail.fulfillment_type'); |
| 778 |
$errors = []; |
| 779 |
|
| 780 |
if (!empty($downloadableFiles)) { |
| 781 |
foreach ($variants as $index => $variant) { |
| 782 |
$count = 0; |
| 783 |
$id = Arr::get($variant, 'rowId', null); |
| 784 |
foreach ($downloadableFiles as $idx => $downloadFile) { |
| 785 |
$variantIds = Arr::get($downloadFile, 'product_variation_id', null); |
| 786 |
if (empty($variantIds)) { |
| 787 |
$errors['downloadable_files.' . $idx . '.product_variation_id'] = 'Please choose variant'; |
| 788 |
} |
| 789 |
if ($fulfilmentType === 'digital' && is_array($variantIds) && in_array($id, $variantIds)) { |
| 790 |
$count += 1; |
| 791 |
} |
| 792 |
if ($fulfilmentType === 'physical') { |
| 793 |
if (Arr::get($variant, 'downloadable') == 'true' && is_array($variantIds) && in_array($id, $variantIds)) { |
| 794 |
$count += 1; |
| 795 |
} |
| 796 |
if (Arr::get($variant, 'downloadable') == 'false') { |
| 797 |
$count += 1; |
| 798 |
} |
| 799 |
} |
| 800 |
} |
| 801 |
if ($count == 0) { |
| 802 |
$errors['variants.' . $index . '.downloadable'] = sprintf( |
| 803 |
/* translators: %s is the variant title */ |
| 804 |
__('%s variant is downloadable without any downloadable file', 'fluent-cart'), |
| 805 |
$variant['variation_title'] |
| 806 |
|
| 807 |
); |
| 808 |
|
| 809 |
} |
| 810 |
} |
| 811 |
} |
| 812 |
return $errors; |
| 813 |
} |
| 814 |
|
| 815 |
public static function getNextProduct($productId, $skipOutOfStock) |
| 816 |
{ |
| 817 |
|
| 818 |
} |
| 819 |
|
| 820 |
/** |
| 821 |
* Count total no of products. |
| 822 |
* |
| 823 |
*/ |
| 824 |
public static function countTotalProducts() |
| 825 |
{ |
| 826 |
return static::getQuery() |
| 827 |
->whereHas('detail') |
| 828 |
->whereHas('variants') |
| 829 |
->count(); |
| 830 |
} |
| 831 |
|
| 832 |
public static function syncTaxonomyTerms($data, $postId = '', $params = []) |
| 833 |
{ |
| 834 |
$data ??= []; |
| 835 |
|
| 836 |
if (count(Arr::get($data, 'terms', [])) == 0) { |
| 837 |
$isSynced = Taxonomy::deleteAllTermRelationshipsFromProduct($postId, Arr::get($data, 'taxonomy')); |
| 838 |
} else { |
| 839 |
$isSynced = Taxonomy::syncTaxonomyTermsToProduct($postId, Arr::get($data, 'taxonomy'), Arr::get($data, 'terms', [])); |
| 840 |
} |
| 841 |
if (!is_wp_error($isSynced)) { |
| 842 |
return static::makeSuccessResponse( |
| 843 |
$isSynced, |
| 844 |
__("Product has been updated", 'fluent-cart') |
| 845 |
); |
| 846 |
} |
| 847 |
|
| 848 |
return static::makeErrorResponse([ |
| 849 |
['code' => 400, 'message' => __("Product update failed!", 'fluent-cart')] |
| 850 |
]); |
| 851 |
} |
| 852 |
|
| 853 |
public static function deleteTaxonomyTerms($data, $postId = '', $params = []) |
| 854 |
{ |
| 855 |
$data ??= []; |
| 856 |
|
| 857 |
$isDeleted = Taxonomy::deleteTaxonomyTermFromProduct( |
| 858 |
$postId, |
| 859 |
Arr::get($data, 'taxonomy'), |
| 860 |
Arr::get($data, 'term', null) |
| 861 |
); |
| 862 |
|
| 863 |
if (!is_wp_error($isDeleted)) { |
| 864 |
return static::makeSuccessResponse( |
| 865 |
$isDeleted, |
| 866 |
__("Product has been updated", 'fluent-cart') |
| 867 |
); |
| 868 |
} |
| 869 |
|
| 870 |
return static::makeErrorResponse([ |
| 871 |
['code' => 400, 'message' => __("Product update failed!", 'fluent-cart')] |
| 872 |
]); |
| 873 |
} |
| 874 |
|
| 875 |
public static function findByProductAndVariants(array $params = []) |
| 876 |
{ |
| 877 |
$productId = Arr::get($params, 'product_id', null); |
| 878 |
$variantIds = Arr::get($params, 'variant_ids', []); |
| 879 |
|
| 880 |
if (empty($productId)) { |
| 881 |
return null; |
| 882 |
} |
| 883 |
|
| 884 |
$product = static::getQuery() |
| 885 |
->where('ID', $productId) |
| 886 |
->with([ |
| 887 |
'postmeta', |
| 888 |
'detail', |
| 889 |
'variants' => function ($query) use ($variantIds) { |
| 890 |
// Filter variants only if variant_ids provided |
| 891 |
if (!empty($variantIds)) { |
| 892 |
$query->whereIn('id', $variantIds); |
| 893 |
} |
| 894 |
|
| 895 |
$query->with('media') |
| 896 |
->orderBy('serial_index', 'ASC'); |
| 897 |
} |
| 898 |
]) |
| 899 |
->first(); |
| 900 |
|
| 901 |
if (!$product) { |
| 902 |
return null; |
| 903 |
} |
| 904 |
|
| 905 |
return $product; |
| 906 |
} |
| 907 |
|
| 908 |
} |
| 909 |
|