PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.2
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Http / Controllers / ProductController.php

ProductController.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.2, at app/Http/Controllers/ProductController.php

1,621 lines 54.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Http\Controllers;
4
5 use FluentCart\Api\Resource\ProductDetailResource;
6 use FluentCart\Api\Resource\ProductResource;
7 use FluentCart\Api\Resource\ProductVariationResource;
8 use FluentCart\App\Events\StockChanged;
9 use FluentCart\Api\Resource\ShopResource;
10 use FluentCart\Api\Taxonomy;
11 use FluentCart\App\CPT\FluentProducts;
12 use FluentCart\App\Helpers\AdminHelper;
13 use FluentCart\App\Helpers\Helper;
14 use FluentCart\App\Http\Requests\ProductCreateRequest;
15 use FluentCart\App\Http\Requests\ProductRequest;
16 use FluentCart\App\Http\Requests\ProductUpdateRequest;
17 use FluentCart\App\Http\Requests\UpgradePathSettingRequest;
18 use FluentCart\App\Models\Meta;
19 use FluentCart\App\Models\Product;
20 use FluentCart\App\Models\ProductDetail;
21 use FluentCart\App\Models\ProductDownload;
22 use FluentCart\App\Models\ProductMeta;
23 use FluentCart\App\Models\ProductVariation;
24 use FluentCart\App\Models\ShippingClass;
25 use FluentCart\App\Models\TaxClass;
26 use FluentCart\App\Modules\ReportingModule\ProductReport;
27 use FluentCart\App\Services\Async\DummyProductService;
28 use FluentCart\App\Helpers\AttributeHelper;
29 use FluentCart\App\Services\BulkProductInsertService;
30 use FluentCart\App\Services\BulkProductUpdateService;
31 use FluentCart\App\Services\Filter\ProductFilter;
32 use FluentCart\App\Services\PlanUpgradeService;
33 use FluentCart\Framework\Database\Orm\Builder;
34 use FluentCart\Framework\Http\Request\Request;
35 use FluentCart\Framework\Support\Arr;
36 use FluentCart\Framework\Support\Collection;
37 use FluentCart\Framework\Support\Str;
38 use WP_REST_Response;
39 use FluentCart\Api\Helper as ApiHelper;
40
41 class ProductController extends Controller
42 {
43 public function index(Request $request): WP_REST_Response
44 {
45 //$request->set('with', ['detail', 'variants:post_id,available,manage_stock,stock_status,variation_title,other_info']);
46 $products = ProductFilter::fromRequest($request)->paginate();
47
48 // Attach the resolved variation_display_title to each variation (batched,
49 // no N+1) so the admin order product picker matches the order item display.
50 AttributeHelper::attachVariationDisplayTitles($products->getCollection());
51
52 $products->setCollection(
53 $products->getCollection()->transform(function ($product) {
54 return $product->setAppends(['view_url', 'edit_url']);
55 })
56 );
57
58 $products = apply_filters('fluent_cart/products_list', $products);
59
60 return $this->sendSuccess([
61 'products' => $products
62 ]);
63 }
64
65 public function find(Request $request, Product $product): array
66 {
67 if ($request->get('with')) {
68 $product->load($request->get('with'));
69 }
70 $data = [
71 'product' => $product,
72 ];
73
74 if (in_array('product_menu', $request->get('with', []))) {
75 $data['product_menu'] = AdminHelper::getProductMenu($product);
76 }
77
78 return $data;
79 }
80
81 public function getRelatedProducts(Request $request, $productId): WP_REST_Response
82 {
83 $productId = absint($productId);
84
85 if (!$productId) {
86 return $this->sendError('Invalid product ID');
87 }
88
89 $relatedBy = [];
90
91 if (filter_var($request->get('related_by_categories'), FILTER_VALIDATE_BOOLEAN)) {
92 $relatedBy[] = 'product-categories';
93 }
94
95 if (filter_var($request->get('related_by_brands'), FILTER_VALIDATE_BOOLEAN)) {
96 $relatedBy[] = 'product-brands';
97 }
98
99 $orderBy = sanitize_text_field($request->get('order_by', 'title_asc'));
100 $postsPerPage = absint($request->get('posts_per_page', 6));
101
102 $products = ShopResource::getSimilarProducts($productId, true, [
103 'related_by' => $relatedBy,
104 'order_by' => $orderBy,
105 'posts_per_page' => $postsPerPage,
106 ]);
107
108 return $this->sendSuccess([
109 'products' => $products
110 ]);
111 }
112
113
114 /**
115 *
116 * @param ProductRequest $request
117 * @return WP_REST_Response
118 */
119 public function create(ProductCreateRequest $request): WP_REST_Response
120 {
121
122 $data = $request->getSafe($request->sanitize());
123
124
125 $postData = array_filter(Arr::only($data, [
126 'post_title',
127 'post_status',
128 //'detail',
129 ]));
130
131 $postData['post_name'] = sanitize_title($postData['post_title']);
132
133 $postData['post_type'] = FluentProducts::CPT_NAME;
134 $createdPostId = wp_insert_post($postData);
135
136 if (is_wp_error($createdPostId)) {
137 return $this->sendError([
138 'code' => 403,
139 'message' => $createdPostId->get_error_message()
140 ]);
141 }
142
143 $detail = Arr::get($data, 'detail');
144 $detail['post_id'] = $createdPostId;
145
146 $isDigital = Arr::get($detail, 'fulfillment_type') === 'digital';
147
148 $createdProductDetail = ProductDetail::query()->create($detail);
149
150 // Only Simple products get a default starter variant. Simple Variations
151 // and Advanced Variations are created with no variant and build their own
152 // on the edit page — Simple Variations via the pricing table's "Add
153 // Pricing" empty state, Advanced Variations via attribute combinations
154 // (a starter variant there would be an orphan the attribute UI never expects).
155 $variation = null;
156 if (Arr::get($detail, 'variation_type') === Helper::PRODUCT_TYPE_SIMPLE) {
157 $variation = ProductVariation::query()->create([
158 'post_id' => $createdPostId,
159 'serial_index' => 1,
160 'variation_title' => $postData['post_title'],
161 //'stock_status' => $isDigital ? 'in-stock' : 'out-of-stock',
162 'stock_status' => 'in-stock',
163 'payment_type' => 'onetime',
164 'total_stock' => 1,
165 'available' => 1,
166 'fulfillment_type' => $detail['fulfillment_type'],
167 'other_info' => [
168 'description' => '',
169 'payment_type' => 'onetime',
170 'tax_class' => 'standard',
171 'tax_exempt' => 'no',
172 'times' => '',
173 'repeat_interval' => '',
174 'trial_days' => '',
175 'billing_summary' => '',
176 'manage_setup_fee' => 'no',
177 'signup_fee_name' => '',
178 'signup_fee' => '',
179 'setup_fee_per_item' => 'no',
180 'is_bundle_product' => Arr::get($detail, 'other_info.is_bundle_product', 'no'),
181 ]
182 ]);
183 }
184
185 if ($createdProductDetail) {
186 return $this->sendSuccess([
187 'data' => [
188 'ID' => $createdPostId,
189 'variant' => $variation,
190 'product_details' => Arr::get($createdProductDetail, 'data'),
191 ],
192 'message' => __('Product has been created successfully', 'fluent-cart')
193 ]);
194 }
195
196 return $this->sendError(['code' => 400, 'message' => __('Product creation failed!', 'fluent-cart')]);
197
198 }
199
200 /**
201 * Bulk insert products from import/manual entry.
202 *
203 * @param Request $request
204 * @return WP_REST_Response
205 */
206 public function bulkInsert(Request $request): WP_REST_Response
207 {
208 $products = $request->get('products', []);
209
210 if (empty($products) || !is_array($products)) {
211 return $this->sendError([
212 'message' => __('No products provided', 'fluent-cart'),
213 ]);
214 }
215
216 if (count($products) > 10) {
217 return $this->sendError([
218 'message' => __('Maximum 10 products per chunk allowed', 'fluent-cart'),
219 ]);
220 }
221
222 try {
223 $service = new BulkProductInsertService();
224 $result = $service->insertChunk($products);
225
226 if (empty($result['created']) && !empty($result['errors'])) {
227 return $this->sendError([
228 'message' => __('All products failed to insert', 'fluent-cart'),
229 'errors' => $result['errors'],
230 ]);
231 }
232
233 return $this->sendSuccess([
234 'message' => sprintf(
235 __('%d product(s) created successfully', 'fluent-cart'),
236 count($result['created'])
237 ),
238 'created' => $result['created'],
239 'errors' => $result['errors'],
240 ]);
241 } catch (\Throwable $e) {
242 return $this->sendError([
243 'message' => __('Bulk insert failed: ', 'fluent-cart') . $e->getMessage(),
244 ]);
245 }
246 }
247
248 /**
249 * Duplicate a product with selected options
250 *
251 * @param Request $request
252 * @param int $productId
253 * @return WP_REST_Response
254 */
255 public function duplicate(Request $request, $productId): WP_REST_Response
256 {
257 try {
258 $data = $request->getSafe([
259 'import_stock_management' => 'sanitize_text_field',
260 'import_license_settings' => 'sanitize_text_field',
261 'import_downloadable_files' => 'sanitize_text_field',
262 ]);
263
264 $importStockManagement = filter_var(
265 Arr::get($data, 'import_stock_management', false),
266 FILTER_VALIDATE_BOOLEAN
267 );
268 $importLicenseSettings = filter_var(
269 Arr::get($data, 'import_license_settings', false),
270 FILTER_VALIDATE_BOOLEAN
271 );
272 $importDownloadableFiles = filter_var(
273 Arr::get($data, 'import_downloadable_files', false),
274 FILTER_VALIDATE_BOOLEAN
275 );
276
277 try {
278 $newProductId = Product::duplicateProduct($productId, [
279 'import_stock_management' => $importStockManagement,
280 'import_license_settings' => $importLicenseSettings,
281 'import_downloadable_files' => $importDownloadableFiles,
282 ]);
283
284 return $this->sendSuccess([
285 'product_id' => $newProductId,
286 'message' => __('Product duplicated successfully. The new product has been saved as a draft.', 'fluent-cart')
287 ]);
288
289 } catch (\RuntimeException $e) {
290 if ((int)$e->getCode() === 404) {
291 return $this->sendError([
292 'message' => __('Product not found', 'fluent-cart')
293 ]);
294 }
295 return $this->sendError([
296 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
297 ]);
298 } catch (\Exception $e) {
299 return $this->sendError([
300 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
301 ]);
302 }
303
304 } catch (\Exception $e) {
305 return $this->sendError([
306 'message' => __('An error occurred while duplicating the product.', 'fluent-cart'),
307 'error' => $e->getMessage()
308 ]);
309 }
310 }
311
312 public function delete(Request $request, Product $product)
313 {
314
315 $isDeleted = ProductResource::delete($product->ID);
316
317 if (is_wp_error($isDeleted)) {
318 return $isDeleted;
319 }
320 return $this->response->sendSuccess($isDeleted);
321 }
322
323 public function update(ProductUpdateRequest $request, $postId)
324 {
325 $data = $request->getSafe($request->sanitize());
326
327 if (
328 Arr::get($data, 'detail.variation_type') === 'simple' &&
329 (empty(Arr::get($data, 'variants')) || empty(Arr::get($data, 'variants.0')))
330 ) {
331 return $this->sendError(
332 [
333 'message' => __('Variation info is not present', 'fluent-cart')
334 ]
335 );
336 }
337
338 // $hasError = ProductResource::validateDownloadableFiles($data);
339 // if (!empty($hasError)) {
340 // return $this->sendError($hasError);
341 // }
342
343 $isUpdated = ProductResource::update($data, $postId);
344
345
346 if (is_wp_error($isUpdated)) {
347 return $isUpdated;
348 }
349
350 do_action('fluent_cart/product_updated', [
351 'data' => $data,
352 'product' => $isUpdated['data']
353 ]);
354
355 return $this->response->sendSuccess($isUpdated);
356 }
357
358 public function updateLongDescEditorMode(Request $request, $postId)
359 {
360 // Validate input
361 $activeEditor = sanitize_text_field($request->get('active_editor'));
362
363 // Fetch product detail directly by post_id
364 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
365
366 if (!$productDetail) {
367 return $this->sendError([
368 'message' => __('Product not found', 'fluent-cart')
369 ]);
370 }
371
372 $otherInfo = $productDetail->other_info;
373 $otherInfo['active_editor'] = $activeEditor;
374
375 // Update product detail
376 $isUpdated = $productDetail->update([
377 'other_info' => $otherInfo
378 ]);
379
380 if (!$isUpdated) {
381 return $this->sendError([
382 'message' => __('Failed to update editor mode', 'fluent-cart')
383 ]);
384 }
385
386 return $this->sendSuccess([
387 'message' => __('Editor mode updated successfully', 'fluent-cart')
388 ]);
389 }
390
391
392 public function updateTaxClass(Request $request, $postId)
393 {
394 $taxClassId = sanitize_text_field(Arr::get($this->request->all(), 'tax_class', 0));
395
396 $taxClass = TaxClass::query()->findOrFail($taxClassId);
397
398 if (empty($taxClass)) {
399 return $this->sendError([
400 'message' => __('Tax Class not found', 'fluent-cart')
401 ]);
402 }
403
404 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
405
406 if (empty($productDetail)) {
407 return $this->sendError([
408 'message' => __('Product not found', 'fluent-cart')
409 ]);
410 }
411
412 // Get existing other_info and merge with new tax_class
413 $otherInfo = $productDetail->other_info;
414 $otherInfo['tax_class'] = $taxClass->id;
415
416 // update only tax_class inside the $productDetails->other_info
417 $productDetail->update([
418 'other_info' => $otherInfo
419 ]);
420
421 return $this->sendSuccess([
422 'message' => __('Tax profile updated successfully', 'fluent-cart')
423 ]);
424 }
425
426 public function removeTaxClass(Request $request, $postId)
427 {
428 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
429 if (empty($productDetail)) {
430 return $this->sendError([
431 'message' => __('Product not found', 'fluent-cart')
432 ]);
433 }
434 $otherInfo = $productDetail->other_info;
435 $otherInfo['tax_class'] = '';
436 $productDetail->update([
437 'other_info' => $otherInfo
438 ]);
439 return $this->sendSuccess([
440 'message' => __('Tax profile removed successfully', 'fluent-cart')
441 ]);
442 }
443
444 public function toggleTaxExempt(Request $request, $postId)
445 {
446 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
447 if (empty($productDetail)) {
448 return $this->sendError([
449 'message' => __('Product not found', 'fluent-cart')
450 ]);
451 }
452
453 $otherInfo = $productDetail->other_info ?: [];
454 $taxExempt = sanitize_text_field($request->get('tax_exempt', 'no'));
455 $existingTaxClass = Arr::get($otherInfo, 'tax_class');
456 // Product-level tax settings live on product detail, so convert the UI
457 // slug back to the stored tax-class ID before persisting the change.
458 $taxClassSlug = sanitize_text_field($request->get('tax_class', ''));
459 $otherInfo['tax_exempt'] = $taxExempt === 'yes' ? 'yes' : 'no';
460
461 if (!$taxClassSlug) {
462 $defaultClass = TaxClass::query()->where('slug', 'standard')->first();
463 $resolvedTaxClassId = $existingTaxClass ?: ($defaultClass ? $defaultClass->id : null);
464 if ($resolvedTaxClassId) {
465 $resolvedTaxClass = TaxClass::query()->find($resolvedTaxClassId);
466 $taxClassSlug = $resolvedTaxClass ? $resolvedTaxClass->slug : 'standard';
467 } else {
468 $taxClassSlug = 'standard';
469 }
470 } else {
471 $taxClass = TaxClass::query()->where('slug', $taxClassSlug)->first();
472 if (!$taxClass) {
473 return $this->sendError([
474 'message' => __('Invalid tax class', 'fluent-cart')
475 ], 422);
476 }
477 $resolvedTaxClassId = $taxClass->id;
478 }
479
480 $otherInfo['tax_class'] = $resolvedTaxClassId;
481
482 $productDetail->update([
483 'other_info' => $otherInfo
484 ]);
485
486 return $this->sendSuccess([
487 'message' => $taxExempt === 'yes'
488 ? __('Product is now tax exempt', 'fluent-cart')
489 : __('Tax will be charged on this product', 'fluent-cart'),
490 'tax_exempt' => $otherInfo['tax_exempt'],
491 'tax_class' => $otherInfo['tax_class'],
492 'tax_class_slug' => $taxClassSlug ?: 'standard'
493 ]);
494 }
495
496 public function updateShippingClass(Request $request, $postId)
497 {
498 $shippingClassId = sanitize_text_field(Arr::get($this->request->all(), 'shipping_class', 0));
499 $shippingClass = ShippingClass::query()->findOrFail($shippingClassId);
500
501 if (empty($shippingClass)) {
502 return $this->sendError([
503 'message' => __('Shipping Class not found', 'fluent-cart')
504 ]);
505 }
506
507 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
508 if (empty($productDetail)) {
509 return $this->sendError([
510 'message' => __('Product not found', 'fluent-cart')
511 ]);
512 }
513 $otherInfo = $productDetail->other_info;
514 $otherInfo['shipping_class'] = $shippingClass->id;
515 $productDetail->update([
516 'other_info' => $otherInfo
517 ]);
518 return $this->sendSuccess([
519 'message' => __('Shipping Class updated successfully', 'fluent-cart')
520 ]);
521 }
522
523 public function removeShippingClass(Request $request, $postId)
524 {
525 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
526 if (empty($productDetail)) {
527 return $this->sendError([
528 'message' => __('Product not found', 'fluent-cart')
529 ]);
530 }
531
532 $otherInfo = $productDetail->other_info;
533 $otherInfo['shipping_class'] = '';
534 $productDetail->update([
535 'other_info' => $otherInfo
536 ]);
537
538 return $this->sendSuccess([
539 'message' => __('Shipping Class removed successfully', 'fluent-cart')
540 ]);
541 }
542
543 /**
544 *
545 * @param Request $request
546 * @param $productId
547 * @return WP_REST_Response
548 */
549 public function get(Request $request, $productId)
550 {
551 // attrMap joins fct_atts_relations, populated by the Advanced Variation feature.
552 $variantRelations = ['media', 'attrMap'];
553
554 $product = Product::with([
555 'detail',
556 'variants' => function ($query) use ($variantRelations) {
557 $query->with($variantRelations)
558 ->orderBy('serial_index', 'ASC');
559 }
560 ])->with('downloadable_files')->find($productId);
561
562 if (empty($product)) {
563 return $this->entityNotFoundError(
564 __('Product not found', 'fluent-cart'),
565 __('Back to Product List', 'fluent-cart'),
566 '/products'
567 );
568 }
569
570 // $product = ProductResource::search(['id' => $productId], function (Builder $query) {
571 // return $query
572 // ->with([
573 // 'detail',
574 // 'variants' => function ($query) {
575 // $query->with(['media'])
576 // ->orderBy('serial_index', 'ASC');
577 // }
578 // ])
579 // ->with('downloadable_files');
580 // }, true)->first();
581
582
583 if (!empty($product)) {
584 $product->setAppends(['view_url', 'edit_url']);
585 $taxonomies = Taxonomy::getTaxonomies();
586 $taxonomies = Collection::make($taxonomies)
587 ->map(function ($taxonomy) use (&$product) {
588 $taxonomy_object = get_taxonomy($taxonomy);
589 $all_labels = (array)get_taxonomy_labels($taxonomy_object);
590 $filtered_labels = Arr::only($all_labels, ['singular_name', 'name']);
591 $product[$taxonomy] = Taxonomy::getTermIdsFromTerms($product->getTermByType($taxonomy)->get()->toArray());
592
593 return [
594 'name' => $taxonomy,
595 'label' => Str::headline($taxonomy),
596 'terms' => Taxonomy::getFormattedTerms($taxonomy),
597 'labels' => $filtered_labels
598 ];
599 });
600
601 if (in_array('product_menu', $request->get('with', []))) {
602 $productMenu = AdminHelper::getProductMenu($product);
603 }
604
605 $featuredImageId = get_post_thumbnail_id($product->ID);
606 $productData = $product->toArray();
607 $productData['featured_image_id'] = $featuredImageId;
608
609 $payload = apply_filters('fluent_cart/product/get_response_data', [
610 'product' => $productData,
611 'product_id' => (int) $productId,
612 'request' => $request,
613 ]);
614
615 return $this->sendSuccess([
616 'product' => Arr::get($payload, 'product', $productData),
617 'product_menu' => $productMenu ?? "",
618 'taxonomies' => $taxonomies,
619 ]);
620 } else {
621 return $this->sendError([
622 'message' => __('Something went wrong', 'fluent-cart'),
623 ]);
624 }
625 //return ProductResource::find($postId);
626 }
627
628
629 public function getUpgradeSettings($id, Request $request): WP_REST_Response
630 {
631 return $this->sendSuccess(
632 [
633 'data' => PlanUpgradeService::getUpgradeSettings($id)
634 ]
635 );
636
637 }
638
639 public function saveUpgradeSetting(UpgradePathSettingRequest $request, $id): WP_REST_Response
640 {
641 $data = ApiHelper::sanitizeTextAll(
642 $request->except('query_timestamp')
643 );
644
645 $isSaved = PlanUpgradeService::saveUpgradeSetting($data);
646
647 if ($isSaved) {
648 return $this->sendSuccess(
649 [
650 'message' => __('Settings saved successfully', 'fluent-cart')
651 ]
652 );
653 } else {
654 return $this->sendError(
655 [
656 'message' => __('Failed to save settings', 'fluent-cart')
657 ]
658 );
659 }
660
661 }
662
663 public function deleteUpgradePath($id): WP_REST_Response
664 {
665 $isDeleted = Meta::query()->where('id', $id)->delete();
666 if ($isDeleted) {
667 return $this->sendSuccess(
668 [
669 'message' => __('Path deleted successfully', 'fluent-cart')
670 ]
671 );
672 } else {
673 return $this->sendError(
674 [
675 'message' => __('Failed to delete path', 'fluent-cart')
676 ]
677 );
678 }
679 }
680
681 public function updateUpgradePath(UpgradePathSettingRequest $request, $id): WP_REST_Response
682 {
683 $data = ApiHelper::sanitizeTextAll(
684 $request->except('query_timestamp')
685 );
686
687 $isUpdated = PlanUpgradeService::updateUpgradeSetting($id, $data);
688
689 if ($isUpdated) {
690 return $this->sendSuccess(
691 [
692 'message' => __('Settings updated successfully', 'fluent-cart')
693 ]
694 );
695 } else {
696 return $this->sendError(
697 [
698 'message' => __('Failed to update settings', 'fluent-cart')
699 ]
700 );
701 }
702
703 }
704
705 public function getUpgradePaths($variationId, Request $request)
706 {
707 $params = $request->get('params');
708 $orderHash = Arr::get($params, 'order_hash');
709
710 if (!$variationId || !$orderHash) {
711 return [];
712 }
713
714 $upgradePaths = PlanUpgradeService::getUpgardePathsFromVariation($variationId, $orderHash);
715
716 return $this->sendSuccess([
717 'upgradePaths' => $upgradePaths
718 ]);
719 }
720
721 public function getPricingWidgets(Request $request, $productId)
722 {
723 $thisMonthKey = '-' . gmdate('d') . ' days';
724 $ranges = [
725 'all_time' => __('All time', 'fluent-cart'),
726 '-30 days' => __('Last 30 days', 'fluent-cart'),
727 $thisMonthKey => __('This month', 'fluent-cart'),
728 ];
729
730 $stats = ProductReport::getStatByProductIds([$productId], array_keys($ranges));
731
732 $html = '<ul class="fct-lists">';
733
734 foreach ($ranges as $rangeSlug => $range) {
735 $stat = $stats[$rangeSlug];
736 $prefix = '';
737 if ($stat->total_quantity) {
738 $prefix = ' <b title="Quantity">(' . $stat->total_quantity . ')</b> ';
739 }
740 $html .= '<li> <span>' . $range . $prefix . '</span>' . '<span>' . Helper::toDecimal($stat->total_amount, true) . '</span>';
741 }
742
743 $html .= '</ul>';
744
745 $widgets = [
746 [
747 'title' => __('Quick Sales Overview', 'fluent-cart'),
748 'body' => $html,
749 ],
750 ];
751
752 return [
753 'widgets' => $widgets,
754 ];
755 }
756
757 public function updateProductDetail(Request $request, $id)
758 {
759 $data = $request->getSafe([
760 'variation_type' => 'sanitize_key',
761 'variation_ids.*' => 'intval',
762 'action' => 'sanitize_key'
763 ]);
764
765 $isUpdated = ProductDetailResource::update(
766 $data,
767 $id,
768 ['action' => Arr::get($data, 'action', 'change_variation_type')]
769 );
770
771 if (is_wp_error($isUpdated)) {
772 return $isUpdated;
773 }
774 return $this->response->sendSuccess($isUpdated);
775 }
776
777 public function syncTaxonomyTerms(Request $request, $id)
778 {
779
780 if (empty(($request->get('terms')))) {
781 $data['taxonomy'] = sanitize_key($request->get('taxonomy'));
782 } else {
783 $data = $request->getSafe([
784 'taxonomy' => 'sanitize_key',
785 'terms.*' => 'intval',
786 ]);
787 }
788 $isUpdated = ProductResource::syncTaxonomyTerms($data, $id);
789
790 if (is_wp_error($isUpdated)) {
791 return $isUpdated;
792 }
793 return $this->response->sendSuccess($isUpdated);
794 }
795
796 public function deleteTaxonomyTerms(Request $request, $id)
797 {
798
799 $data = $request->getSafe([
800 'taxonomy' => 'sanitize_key',
801 'term' => 'intval',
802 ]);
803 $isUpdated = ProductResource::deleteTaxonomyTerms($data, $id);
804
805 if (is_wp_error($isUpdated)) {
806 return $isUpdated;
807 }
808 return $this->response->sendSuccess($isUpdated);
809 }
810
811 public function updateVariantOption(Request $request, $postId)
812 {
813 $data = $request->all();
814
815 // Cap user-supplied option groups before they reach the sync pipeline. The
816 // client UI enforces a 200-combination ceiling, but a forged POST can carry
817 // arbitrarily many entries. Trim at the controller so the filter chain and
818 // downstream Pro listeners never see unbounded input.
819 if (isset($data['options']) && is_array($data['options'])) {
820 $data['options'] = array_slice($data['options'], 0, 200);
821 }
822
823 $isSynced = ProductResource::syncVariantOption($postId, $data);
824
825 if (is_wp_error($isSynced)) {
826 return $isSynced;
827 }
828 return $this->response->sendSuccess($isSynced);
829 }
830
831
832 public function addProductTerms(Request $request)
833 {
834 $data = $request->get('term');
835 $name = Arr::get($data, 'name', '');
836 $taxonomy = Arr::get($data, 'taxonomy', '');
837 $parent = Arr::get($data, 'parent', '');
838 $name = sanitize_text_field($name);
839 $taxonomy = sanitize_text_field($taxonomy);
840 $parent = sanitize_text_field($parent);
841
842 $args = [];
843
844 if (!empty($parent)) {
845 $args['parent'] = $parent;
846 }
847
848
849 $termNames = explode(',', $name);
850 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
851
852 if (count($ids)) {
853 $this->response->json([
854 'term_ids' => $ids,
855 'names' => $termNames
856 ]);
857 } else {
858 $this->response->json([
859 'message' => __('Unable To Create Term/s', 'fluent-cart'),
860 ], 423);
861 }
862
863 }
864
865 public function getProductTermsList(): array
866 {
867 $taxonomies = Taxonomy::getTaxonomies();
868
869 $taxonomies = Collection::make($taxonomies)
870 ->map(function ($taxonomy) {
871 return [
872 'name' => $taxonomy,
873 'label' => Str::headline($taxonomy),
874 'terms' => Taxonomy::getFormattedTerms($taxonomy),
875 ];
876 });
877 return [
878 "taxonomies" => $taxonomies,
879 ];
880 }
881
882 public function getProductTermListByParent(Request $request): WP_REST_Response
883 {
884 $parentTerms = $request->get('parents');
885 $termsData = [];
886 foreach ($request->get('listeners') as $listener) {
887 $termsData[$listener] = Taxonomy::getFormattedTerms($listener, false, $parentTerms);
888 }
889 return $this->sendSuccess([
890 'data' => $termsData
891 ]);
892
893 }
894
895 public function handleBulkActions(Request $request)
896 {
897
898 $isUpdated = ProductResource::manageBulkActions($request->all());
899
900 if (is_wp_error($isUpdated)) {
901 return $isUpdated;
902 }
903 return $this->response->sendSuccess($isUpdated);
904 }
905
906 public static function getMimeGroups()
907 {
908 return apply_filters('fluent_support/mime_groups', [
909 'images' => [
910 'title' => __('Photos', 'fluent-cart'),
911 'mimes' => [
912 'image/gif',
913 'image/ief',
914 'image/jpeg',
915 'image/webp',
916 'image/pjpeg',
917 'image/ktx',
918 'image/png',
919 ],
920 ],
921 'csv' => [
922 'title' => __('CSV', 'fluent-cart'),
923 'mimes' => [
924 'application/csv',
925 'application/txt',
926 'text/csv',
927 'text/plain',
928 'text/comma-separated-values',
929 'text/anytext',
930 ],
931 ],
932 'documents' => [
933 'title' => __('PDF/Docs', 'fluent-cart'),
934 'mimes' => [
935 'application/excel',
936 'application/vnd.ms-excel',
937 'application/vnd.msexcel',
938 'application/octet-stream',
939 'application/pdf',
940 'application/msword',
941 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
942 ],
943 ],
944 'zip' => [
945 'title' => __('Zip', 'fluent-cart'),
946 'mimes' => [
947 'application/zip',
948 ],
949 ],
950 'json' => [
951 'title' => __('JSON', 'fluent-cart'),
952 'mimes' => [
953 'application/json',
954 'application/jsonml+json',
955 ],
956 ],
957 ]);
958 }
959
960 public function searchVariantByName(Request $request): array
961 {
962 $data = $request->getSafe([
963 'name' => 'sanitize_text_field',
964 'ids.*' => 'intval',
965 'search' => 'sanitize_text_field',
966 ]);
967
968 $name = Arr::get($data, 'name', '');
969 if (empty($name)) {
970 $name = Arr::get($data, 'search', '');
971 }
972 $ids = Arr::get($data, 'ids', []);
973 $productVariations = [];
974 $query = [];
975 if (!empty($name) || count($ids) > 0) {
976 $query = [
977 "ID" =>
978 [
979 "column" => "ID",
980 "operator" => "in",
981 "value" => Arr::get($data, 'ids', [])
982 ]
983 ,
984 "post_title" =>
985 [
986 "column" => "post_title",
987 "operator" => "like",
988 "value" => '%' . Arr::get($data, 'name') . '%'
989 ],
990 "post_status" =>
991 [
992 "column" => "post_status",
993 "operator" => "=",
994 "value" => 'publish'
995 ]
996 ];
997 }
998
999 $products = Product::query()
1000 ->with('variants')
1001 ->when(count($query), function (Builder $q) use ($query) {
1002 return $q->search($query, function (Builder $query) {
1003 return $query;
1004 }, true);
1005 })
1006 ->when(empty($name), function (Builder $q) {
1007 return $q->limit(10);
1008 })
1009 ->limit(20)->get()
1010 ->map(function ($product) {
1011 return [
1012 'value' => $product->ID,
1013 'label' => $product->post_title,
1014 'children' => $product->variants->map(function ($variation) use ($product) {
1015 return [
1016 'value' => $variation->id,
1017 // 'label' => $product->post_title . ' - ' . $variation->variation_title,
1018 'label' => $variation->variation_title,
1019 ];
1020 })
1021 ->toArray()
1022 ];
1023 })->toArray();
1024 return $products;
1025 }
1026
1027 public function searchProductVariantOptions(Request $request): array
1028 {
1029 $data = $request->getSafe([
1030 'include_ids.*' => 'intval',
1031 'search' => 'sanitize_text_field',
1032 'scopes.*' => 'sanitize_text_field',
1033 'subscription_status' => 'sanitize_text_field',
1034 ]);
1035
1036 $subscription_status = Arr::get($data, 'subscription_status');
1037 $search = Arr::get($data, 'search', '');
1038 $includeIds = Arr::get($data, 'include_ids', []);
1039
1040 $productsQuery = Product::query()
1041 ->whereIn('post_status', ['publish', 'private']);
1042
1043 $productsQuery->with(['detail', 'variants' => function ($query) use ($subscription_status) {
1044 if ($subscription_status === 'not_subscribable') {
1045 $query->where('payment_type', '!=', 'subscription');
1046 }
1047 }]);
1048
1049 $scopes = Arr::get($data, 'scopes', []);
1050 if ($scopes) {
1051 $productsQuery = $productsQuery->scopes($scopes);
1052 }
1053
1054 if ($search) {
1055 $productsQuery->where(function ($query) use ($search, $subscription_status) {
1056 $query->where('post_title', 'like', '%' . $search . '%')
1057 ->orWhereHas('variants', function ($query) use ($search, $subscription_status) {
1058 $query->where('variation_title', 'like', "%$search%");
1059 if ($subscription_status === 'not_subscribable') {
1060 $query->where('payment_type', '!=', 'subscription');
1061 }
1062 });
1063 });
1064 }
1065
1066 $productsQuery->limit(20);
1067
1068 $products = $productsQuery->get();
1069
1070 $pushedVariationIds = [];
1071 $formattedProducts = [];
1072
1073 foreach ($products as $product) {
1074 $detail = $product->detail;
1075 if ($detail && $detail->manage_stock && $detail->stock_availability !== Helper::IN_STOCK) {
1076 continue;
1077 }
1078
1079 $formatted = [
1080 'value' => 'product_' . $product->ID,
1081 'label' => $product->post_title,
1082 ];
1083
1084 $variants = $product->variants;
1085
1086 $children = [];
1087 foreach ($variants as $variant) {
1088 if ($variant->manage_stock && $variant->stock_status !== Helper::IN_STOCK) {
1089 continue;
1090 }
1091 $pushedVariationIds[] = $variant->id;
1092 $children[] = [
1093 'value' => $variant->id,
1094 'label' => $variant->variation_title,
1095 ];
1096 }
1097
1098 if (!$children) {
1099 continue;
1100 }
1101
1102 $formatted['children'] = $children;
1103 $formattedProducts[$product->ID] = $formatted;
1104 }
1105
1106 $leftVariationIds = array_diff($includeIds, $pushedVariationIds);
1107
1108 if ($leftVariationIds) {
1109 $leftVariants = ProductVariation::query()
1110 ->whereIn('id', $leftVariationIds)
1111 ->with(['product' => function ($query) {
1112 $query->whereIn('post_status', ['publish', 'private']);
1113 }, 'product.detail'])
1114 ->get();
1115
1116 foreach ($leftVariants as $variant) {
1117 if ($subscription_status == 'not_subscribable' && $variant->payment_type === 'subscription') {
1118 continue;
1119 }
1120 if ($variant->manage_stock && $variant->stock_status !== Helper::IN_STOCK) {
1121 continue;
1122 }
1123 $product = $variant->product;
1124 if (!$product) {
1125 continue;
1126 }
1127 $detail = $product->detail;
1128 if ($detail && $detail->manage_stock && $detail->stock_availability !== Helper::IN_STOCK) {
1129 continue;
1130 }
1131 if (isset($formattedProducts[$product->ID])) {
1132 $formattedProducts[$product->ID]['children'][] = [
1133 'value' => $variant->id,
1134 'label' => $variant->variation_title,
1135 ];
1136 } else {
1137 $formattedProducts[$product->ID] = [
1138 'value' => 'product_' . $product->ID,
1139 'label' => $product->post_title,
1140 'children' => [
1141 [
1142 'value' => $variant->id,
1143 'label' => $variant->variation_title,
1144 ]
1145 ]
1146 ];
1147 }
1148 }
1149 }
1150
1151 $products = array_values($formattedProducts);
1152
1153 // sort the products by label
1154 usort($products, function ($a, $b) {
1155 return strcmp($a['label'], $b['label']);
1156 });
1157
1158 return [
1159 'products' => $products
1160 ];
1161 }
1162
1163 public function findSubscriptionVariants(Request $request)
1164 {
1165 $data = $request->getSafe([
1166 'name' => 'sanitize_text_field',
1167 ]);
1168
1169 $search = Arr::get($data, 'name', '');
1170
1171
1172 if (!empty($search)) {
1173 $query['variation_title'] = [
1174 'column' => 'variation_title',
1175 'operator' => 'like',
1176 'value' => '%' . $search . '%',
1177 ];
1178 }
1179
1180 $variants = ProductVariation::query()
1181 ->when(!empty($search), function (Builder $query) use ($search) {
1182 $query->where('variation_title', 'like', '%' . $search . '%');
1183 })
1184 ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(other_info, '$.payment_type')) = ?", ['subscription'])
1185 ->get()
1186 ->map(function ($variation) {
1187 return [
1188 'id' => $variation->id,
1189 'title' => $variation->variation_title,
1190 ];
1191 })
1192 ->toArray();
1193
1194 return $variants;
1195
1196
1197 }
1198
1199
1200 public function fetchVariationsByIds(Request $request): array
1201 {
1202 $ids = $request->getSafe(['productIds.*' => 'intval']);
1203 $ids = is_array($ids) ? $ids : [];
1204 if (empty($ids)) {
1205 return ['products' => []];
1206 }
1207 $products = ProductVariationResource::search(["id" => ["column" => "id", "operator" => "in", "value" => is_array($ids) ? $ids : [],]], function (Builder $query) {
1208 return $query;
1209 }, true)->pluck('variation_title', 'id')->map(function ($name, $id) {
1210 return [
1211 'value' => $id,
1212 'label' => $name,
1213 ];
1214 })->values()->toArray();
1215 return ['products' => $products];
1216 }
1217
1218 public function searchProductByName(Request $request): array
1219 {
1220 $searchValue = $request->getSafe('name', 'sanitize_text_field');
1221 $urlMode = $request->getSafe('url_mode', 'sanitize_text_field');
1222 $termId = $request->getSafe('termId', 'intval');
1223
1224 $defaultFilters =
1225 [
1226 "wildcard" => $searchValue,
1227 ];
1228
1229 $status = ["post_status" => ["column" => "post_status", "operator" => "in", "value" => ["publish"]]];
1230
1231 $params = [
1232 "select" => ['id AS ID', 'post_title'],
1233 "with" => ['wpTerms'],
1234 "selected_status" => true,
1235 "status" => $status,
1236 "default_filters" => $defaultFilters,
1237 ];
1238
1239 if (!empty($termId)) {
1240 $params["taxonomy_filters"] = [
1241 'product-categories' => Arr::wrap($termId)
1242 ];
1243 }
1244
1245 $products = ShopResource::get($params);
1246 $items = $products['products'];
1247
1248 return [
1249 'products' => $items
1250 ];
1251 }
1252
1253 public function getBundleInfo(Request $request, $productId): array
1254 {
1255 $variants = ProductVariation::query()
1256 ->where('post_id', $productId)
1257 ->select([
1258 'id',
1259 'variation_title',
1260 'other_info'
1261 ])
1262 ->get()
1263 ->toArray();
1264
1265 $variants = Helper::loadBundleChild($variants);
1266
1267 return $variants;
1268 }
1269
1270 public function saveBundleInfo(Request $request, $variationId): array
1271 {
1272 $variation = ProductVariation::query()->findOrFail($variationId);
1273
1274 $childIds = $request->get('bundle_child_ids');
1275
1276 if (!empty($childIds) && is_array($childIds)) {
1277 // Reject any child variations that belong to a bundle product
1278 $bundleChildVariations = ProductVariation::whereIn('id', $childIds)
1279 ->get(['id', 'post_id']);
1280
1281 foreach ($bundleChildVariations as $childVariation) {
1282 if ($childVariation->product && $childVariation->product->isBundleProduct()) {
1283 return wp_send_json([
1284 'message' => __('A bundle product cannot be added as a bundle child.', 'fluent-cart'),
1285 ], 422);
1286 }
1287 }
1288 }
1289
1290 $otherInfo = $variation->other_info ?? [];
1291 $otherInfo['bundle_child_ids'] = $childIds;
1292 $variation->other_info = $otherInfo;
1293
1294 return [$variation->update()];
1295 }
1296
1297 public function fetchProductsByIds(Request $request): array
1298 {
1299 $ids = $request->getSafe(['productIds.*' => 'intval']);
1300
1301 $ids = Arr::get($ids, 'productIds', []);
1302 $ids = is_array($ids) ? $ids : [];
1303
1304 if (empty($ids)) {
1305 return [
1306 'products' => []
1307 ];
1308 }
1309 $products = ProductResource::search([
1310 "id" => [
1311 "column" => "id",
1312 "operator" => "in",
1313 "value" => is_array($ids) ? $ids : [],
1314 ]
1315 ], function (Builder $query) {
1316 return $query
1317 ->with('detail');
1318 }, true);
1319
1320 return [
1321 'products' => $products
1322 ];
1323 }
1324
1325 public function getMaxExcerptWordCount(): WP_REST_Response
1326 {
1327 return $this->sendSuccess([
1328 'count' => (int)apply_filters('excerpt_length', 55)
1329 ]);
1330 }
1331
1332 public function createDummyProducts(Request $request)
1333 {
1334 return DummyProductService::create($request->get('category'), $request->get('index'));
1335 }
1336
1337 public function updateInventory(Request $request, $postId, $variantId)
1338 {
1339
1340 $variant = ProductVariation::query()->find($variantId);
1341
1342 if (!$variant) {
1343 return $this->response->sendError([
1344 'message' => __('Variant not found', 'fluent-cart')
1345 ]);
1346 }
1347
1348 // Capture old stock state before update
1349 $oldAvailable = intval($variant->available);
1350 $oldStockStatus = $variant->stock_status;
1351
1352 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1353
1354 // get variations by post_id
1355 $variations = ProductVariation::query()->where('post_id', $postId)->where('id', '!=', $variantId)->get();
1356 $updateData = [];
1357 foreach ($variations as $variation) {
1358 $updateData[] = [
1359 'id' => $variation->id,
1360 'manage_stock' => 1,
1361 'total_stock' => $variation->total_stock,
1362 'available' => $variation->available,
1363 'stock_status' => $variation->stock_status
1364 ];
1365 }
1366 $newAvailable = intval($request->get('available'));
1367 $newStockStatus = $newAvailable > 0 ? 'in-stock' : 'out-of-stock';
1368
1369 $updateData[] = [
1370 'id' => $variantId,
1371 'total_stock' => sanitize_text_field($request->get('total_stock')),
1372 'available' => $newAvailable,
1373 'manage_stock' => 1,
1374 'stock_status' => $newStockStatus
1375 ];
1376 // update variations
1377 $isUpdated = ProductVariation::query()->batchUpdate($updateData);
1378
1379
1380 if ($detail) {
1381 $hasAvailableStock = ProductVariation::query()->where('post_id', $postId)->where('available', '>', 0)->exists();
1382 $detail->stock_availability = $hasAvailableStock ? 'in-stock' : 'out-of-stock';
1383 $detail->manage_stock = 1;
1384 $detail->save();
1385 }
1386 if (is_wp_error($isUpdated)) {
1387 return $this->response->sendError([
1388 'message' => __('Inventory update failed', 'fluent-cart')
1389 ]);
1390 }
1391
1392 // Stock persisted — fire StockChanged only if it actually changed.
1393 if ($oldAvailable !== $newAvailable || $oldStockStatus !== $newStockStatus) {
1394 (new StockChanged([$postId]))->dispatch();
1395 }
1396
1397 return $this->response->sendSuccess([
1398 'message' => __('Inventory updated successfully', 'fluent-cart')
1399 ]);
1400 }
1401
1402 public function updateManageStock(Request $request, $postId)
1403 {
1404 $manageStock = sanitize_text_field($request->get('manage_stock'));
1405
1406 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1407
1408 $updateData = [
1409 'manage_stock' => $manageStock,
1410 ];
1411 if ($manageStock == 0) {
1412 $updateData['stock_status'] = 'in-stock';
1413 }
1414
1415 $updatedVariations = ProductVariation::query()->where('post_id', $postId)->update($updateData);
1416
1417 $hasAvailableStock = ProductVariation::query()->where('post_id', $postId)->where('available', '>', 0)->exists();
1418 $detail->manage_stock = $manageStock;
1419 $detail->stock_availability = $hasAvailableStock || $manageStock == 0 ? 'in-stock' : 'out-of-stock';
1420 $updatedProductDetails = $detail->save();
1421
1422 if (is_wp_error($updatedProductDetails)) {
1423 return $this->response->sendError([
1424 'message' => __('Manage stock update failed', 'fluent-cart')
1425 ]);
1426 }
1427
1428 if (is_wp_error($updatedVariations)) {
1429 return $this->response->sendError([
1430 'message' => __('Manage stock update failed', 'fluent-cart')
1431 ]);
1432 }
1433
1434 return $this->response->sendSuccess([
1435 'message' => __('Manage stock updated successfully', 'fluent-cart')
1436 ]);
1437 }
1438
1439 /**
1440 * Suggest a unique SKU based on product title and optional variant title.
1441 *
1442 * @param Request $request
1443 * @return WP_REST_Response
1444 */
1445 public function suggestSku(Request $request)
1446 {
1447 $title = sanitize_text_field($request->get('title', ''));
1448 $variantTitle = sanitize_text_field($request->get('variant_title', ''));
1449 $excludeId = absint($request->get('exclude_id', 0));
1450
1451 if (empty($title)) {
1452 return $this->sendError([
1453 'message' => __('Product title is required to generate SKU.', 'fluent-cart')
1454 ]);
1455 }
1456
1457 $sku = $this->generateSkuFromTitle($title, $variantTitle);
1458
1459 if (empty($sku)) {
1460 return $this->sendError([
1461 'message' => __('Could not generate SKU from the given title.', 'fluent-cart')
1462 ]);
1463 }
1464
1465 $sku = $this->ensureUniqueSku($sku, $excludeId);
1466
1467 return $this->sendSuccess([
1468 'sku' => $sku,
1469 ]);
1470 }
1471
1472 /**
1473 * Generate a SKU string from a product title and optional variant title.
1474 *
1475 * @param string $title
1476 * @param string $variantTitle
1477 * @return string
1478 */
1479 private function generateSkuFromTitle($title, $variantTitle = '')
1480 {
1481 $stopWords = ['the', 'and', 'for', 'with', 'a', 'an', 'of', 'in', 'on', 'to', 'is', 'it', 'by', 'or', 'at', 'from'];
1482
1483 $fullTitle = trim($title);
1484 if (!empty($variantTitle) && strtolower(trim($variantTitle)) !== strtolower(trim($title))) {
1485 $fullTitle .= ' ' . trim($variantTitle);
1486 }
1487
1488 $cleaned = strtoupper($fullTitle);
1489 $cleaned = preg_replace('/[^A-Z0-9\s]/', '', $cleaned);
1490 $words = array_values(array_filter(explode(' ', $cleaned), function ($word) use ($stopWords) {
1491 return strlen($word) > 0 && !in_array(strtolower($word), $stopWords);
1492 }));
1493
1494 if (empty($words)) {
1495 return '';
1496 }
1497
1498 $parts = array_map(function ($word) {
1499 return substr($word, 0, 3);
1500 }, $words);
1501
1502 $base = implode('-', $parts);
1503
1504 // Keep base within 25 chars to leave room for uniqueness suffix
1505 if (strlen($base) > 25) {
1506 $base = substr($base, 0, 25);
1507 $base = rtrim($base, '-');
1508 }
1509
1510 return $base;
1511 }
1512
1513 /**
1514 * Ensure the SKU is unique in the database, appending a numeric suffix if needed.
1515 *
1516 * @param string $sku
1517 * @param int|null $excludeId
1518 * @return string
1519 */
1520 private function ensureUniqueSku($sku, $excludeId = null)
1521 {
1522 $original = $sku;
1523 $suffix = 0;
1524 $batchSize = 10;
1525
1526 while ($suffix < 100) {
1527 // Build a batch of candidate SKUs to check at once
1528 $candidates = [];
1529 for ($i = $suffix; $i < $suffix + $batchSize && $i < 100; $i++) {
1530 if ($i === 0) {
1531 $candidates[] = $original;
1532 } else {
1533 $maxBaseLen = 30 - strlen('-' . $i);
1534 $candidates[] = substr($original, 0, $maxBaseLen) . '-' . $i;
1535 }
1536 }
1537
1538 $query = ProductVariation::query()->whereIn('sku', $candidates);
1539 if ($excludeId) {
1540 $query->where('id', '!=', $excludeId);
1541 }
1542 $taken = $query->get()->pluck('sku')->toArray();
1543
1544 // Return the first candidate that isn't taken
1545 foreach ($candidates as $candidate) {
1546 if (!in_array($candidate, $taken)) {
1547 return $candidate;
1548 }
1549 }
1550
1551 $suffix += $batchSize;
1552 }
1553
1554 // All candidates exhausted — fallback to timestamp-based suffix
1555 $maxBaseLen = 30 - 1 - 10; // hyphen + 10-digit timestamp
1556 return substr($original, 0, $maxBaseLen) . '-' . substr(time(), -10);
1557 }
1558
1559 /**
1560 * Fetch products formatted for bulk editing.
1561 */
1562 public function bulkEditFetch(Request $request): WP_REST_Response
1563 {
1564 try {
1565 $service = new BulkProductUpdateService();
1566 $result = $service->fetchForBulkEdit($request);
1567
1568 return $this->sendSuccess($result);
1569 } catch (\Throwable $e) {
1570 return $this->sendError([
1571 'message' => __('Failed to fetch products: ', 'fluent-cart') . $e->getMessage(),
1572 ]);
1573 }
1574 }
1575
1576 /**
1577 * Bulk update products from the bulk edit spreadsheet.
1578 */
1579 public function bulkUpdate(Request $request): WP_REST_Response
1580 {
1581 $products = $request->get('products', []);
1582
1583 if (empty($products) || !is_array($products)) {
1584 return $this->sendError([
1585 'message' => __('No products provided', 'fluent-cart'),
1586 ]);
1587 }
1588
1589 if (count($products) > 10) {
1590 return $this->sendError([
1591 'message' => __('Maximum 10 products per chunk allowed', 'fluent-cart'),
1592 ]);
1593 }
1594
1595 try {
1596 $service = new BulkProductUpdateService();
1597 $result = $service->updateChunk($products);
1598
1599 if (empty($result['updated']) && !empty($result['errors'])) {
1600 return $this->sendError([
1601 'message' => __('All products failed to update', 'fluent-cart'),
1602 'errors' => $result['errors'],
1603 ]);
1604 }
1605
1606 return $this->sendSuccess([
1607 'message' => sprintf(
1608 __('%d product(s) updated successfully', 'fluent-cart'),
1609 count($result['updated'])
1610 ),
1611 'updated' => $result['updated'],
1612 'errors' => $result['errors'],
1613 ]);
1614 } catch (\Throwable $e) {
1615 return $this->sendError([
1616 'message' => __('Bulk update failed: ', 'fluent-cart') . $e->getMessage(),
1617 ]);
1618 }
1619 }
1620 }
1621