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 / api / Resource / ProductResource.php

ProductResource.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at api/Resource/ProductResource.php

909 lines 36.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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