PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 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 All 50 releases
← All changes | app/Http/Controllers/ProductController.php +412 -86 1.3.20 → 1.7.0 View file →
@@ -1,11 +1,13 @@
1 1 <?php
2 2
3 3 namespace FluentCart\App\Http\Controllers;
4 4
5 +use FluentCart\Api\ModuleSettings;
5 6 use FluentCart\Api\Resource\ProductDetailResource;
6 7 use FluentCart\Api\Resource\ProductResource;
7 8 use FluentCart\Api\Resource\ProductVariationResource;
9 +use FluentCart\App\Events\StockChanged;
8 10 use FluentCart\Api\Resource\ShopResource;
9 11 use FluentCart\Api\Taxonomy;
10 12 use FluentCart\App\CPT\FluentProducts;
11 13 use FluentCart\App\Helpers\AdminHelper;
@@ -23,11 +25,13 @@
23 25 use FluentCart\App\Models\ShippingClass;
24 26 use FluentCart\App\Models\TaxClass;
25 27 use FluentCart\App\Modules\ReportingModule\ProductReport;
26 28 use FluentCart\App\Services\Async\DummyProductService;
29 +use FluentCart\App\Helpers\AttributeHelper;
27 30 use FluentCart\App\Services\BulkProductInsertService;
28 31 use FluentCart\App\Services\BulkProductUpdateService;
29 32 use FluentCart\App\Services\Filter\ProductFilter;
33 +use FluentCart\App\Services\Permission\PermissionManager;
30 34 use FluentCart\App\Services\PlanUpgradeService;
31 35 use FluentCart\Framework\Database\Orm\Builder;
32 36 use FluentCart\Framework\Http\Request\Request;
33 37 use FluentCart\Framework\Support\Arr;
@@ -42,11 +46,33 @@
42 46 {
43 47 //$request->set('with', ['detail', 'variants:post_id,available,manage_stock,stock_status,variation_title,other_info']);
44 48 $products = ProductFilter::fromRequest($request)->paginate();
45 49
50 + // Attach the resolved variation_display_title to each variation (batched,
51 + // no N+1) so the admin order product picker matches the order item display.
52 + AttributeHelper::attachVariationDisplayTitles($products->getCollection());
53 +
54 + $collection = $products->getCollection();
55 +
46 56 $products->setCollection(
47 - $products->getCollection()->transform(function ($product) {
48 - return $product->setAppends(['view_url', 'edit_url']);
57 + $collection->transform(function ($product) {
58 + $product->setAppends(['view_url', 'edit_url']);
59 +
60 + // Source rating from canonical detail.other_info (maintained by recalculateProductRatings).
61 + // Use array_key_exists to avoid overwriting valid data with fallback-to-zero.
62 + if ($product->detail) {
63 + $otherInfo = $product->detail->other_info ?? [];
64 +
65 + if (array_key_exists('average_rating', $otherInfo)) {
66 + $product->avg_rating = (float) $otherInfo['average_rating'];
67 + }
68 +
69 + if (array_key_exists('review_count', $otherInfo)) {
70 + $product->reviews_count = (int) $otherInfo['review_count'];
71 + }
72 + }
73 +
74 + return $product;
49 75 })
50 76 );
51 77
52 78 $products = apply_filters('fluent_cart/products_list', $products);
@@ -57,10 +83,12 @@
57 83 }
58 84
59 85 public function find(Request $request, Product $product): array
60 86 {
61 - if ($request->get('with')) {
62 - $product->load($request->get('with'));
87 + $with = $this->resolveEagerLoads($request->get('with', []));
88 +
89 + if ($with) {
90 + $product->load($with);
63 91 }
64 92 $data = [
65 93 'product' => $product,
66 94 ];
@@ -71,14 +99,178 @@
71 99
72 100 return $data;
73 101 }
74 102
103 + /**
104 + * What the `with` parameter on `GET products/{id}` may eager-load.
105 + *
106 + * ## The entry form
107 + *
108 + * Every entry is a LITERAL request key mapped to a CALLABLE. The key is never
109 + * decomposed, prefix-matched or suffix-stripped, so what the client sends is
110 + * either a key in this map or it is dropped. That is what keeps the dotted
111 + * `orderItems.order.customer`, the column-select
112 + * `orderItems.order.customer:id,email` and the nested array
113 + * `with[orderItems][]=order.customer` out — none of them is a key.
114 + *
115 + * The callback owns the whole path AND its own permission bar, and returns the
116 + * relation paths to eager-load, or an empty array when it refuses.
117 + *
118 + * ## Two tiers of key
119 + *
120 + * A SCREEN key names a calling screen and loads exactly what that screen
121 + * renders. A PUBLIC key is a plain relation name an external consumer of a
122 + * product endpoint can reasonably ask for.
123 + *
124 + * ## What stays off the map
125 + *
126 + * `Product::orderItems()` is a real relation keyed on `post_id`, so before the
127 + * request value was constrained an actor holding nothing but products/view
128 + * could walk `?with[]=orderItems.order.customer` from a catalogue product to
129 + * order and customer data — a probe pulled 104 KB of it off one product. It
130 + * must stay unreachable, along with `downloadable_files` (protected file
131 + * paths), `licensesMeta`, `postmeta` and `wpTerms`.
132 + *
133 + * `product_menu` is NOT on this map and does not belong on it: it is a
134 + * controller sentinel, not a relation. find() reads it straight off the raw
135 + * request and answers it with AdminHelper::getProductMenu(); it never reaches
136 + * load(), so it is unaffected by anything here.
137 + *
138 + * No entry declares a select. `Product::$appends` carries `thumbnail`, which
139 + * resolves through `detail->featured_media` — itself a ProductDetail append
140 + * backed by the `galleryImage` relation — and `ProductVariation::$appends`
141 + * lazy-loads `media` keyed on the variant `id`. A select would have to go
142 + * INSIDE the relation closure in any case; on the main query it would narrow
143 + * the product row itself.
144 + *
145 + * @return array<string, callable>
146 + */
147 + private function allowedWiths(): array
148 + {
149 + return [
150 + 'block_product_detail' => [$this, 'blockProductDetail'],
151 +
152 + // The public entry points. These are the two relations an external
153 + // consumer can reasonably ask a product endpoint for: the catalogue
154 + // detail row and the variation rows. Both are catalogue data already
155 + // covered by the route's own products/view, and neither chains toward
156 + // orders, customers or protected downloads, so they carry no risk the
157 + // route does not already carry.
158 + //
159 + // The screen key above exists because the block editors want both in
160 + // one request; these two give either one on its own to a consumer
161 + // that is not that screen.
162 + 'detail' => [$this, 'publicDetail'],
163 + 'variants' => [$this, 'publicVariants'],
164 + ];
165 + }
166 +
167 + /**
168 + * The Gutenberg block editors' single-product fetch. Fourteen block editors
169 + * under `resources/admin/BlockEditor/` hit this endpoint — BuySection,
170 + * Excerpt, MediaCarousel, PriceRange, ProductCard, ProductDescription,
171 + * ProductGallery, ProductImage, ProductInfo, ProductSku, ProductTitle,
172 + * RelatedProduct, SaleBadge and Stock — and between them they render the
173 + * detail row (price range, stock availability, gallery) and the variation
174 + * rows (SKU, per-variant price, buy section), so the key loads both.
175 + *
176 + * `products/view` is the route's own bar, restated here so the entry still
177 + * refuses if this map is ever reached from somewhere the route did not guard.
178 + *
179 + * @return array relation paths
180 + */
181 + private function blockProductDetail(): array
182 + {
183 + if (!PermissionManager::hasPermission('products/view')) {
184 + return [];
185 + }
186 +
187 + return ['detail', 'variants'];
188 + }
189 +
190 + /**
191 + * The catalogue detail row on its own — price range, stock availability,
192 + * variation type, featured media.
193 + *
194 + * @return array relation paths
195 + */
196 + private function publicDetail(): array
197 + {
198 + if (!PermissionManager::hasPermission('products/view')) {
199 + return [];
200 + }
201 +
202 + return ['detail'];
203 + }
204 +
205 + /**
206 + * The variation rows on their own — SKU, per-variant price, stock.
207 + *
208 + * @return array relation paths
209 + */
210 + private function publicVariants(): array
211 + {
212 + if (!PermissionManager::hasPermission('products/view')) {
213 + return [];
214 + }
215 +
216 + return ['variants'];
217 + }
218 +
219 + /**
220 + * Reduce a client-supplied `with` payload to the relation paths this endpoint
221 + * is allowed to eager-load.
222 + *
223 + * Anything that is not a literal key of allowedWiths() is dropped SILENTLY —
224 + * an unknown relation otherwise reaches Builder::getRelation() and becomes a
225 + * RelationNotFoundException, i.e. a 500, where a stale block build should
226 + * simply render without its data.
227 + *
228 + * Only STRING request entries are considered, which is what drops the nested
229 + * array shape `with[orderItems][]=order.customer`: its value is an array and
230 + * its key is never read.
231 + *
232 + * Kept local to this controller rather than folded into
233 + * `Services/Filter/BaseFilter::allowedWiths()`: that map adopts a Builder
234 + * returned by each callback, while this endpoint eager-loads onto a
235 + * route-model-bound instance, and the two maps share no entry.
236 + *
237 + * @param mixed $with raw request value
238 + * @return array relation names safe to pass to Product::load()
239 + */
240 + private function resolveEagerLoads($with): array
241 + {
242 + $map = $this->allowedWiths();
243 +
244 + $resolved = [];
245 +
246 + foreach (Arr::wrap($with) as $requestKey) {
247 + if (!is_string($requestKey) || !array_key_exists($requestKey, $map)) {
248 + continue;
249 + }
250 +
251 + $entry = $map[$requestKey];
252 +
253 + if (!is_callable($entry)) {
254 + continue;
255 + }
256 +
257 + foreach ((array) $entry() as $relation) {
258 + if (is_string($relation) && $relation !== '') {
259 + $resolved[$relation] = true;
260 + }
261 + }
262 + }
263 +
264 + return array_keys($resolved);
265 + }
266 +
75 267 public function getRelatedProducts(Request $request, $productId): WP_REST_Response
76 268 {
77 269 $productId = absint($productId);
78 270
79 271 if (!$productId) {
80 - return $this->sendError('Invalid product ID');
272 + return $this->sendError(__('Invalid product ID', 'fluent-cart'));
81 273 }
82 274
83 275 $relatedBy = [];
84 276
@@ -139,33 +331,44 @@
139 331
140 332 $isDigital = Arr::get($detail, 'fulfillment_type') === 'digital';
141 333
142 334 $createdProductDetail = ProductDetail::query()->create($detail);
143 - $variation = ProductVariation::query()->create([
144 - 'post_id' => $createdPostId,
145 - 'serial_index' => 1,
146 - 'variation_title' => $postData['post_title'],
147 - //'stock_status' => $isDigital ? 'in-stock' : 'out-of-stock',
148 - 'stock_status' => 'in-stock',
149 - 'payment_type' => 'onetime',
150 - 'total_stock' => 1,
151 - 'available' => 1,
152 - 'fulfillment_type' => $detail['fulfillment_type'],
153 - 'other_info' => [
154 - 'description' => '',
155 - 'payment_type' => 'onetime',
156 - 'times' => '',
157 - 'repeat_interval' => '',
158 - 'trial_days' => '',
159 - 'billing_summary' => '',
160 - 'manage_setup_fee' => 'no',
161 - 'signup_fee_name' => '',
162 - 'signup_fee' => '',
163 - 'setup_fee_per_item' => 'no',
164 - 'is_bundle_product' => Arr::get($detail, 'other_info.is_bundle_product', 'no'),
165 - ]
166 - ]);
167 335
336 + // Only Simple products get a default starter variant. Simple Variations
337 + // and Advanced Variations are created with no variant and build their own
338 + // on the edit page — Simple Variations via the pricing table's "Add
339 + // Pricing" empty state, Advanced Variations via attribute combinations
340 + // (a starter variant there would be an orphan the attribute UI never expects).
341 + $variation = null;
342 + if (Arr::get($detail, 'variation_type') === Helper::PRODUCT_TYPE_SIMPLE) {
343 + $variation = ProductVariation::query()->create([
344 + 'post_id' => $createdPostId,
345 + 'serial_index' => 1,
346 + 'variation_title' => $postData['post_title'],
347 + //'stock_status' => $isDigital ? 'in-stock' : 'out-of-stock',
348 + 'stock_status' => 'in-stock',
349 + 'payment_type' => 'onetime',
350 + 'total_stock' => 1,
351 + 'available' => 1,
352 + 'fulfillment_type' => $detail['fulfillment_type'],
353 + 'other_info' => [
354 + 'description' => '',
355 + 'payment_type' => 'onetime',
356 + 'tax_class' => 'standard',
357 + 'tax_exempt' => 'no',
358 + 'times' => '',
359 + 'repeat_interval' => '',
360 + 'trial_days' => '',
361 + 'billing_summary' => '',
362 + 'manage_setup_fee' => 'no',
363 + 'signup_fee_name' => '',
364 + 'signup_fee' => '',
365 + 'setup_fee_per_item' => 'no',
366 + 'is_bundle_product' => Arr::get($detail, 'other_info.is_bundle_product', 'no'),
367 + ]
368 + ]);
369 + }
370 +
168 371 if ($createdProductDetail) {
169 372 return $this->sendSuccess([
170 373 'data' => [
171 374 'ID' => $createdPostId,
@@ -272,9 +475,9 @@
272 475 } catch (\RuntimeException $e) {
273 476 if ((int)$e->getCode() === 404) {
274 477 return $this->sendError([
275 478 'message' => __('Product not found', 'fluent-cart')
276 - ]);
479 + ], 404);
277 480 }
278 481 return $this->sendError([
279 482 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
280 483 ]);
@@ -306,8 +509,15 @@
306 509 public function update(ProductUpdateRequest $request, $postId)
307 510 {
308 511 $data = $request->getSafe($request->sanitize());
309 512
513 + $isPartialUpdate = !(isset($data['detail']) && is_array($data['detail'])) &&
514 + !(isset($data['variants']) && is_array($data['variants']));
515 +
516 + if ($isPartialUpdate) {
517 + return $this->applyPartialPostUpdate($data, $postId);
518 + }
519 +
310 520 if (
311 521 Arr::get($data, 'detail.variation_type') === 'simple' &&
312 522 (empty(Arr::get($data, 'variants')) || empty(Arr::get($data, 'variants.0')))
313 523 ) {
@@ -324,9 +534,8 @@
324 534 // }
325 535
326 536 $isUpdated = ProductResource::update($data, $postId);
327 537
328 -
329 538 if (is_wp_error($isUpdated)) {
330 539 return $isUpdated;
331 540 }
332 541
@@ -337,8 +546,25 @@
337 546
338 547 return $this->response->sendSuccess($isUpdated);
339 548 }
340 549
550 + private function applyPartialPostUpdate(array $data, $postId)
551 + {
552 + $result = ProductResource::partialUpdate($data, $postId);
553 +
554 + if (is_wp_error($result)) {
555 + $statusCode = $result->get_error_code() === 'not_found' ? 404 : 422;
556 + return $this->sendError(['message' => $result->get_error_message()], $statusCode);
557 + }
558 +
559 + do_action('fluent_cart/product_updated', [
560 + 'data' => $data,
561 + 'product' => $result['data'],
562 + ]);
563 +
564 + return $this->response->sendSuccess($result);
565 + }
566 +
341 567 public function updateLongDescEditorMode(Request $request, $postId)
342 568 {
343 569 // Validate input
344 570 $activeEditor = sanitize_text_field($request->get('active_editor'));
@@ -401,9 +627,9 @@
401 627 'other_info' => $otherInfo
402 628 ]);
403 629
404 630 return $this->sendSuccess([
405 - 'message' => __('Tax Class updated successfully', 'fluent-cart')
631 + 'message' => __('Tax profile updated successfully', 'fluent-cart')
406 632 ]);
407 633 }
408 634
409 635 public function removeTaxClass(Request $request, $postId)
@@ -419,12 +645,64 @@
419 645 $productDetail->update([
420 646 'other_info' => $otherInfo
421 647 ]);
422 648 return $this->sendSuccess([
423 - 'message' => __('Tax Class removed successfully', 'fluent-cart')
649 + 'message' => __('Tax profile removed successfully', 'fluent-cart')
424 650 ]);
425 651 }
426 652
653 + public function toggleTaxExempt(Request $request, $postId)
654 + {
655 + $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
656 + if (empty($productDetail)) {
657 + return $this->sendError([
658 + 'message' => __('Product not found', 'fluent-cart')
659 + ]);
660 + }
661 +
662 + $otherInfo = $productDetail->other_info ?: [];
663 + $taxExempt = sanitize_text_field($request->get('tax_exempt', 'no'));
664 + $existingTaxClass = Arr::get($otherInfo, 'tax_class');
665 + // Product-level tax settings live on product detail, so convert the UI
666 + // slug back to the stored tax-class ID before persisting the change.
667 + $taxClassSlug = sanitize_text_field($request->get('tax_class', ''));
668 + $otherInfo['tax_exempt'] = $taxExempt === 'yes' ? 'yes' : 'no';
669 +
670 + if (!$taxClassSlug) {
671 + $defaultClass = TaxClass::query()->where('slug', 'standard')->first();
672 + $resolvedTaxClassId = $existingTaxClass ?: ($defaultClass ? $defaultClass->id : null);
673 + if ($resolvedTaxClassId) {
674 + $resolvedTaxClass = TaxClass::query()->find($resolvedTaxClassId);
675 + $taxClassSlug = $resolvedTaxClass ? $resolvedTaxClass->slug : 'standard';
676 + } else {
677 + $taxClassSlug = 'standard';
678 + }
679 + } else {
680 + $taxClass = TaxClass::query()->where('slug', $taxClassSlug)->first();
681 + if (!$taxClass) {
682 + return $this->sendError([
683 + 'message' => __('Invalid tax class', 'fluent-cart')
684 + ], 422);
685 + }
686 + $resolvedTaxClassId = $taxClass->id;
687 + }
688 +
689 + $otherInfo['tax_class'] = $resolvedTaxClassId;
690 +
691 + $productDetail->update([
692 + 'other_info' => $otherInfo
693 + ]);
694 +
695 + return $this->sendSuccess([
696 + 'message' => $taxExempt === 'yes'
697 + ? __('Product is now tax exempt', 'fluent-cart')
698 + : __('Tax will be charged on this product', 'fluent-cart'),
699 + 'tax_exempt' => $otherInfo['tax_exempt'],
700 + 'tax_class' => $otherInfo['tax_class'],
701 + 'tax_class_slug' => $taxClassSlug ?: 'standard'
702 + ]);
703 + }
704 +
427 705 public function updateShippingClass(Request $request, $postId)
428 706 {
429 707 $shippingClassId = sanitize_text_field(Arr::get($this->request->all(), 'shipping_class', 0));
430 708 $shippingClass = ShippingClass::query()->findOrFail($shippingClassId);
@@ -478,12 +756,15 @@
478 756 * @return WP_REST_Response
479 757 */
480 758 public function get(Request $request, $productId)
481 759 {
760 + // attrMap joins fct_atts_relations, populated by the Advanced Variation feature.
761 + $variantRelations = ['media', 'attrMap'];
762 +
482 763 $product = Product::with([
483 764 'detail',
484 - 'variants' => function ($query) {
485 - $query->with(['media'])
765 + 'variants' => function ($query) use ($variantRelations) {
766 + $query->with($variantRelations)
486 767 ->orderBy('serial_index', 'ASC');
487 768 }
488 769 ])->with('downloadable_files')->find($productId);
489 770
@@ -532,11 +813,17 @@
532 813
533 814 $featuredImageId = get_post_thumbnail_id($product->ID);
534 815 $productData = $product->toArray();
535 816 $productData['featured_image_id'] = $featuredImageId;
536 - //get featured image id
817 +
818 + $payload = apply_filters('fluent_cart/product/get_response_data', [
819 + 'product' => $productData,
820 + 'product_id' => (int) $productId,
821 + 'request' => $request,
822 + ]);
823 +
537 824 return $this->sendSuccess([
538 - 'product' => $productData,
825 + 'product' => Arr::get($payload, 'product', $productData),
539 826 'product_menu' => $productMenu ?? "",
540 827 'taxonomies' => $taxonomies,
541 828 ]);
542 829 } else {
@@ -731,17 +1018,18 @@
731 1018 }
732 1019
733 1020 public function updateVariantOption(Request $request, $postId)
734 1021 {
1022 + $data = $request->all();
735 1023
1024 + // Cap user-supplied option groups before they reach the sync pipeline. The
1025 + // client UI enforces a 200-combination ceiling, but a forged POST can carry
1026 + // arbitrarily many entries. Trim at the controller so the filter chain and
1027 + // downstream Pro listeners never see unbounded input.
1028 + if (isset($data['options']) && is_array($data['options'])) {
1029 + $data['options'] = array_slice($data['options'], 0, 200);
1030 + }
736 1031
737 - $data = $request->all();
738 - // ProductValidator::validate($data, [
739 -// 'variation_type' => 'required',
740 -// 'product_id' => 'required',
741 -// 'options.*.id' => 'required',
742 -// 'options.*.variants' => 'required',
743 -// ]);
744 1032 $isSynced = ProductResource::syncVariantOption($postId, $data);
745 1033
746 1034 if (is_wp_error($isSynced)) {
747 1035 return $isSynced;
@@ -769,19 +1057,24 @@
769 1057
770 1058 $termNames = explode(',', $name);
771 1059 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
772 1060
773 - if (count($ids)) {
774 - $this->response->json([
1061 + // response->json() delegates to wp_send_json(), which prints and exits —
1062 + // bypassing the REST server (and killing in-process dispatch). send()
1063 + // returns the identical JSON body and status through WP_REST_Response.
1064 + // addTaxonomyTerms returns false (not an array) for a taxonomy outside
1065 + // the registered catalog, e.g. the unshipped product-tags — that must
1066 + // fall into the 423 branch, not raise a count-on-bool warning.
1067 + if (is_array($ids) && count($ids)) {
1068 + return $this->response->send([
775 1069 'term_ids' => $ids,
776 1070 'names' => $termNames
777 1071 ]);
778 - } else {
779 - $this->response->json([
780 - 'message' => __('Unable To Create Term/s', 'fluent-cart'),
781 - ], 423);
782 1072 }
783 1073
1074 + return $this->response->sendError([
1075 + 'message' => __('Unable To Create Term/s', 'fluent-cart'),
1076 + ], 423);
784 1077 }
785 1078
786 1079 public function getProductTermsList(): array
787 1080 {
@@ -891,39 +1184,47 @@
891 1184 $name = Arr::get($data, 'search', '');
892 1185 }
893 1186 $ids = Arr::get($data, 'ids', []);
894 1187 $productVariations = [];
895 - $query = [];
896 - if (!empty($name) || count($ids) > 0) {
897 - $query = [
898 - "ID" =>
899 - [
900 - "column" => "ID",
901 - "operator" => "in",
902 - "value" => Arr::get($data, 'ids', [])
903 - ]
904 - ,
905 - "post_title" =>
906 - [
907 - "column" => "post_title",
908 - "operator" => "like",
909 - "value" => '%' . Arr::get($data, 'name') . '%'
910 - ],
911 - "post_status" =>
912 - [
913 - "column" => "post_status",
914 - "operator" => "=",
915 - "value" => 'publish'
916 - ]
917 - ];
918 - }
919 1188
920 1189 $products = Product::query()
921 - ->with('variants')
922 - ->when(count($query), function (Builder $q) use ($query) {
923 - return $q->search($query, function (Builder $query) {
924 - return $query;
925 - }, true);
1190 + ->with(['variants' => function ($variantQuery) use ($name) {
1191 + if (!empty($name)) {
1192 + // Emit only variants the term actually hit: the variant's own
1193 + // title, or every variant of a product whose title matched.
1194 + // Without this, a product matched through one variant leaked
1195 + // all its non-matching siblings into the picker.
1196 + $variantQuery->where(function ($vq) use ($name) {
1197 + $vq->where('variation_title', 'like', '%' . $name . '%')
1198 + ->orWhereHas('product', function ($pq) use ($name) {
1199 + $pq->where('post_title', 'like', '%' . $name . '%');
1200 + });
1201 + });
1202 + }
1203 + // The relation query is shared across all matched parents, so this
1204 + // caps total child rows serialized per request for this
1205 + // remote-search picker.
1206 + $variantQuery->orderBy('id')->limit(100);
1207 + }])
1208 + ->when(!empty($name) || count($ids) > 0, function (Builder $q) use ($name, $ids) {
1209 + $q->where('post_status', 'publish');
1210 +
1211 + if (count($ids) > 0) {
1212 + $q->whereIn('ID', $ids);
1213 + }
1214 +
1215 + if (!empty($name)) {
1216 + // The endpoint's name is searchVariantByName: a term must match
1217 + // the product title OR any of its variants' variation_title.
1218 + // The previous search-helper query matched post_title only, so
1219 + // typing a variant's own title returned nothing.
1220 + $q->where(function (Builder $titleQuery) use ($name) {
1221 + $titleQuery->where('post_title', 'like', '%' . $name . '%')
1222 + ->orWhereHas('variants', function ($variantQuery) use ($name) {
1223 + $variantQuery->where('variation_title', 'like', '%' . $name . '%');
1224 + });
1225 + });
1226 + }
926 1227 })
927 1228 ->when(empty($name), function (Builder $q) {
928 1229 return $q->limit(10);
929 1230 })
@@ -958,9 +1259,9 @@
958 1259 $search = Arr::get($data, 'search', '');
959 1260 $includeIds = Arr::get($data, 'include_ids', []);
960 1261
961 1262 $productsQuery = Product::query()
962 - ->where('post_status', 'publish');
1263 + ->whereIn('post_status', ['publish', 'private']);
963 1264
964 1265 $productsQuery->with(['detail', 'variants' => function ($query) use ($subscription_status) {
965 1266 if ($subscription_status === 'not_subscribable') {
966 1267 $query->where('payment_type', '!=', 'subscription');
@@ -1029,9 +1330,9 @@
1029 1330 if ($leftVariationIds) {
1030 1331 $leftVariants = ProductVariation::query()
1031 1332 ->whereIn('id', $leftVariationIds)
1032 1333 ->with(['product' => function ($query) {
1033 - $query->where('post_status', 'publish');
1334 + $query->whereIn('post_status', ['publish', 'private']);
1034 1335 }, 'product.detail'])
1035 1336 ->get();
1036 1337
1037 1338 foreach ($leftVariants as $variant) {
@@ -1120,8 +1421,9 @@
1120 1421
1121 1422 public function fetchVariationsByIds(Request $request): array
1122 1423 {
1123 1424 $ids = $request->getSafe(['productIds.*' => 'intval']);
1425 + $ids = Arr::get($ids, 'productIds', []);
1124 1426 $ids = is_array($ids) ? $ids : [];
1125 1427 if (empty($ids)) {
1126 1428 return ['products' => []];
1127 1429 }
@@ -1256,8 +1558,13 @@
1256 1558 }
1257 1559
1258 1560 public function updateInventory(Request $request, $postId, $variantId)
1259 1561 {
1562 + if (!ModuleSettings::isActive('stock_management')) {
1563 + return $this->response->sendError([
1564 + 'message' => __('Stock Management module is disabled. Enable it from Settings to manage inventory.', 'fluent-cart')
1565 + ], 422);
1566 + }
1260 1567
1261 1568 $variant = ProductVariation::query()->find($variantId);
1262 1569
1263 1570 if (!$variant) {
@@ -1262,11 +1569,15 @@
1262 1569
1263 1570 if (!$variant) {
1264 1571 return $this->response->sendError([
1265 1572 'message' => __('Variant not found', 'fluent-cart')
1266 - ]);
1573 + ], 404);
1267 1574 }
1268 1575
1576 + // Capture old stock state before update
1577 + $oldAvailable = intval($variant->available);
1578 + $oldStockStatus = $variant->stock_status;
1579 +
1269 1580 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1270 1581
1271 1582 // get variations by post_id
1272 1583 $variations = ProductVariation::query()->where('post_id', $postId)->where('id', '!=', $variantId)->get();
@@ -1279,14 +1590,17 @@
1279 1590 'available' => $variation->available,
1280 1591 'stock_status' => $variation->stock_status
1281 1592 ];
1282 1593 }
1594 + $newAvailable = intval($request->get('available'));
1595 + $newStockStatus = $newAvailable > 0 ? 'in-stock' : 'out-of-stock';
1596 +
1283 1597 $updateData[] = [
1284 1598 'id' => $variantId,
1285 1599 'total_stock' => sanitize_text_field($request->get('total_stock')),
1286 - 'available' => sanitize_text_field($request->get('available')),
1600 + 'available' => $newAvailable,
1287 1601 'manage_stock' => 1,
1288 - 'stock_status' => $request->get('available') > 0 ? 'in-stock' : 'out-of-stock'
1602 + 'stock_status' => $newStockStatus
1289 1603 ];
1290 1604 // update variations
1291 1605 $isUpdated = ProductVariation::query()->batchUpdate($updateData);
1292 1606
@@ -1296,10 +1610,8 @@
1296 1610 $detail->stock_availability = $hasAvailableStock ? 'in-stock' : 'out-of-stock';
1297 1611 $detail->manage_stock = 1;
1298 1612 $detail->save();
1299 1613 }
1300 -
1301 -
1302 1614 if (is_wp_error($isUpdated)) {
1303 1615 return $this->response->sendError([
1304 1616 'message' => __('Inventory update failed', 'fluent-cart')
1305 1617 ]);
@@ -1304,8 +1616,13 @@
1304 1616 'message' => __('Inventory update failed', 'fluent-cart')
1305 1617 ]);
1306 1618 }
1307 1619
1620 + // Stock persisted — fire StockChanged only if it actually changed.
1621 + if ($oldAvailable !== $newAvailable || $oldStockStatus !== $newStockStatus) {
1622 + (new StockChanged([$postId]))->dispatch();
1623 + }
1624 +
1308 1625 return $this->response->sendSuccess([
1309 1626 'message' => __('Inventory updated successfully', 'fluent-cart')
1310 1627 ]);
1311 1628 }
@@ -1312,8 +1629,17 @@
1312 1629
1313 1630 public function updateManageStock(Request $request, $postId)
1314 1631 {
1315 1632 $manageStock = sanitize_text_field($request->get('manage_stock'));
1633 +
1634 + // Turning inventory ON requires the Stock Management module to be active.
1635 + // Turning it OFF stays allowed so a store that disables the module can
1636 + // still clean up products that were left with manage_stock = 1.
1637 + if ($manageStock == 1 && !ModuleSettings::isActive('stock_management')) {
1638 + return $this->response->sendError([
1639 + 'message' => __('Stock Management module is disabled. Enable it from Settings to manage inventory.', 'fluent-cart')
1640 + ], 422);
1641 + }
1316 1642
1317 1643 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1318 1644
1319 1645 $updateData = [