PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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
← All changes | app/Http/Controllers/ProductController.php +390 -82 1.3.26 → 1.6.5 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,8 +46,12 @@
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 +
46 54 $products->setCollection(
47 55 $products->getCollection()->transform(function ($product) {
48 56 return $product->setAppends(['view_url', 'edit_url']);
49 57 })
@@ -57,10 +65,12 @@
57 65 }
58 66
59 67 public function find(Request $request, Product $product): array
60 68 {
61 - if ($request->get('with')) {
62 - $product->load($request->get('with'));
69 + $with = $this->resolveEagerLoads($request->get('with', []));
70 +
71 + if ($with) {
72 + $product->load($with);
63 73 }
64 74 $data = [
65 75 'product' => $product,
66 76 ];
@@ -71,14 +81,178 @@
71 81
72 82 return $data;
73 83 }
74 84
85 + /**
86 + * What the `with` parameter on `GET products/{id}` may eager-load.
87 + *
88 + * ## The entry form
89 + *
90 + * Every entry is a LITERAL request key mapped to a CALLABLE. The key is never
91 + * decomposed, prefix-matched or suffix-stripped, so what the client sends is
92 + * either a key in this map or it is dropped. That is what keeps the dotted
93 + * `orderItems.order.customer`, the column-select
94 + * `orderItems.order.customer:id,email` and the nested array
95 + * `with[orderItems][]=order.customer` out — none of them is a key.
96 + *
97 + * The callback owns the whole path AND its own permission bar, and returns the
98 + * relation paths to eager-load, or an empty array when it refuses.
99 + *
100 + * ## Two tiers of key
101 + *
102 + * A SCREEN key names a calling screen and loads exactly what that screen
103 + * renders. A PUBLIC key is a plain relation name an external consumer of a
104 + * product endpoint can reasonably ask for.
105 + *
106 + * ## What stays off the map
107 + *
108 + * `Product::orderItems()` is a real relation keyed on `post_id`, so before the
109 + * request value was constrained an actor holding nothing but products/view
110 + * could walk `?with[]=orderItems.order.customer` from a catalogue product to
111 + * order and customer data — a probe pulled 104 KB of it off one product. It
112 + * must stay unreachable, along with `downloadable_files` (protected file
113 + * paths), `licensesMeta`, `postmeta` and `wpTerms`.
114 + *
115 + * `product_menu` is NOT on this map and does not belong on it: it is a
116 + * controller sentinel, not a relation. find() reads it straight off the raw
117 + * request and answers it with AdminHelper::getProductMenu(); it never reaches
118 + * load(), so it is unaffected by anything here.
119 + *
120 + * No entry declares a select. `Product::$appends` carries `thumbnail`, which
121 + * resolves through `detail->featured_media` — itself a ProductDetail append
122 + * backed by the `galleryImage` relation — and `ProductVariation::$appends`
123 + * lazy-loads `media` keyed on the variant `id`. A select would have to go
124 + * INSIDE the relation closure in any case; on the main query it would narrow
125 + * the product row itself.
126 + *
127 + * @return array<string, callable>
128 + */
129 + private function allowedWiths(): array
130 + {
131 + return [
132 + 'block_product_detail' => [$this, 'blockProductDetail'],
133 +
134 + // The public entry points. These are the two relations an external
135 + // consumer can reasonably ask a product endpoint for: the catalogue
136 + // detail row and the variation rows. Both are catalogue data already
137 + // covered by the route's own products/view, and neither chains toward
138 + // orders, customers or protected downloads, so they carry no risk the
139 + // route does not already carry.
140 + //
141 + // The screen key above exists because the block editors want both in
142 + // one request; these two give either one on its own to a consumer
143 + // that is not that screen.
144 + 'detail' => [$this, 'publicDetail'],
145 + 'variants' => [$this, 'publicVariants'],
146 + ];
147 + }
148 +
149 + /**
150 + * The Gutenberg block editors' single-product fetch. Fourteen block editors
151 + * under `resources/admin/BlockEditor/` hit this endpoint — BuySection,
152 + * Excerpt, MediaCarousel, PriceRange, ProductCard, ProductDescription,
153 + * ProductGallery, ProductImage, ProductInfo, ProductSku, ProductTitle,
154 + * RelatedProduct, SaleBadge and Stock — and between them they render the
155 + * detail row (price range, stock availability, gallery) and the variation
156 + * rows (SKU, per-variant price, buy section), so the key loads both.
157 + *
158 + * `products/view` is the route's own bar, restated here so the entry still
159 + * refuses if this map is ever reached from somewhere the route did not guard.
160 + *
161 + * @return array relation paths
162 + */
163 + private function blockProductDetail(): array
164 + {
165 + if (!PermissionManager::hasPermission('products/view')) {
166 + return [];
167 + }
168 +
169 + return ['detail', 'variants'];
170 + }
171 +
172 + /**
173 + * The catalogue detail row on its own — price range, stock availability,
174 + * variation type, featured media.
175 + *
176 + * @return array relation paths
177 + */
178 + private function publicDetail(): array
179 + {
180 + if (!PermissionManager::hasPermission('products/view')) {
181 + return [];
182 + }
183 +
184 + return ['detail'];
185 + }
186 +
187 + /**
188 + * The variation rows on their own — SKU, per-variant price, stock.
189 + *
190 + * @return array relation paths
191 + */
192 + private function publicVariants(): array
193 + {
194 + if (!PermissionManager::hasPermission('products/view')) {
195 + return [];
196 + }
197 +
198 + return ['variants'];
199 + }
200 +
201 + /**
202 + * Reduce a client-supplied `with` payload to the relation paths this endpoint
203 + * is allowed to eager-load.
204 + *
205 + * Anything that is not a literal key of allowedWiths() is dropped SILENTLY —
206 + * an unknown relation otherwise reaches Builder::getRelation() and becomes a
207 + * RelationNotFoundException, i.e. a 500, where a stale block build should
208 + * simply render without its data.
209 + *
210 + * Only STRING request entries are considered, which is what drops the nested
211 + * array shape `with[orderItems][]=order.customer`: its value is an array and
212 + * its key is never read.
213 + *
214 + * Kept local to this controller rather than folded into
215 + * `Services/Filter/BaseFilter::allowedWiths()`: that map adopts a Builder
216 + * returned by each callback, while this endpoint eager-loads onto a
217 + * route-model-bound instance, and the two maps share no entry.
218 + *
219 + * @param mixed $with raw request value
220 + * @return array relation names safe to pass to Product::load()
221 + */
222 + private function resolveEagerLoads($with): array
223 + {
224 + $map = $this->allowedWiths();
225 +
226 + $resolved = [];
227 +
228 + foreach (Arr::wrap($with) as $requestKey) {
229 + if (!is_string($requestKey) || !array_key_exists($requestKey, $map)) {
230 + continue;
231 + }
232 +
233 + $entry = $map[$requestKey];
234 +
235 + if (!is_callable($entry)) {
236 + continue;
237 + }
238 +
239 + foreach ((array) $entry() as $relation) {
240 + if (is_string($relation) && $relation !== '') {
241 + $resolved[$relation] = true;
242 + }
243 + }
244 + }
245 +
246 + return array_keys($resolved);
247 + }
248 +
75 249 public function getRelatedProducts(Request $request, $productId): WP_REST_Response
76 250 {
77 251 $productId = absint($productId);
78 252
79 253 if (!$productId) {
80 - return $this->sendError('Invalid product ID');
254 + return $this->sendError(__('Invalid product ID', 'fluent-cart'));
81 255 }
82 256
83 257 $relatedBy = [];
84 258
@@ -139,33 +313,44 @@
139 313
140 314 $isDigital = Arr::get($detail, 'fulfillment_type') === 'digital';
141 315
142 316 $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 317
318 + // Only Simple products get a default starter variant. Simple Variations
319 + // and Advanced Variations are created with no variant and build their own
320 + // on the edit page — Simple Variations via the pricing table's "Add
321 + // Pricing" empty state, Advanced Variations via attribute combinations
322 + // (a starter variant there would be an orphan the attribute UI never expects).
323 + $variation = null;
324 + if (Arr::get($detail, 'variation_type') === Helper::PRODUCT_TYPE_SIMPLE) {
325 + $variation = ProductVariation::query()->create([
326 + 'post_id' => $createdPostId,
327 + 'serial_index' => 1,
328 + 'variation_title' => $postData['post_title'],
329 + //'stock_status' => $isDigital ? 'in-stock' : 'out-of-stock',
330 + 'stock_status' => 'in-stock',
331 + 'payment_type' => 'onetime',
332 + 'total_stock' => 1,
333 + 'available' => 1,
334 + 'fulfillment_type' => $detail['fulfillment_type'],
335 + 'other_info' => [
336 + 'description' => '',
337 + 'payment_type' => 'onetime',
338 + 'tax_class' => 'standard',
339 + 'tax_exempt' => 'no',
340 + 'times' => '',
341 + 'repeat_interval' => '',
342 + 'trial_days' => '',
343 + 'billing_summary' => '',
344 + 'manage_setup_fee' => 'no',
345 + 'signup_fee_name' => '',
346 + 'signup_fee' => '',
347 + 'setup_fee_per_item' => 'no',
348 + 'is_bundle_product' => Arr::get($detail, 'other_info.is_bundle_product', 'no'),
349 + ]
350 + ]);
351 + }
352 +
168 353 if ($createdProductDetail) {
169 354 return $this->sendSuccess([
170 355 'data' => [
171 356 'ID' => $createdPostId,
@@ -272,9 +457,9 @@
272 457 } catch (\RuntimeException $e) {
273 458 if ((int)$e->getCode() === 404) {
274 459 return $this->sendError([
275 460 'message' => __('Product not found', 'fluent-cart')
276 - ]);
461 + ], 404);
277 462 }
278 463 return $this->sendError([
279 464 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
280 465 ]);
@@ -306,8 +491,15 @@
306 491 public function update(ProductUpdateRequest $request, $postId)
307 492 {
308 493 $data = $request->getSafe($request->sanitize());
309 494
495 + $isPartialUpdate = !(isset($data['detail']) && is_array($data['detail'])) &&
496 + !(isset($data['variants']) && is_array($data['variants']));
497 +
498 + if ($isPartialUpdate) {
499 + return $this->applyPartialPostUpdate($data, $postId);
500 + }
501 +
310 502 if (
311 503 Arr::get($data, 'detail.variation_type') === 'simple' &&
312 504 (empty(Arr::get($data, 'variants')) || empty(Arr::get($data, 'variants.0')))
313 505 ) {
@@ -324,9 +516,8 @@
324 516 // }
325 517
326 518 $isUpdated = ProductResource::update($data, $postId);
327 519
328 -
329 520 if (is_wp_error($isUpdated)) {
330 521 return $isUpdated;
331 522 }
332 523
@@ -337,8 +528,25 @@
337 528
338 529 return $this->response->sendSuccess($isUpdated);
339 530 }
340 531
532 + private function applyPartialPostUpdate(array $data, $postId)
533 + {
534 + $result = ProductResource::partialUpdate($data, $postId);
535 +
536 + if (is_wp_error($result)) {
537 + $statusCode = $result->get_error_code() === 'not_found' ? 404 : 422;
538 + return $this->sendError(['message' => $result->get_error_message()], $statusCode);
539 + }
540 +
541 + do_action('fluent_cart/product_updated', [
542 + 'data' => $data,
543 + 'product' => $result['data'],
544 + ]);
545 +
546 + return $this->response->sendSuccess($result);
547 + }
548 +
341 549 public function updateLongDescEditorMode(Request $request, $postId)
342 550 {
343 551 // Validate input
344 552 $activeEditor = sanitize_text_field($request->get('active_editor'));
@@ -401,9 +609,9 @@
401 609 'other_info' => $otherInfo
402 610 ]);
403 611
404 612 return $this->sendSuccess([
405 - 'message' => __('Tax Class updated successfully', 'fluent-cart')
613 + 'message' => __('Tax profile updated successfully', 'fluent-cart')
406 614 ]);
407 615 }
408 616
409 617 public function removeTaxClass(Request $request, $postId)
@@ -419,12 +627,64 @@
419 627 $productDetail->update([
420 628 'other_info' => $otherInfo
421 629 ]);
422 630 return $this->sendSuccess([
423 - 'message' => __('Tax Class removed successfully', 'fluent-cart')
631 + 'message' => __('Tax profile removed successfully', 'fluent-cart')
424 632 ]);
425 633 }
426 634
635 + public function toggleTaxExempt(Request $request, $postId)
636 + {
637 + $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
638 + if (empty($productDetail)) {
639 + return $this->sendError([
640 + 'message' => __('Product not found', 'fluent-cart')
641 + ]);
642 + }
643 +
644 + $otherInfo = $productDetail->other_info ?: [];
645 + $taxExempt = sanitize_text_field($request->get('tax_exempt', 'no'));
646 + $existingTaxClass = Arr::get($otherInfo, 'tax_class');
647 + // Product-level tax settings live on product detail, so convert the UI
648 + // slug back to the stored tax-class ID before persisting the change.
649 + $taxClassSlug = sanitize_text_field($request->get('tax_class', ''));
650 + $otherInfo['tax_exempt'] = $taxExempt === 'yes' ? 'yes' : 'no';
651 +
652 + if (!$taxClassSlug) {
653 + $defaultClass = TaxClass::query()->where('slug', 'standard')->first();
654 + $resolvedTaxClassId = $existingTaxClass ?: ($defaultClass ? $defaultClass->id : null);
655 + if ($resolvedTaxClassId) {
656 + $resolvedTaxClass = TaxClass::query()->find($resolvedTaxClassId);
657 + $taxClassSlug = $resolvedTaxClass ? $resolvedTaxClass->slug : 'standard';
658 + } else {
659 + $taxClassSlug = 'standard';
660 + }
661 + } else {
662 + $taxClass = TaxClass::query()->where('slug', $taxClassSlug)->first();
663 + if (!$taxClass) {
664 + return $this->sendError([
665 + 'message' => __('Invalid tax class', 'fluent-cart')
666 + ], 422);
667 + }
668 + $resolvedTaxClassId = $taxClass->id;
669 + }
670 +
671 + $otherInfo['tax_class'] = $resolvedTaxClassId;
672 +
673 + $productDetail->update([
674 + 'other_info' => $otherInfo
675 + ]);
676 +
677 + return $this->sendSuccess([
678 + 'message' => $taxExempt === 'yes'
679 + ? __('Product is now tax exempt', 'fluent-cart')
680 + : __('Tax will be charged on this product', 'fluent-cart'),
681 + 'tax_exempt' => $otherInfo['tax_exempt'],
682 + 'tax_class' => $otherInfo['tax_class'],
683 + 'tax_class_slug' => $taxClassSlug ?: 'standard'
684 + ]);
685 + }
686 +
427 687 public function updateShippingClass(Request $request, $postId)
428 688 {
429 689 $shippingClassId = sanitize_text_field(Arr::get($this->request->all(), 'shipping_class', 0));
430 690 $shippingClass = ShippingClass::query()->findOrFail($shippingClassId);
@@ -478,12 +738,15 @@
478 738 * @return WP_REST_Response
479 739 */
480 740 public function get(Request $request, $productId)
481 741 {
742 + // attrMap joins fct_atts_relations, populated by the Advanced Variation feature.
743 + $variantRelations = ['media', 'attrMap'];
744 +
482 745 $product = Product::with([
483 746 'detail',
484 - 'variants' => function ($query) {
485 - $query->with(['media'])
747 + 'variants' => function ($query) use ($variantRelations) {
748 + $query->with($variantRelations)
486 749 ->orderBy('serial_index', 'ASC');
487 750 }
488 751 ])->with('downloadable_files')->find($productId);
489 752
@@ -532,11 +795,17 @@
532 795
533 796 $featuredImageId = get_post_thumbnail_id($product->ID);
534 797 $productData = $product->toArray();
535 798 $productData['featured_image_id'] = $featuredImageId;
536 - //get featured image id
799 +
800 + $payload = apply_filters('fluent_cart/product/get_response_data', [
801 + 'product' => $productData,
802 + 'product_id' => (int) $productId,
803 + 'request' => $request,
804 + ]);
805 +
537 806 return $this->sendSuccess([
538 - 'product' => $productData,
807 + 'product' => Arr::get($payload, 'product', $productData),
539 808 'product_menu' => $productMenu ?? "",
540 809 'taxonomies' => $taxonomies,
541 810 ]);
542 811 } else {
@@ -731,17 +1000,18 @@
731 1000 }
732 1001
733 1002 public function updateVariantOption(Request $request, $postId)
734 1003 {
1004 + $data = $request->all();
735 1005
1006 + // Cap user-supplied option groups before they reach the sync pipeline. The
1007 + // client UI enforces a 200-combination ceiling, but a forged POST can carry
1008 + // arbitrarily many entries. Trim at the controller so the filter chain and
1009 + // downstream Pro listeners never see unbounded input.
1010 + if (isset($data['options']) && is_array($data['options'])) {
1011 + $data['options'] = array_slice($data['options'], 0, 200);
1012 + }
736 1013
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 1014 $isSynced = ProductResource::syncVariantOption($postId, $data);
745 1015
746 1016 if (is_wp_error($isSynced)) {
747 1017 return $isSynced;
@@ -769,19 +1039,24 @@
769 1039
770 1040 $termNames = explode(',', $name);
771 1041 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
772 1042
773 - if (count($ids)) {
774 - $this->response->json([
1043 + // response->json() delegates to wp_send_json(), which prints and exits —
1044 + // bypassing the REST server (and killing in-process dispatch). send()
1045 + // returns the identical JSON body and status through WP_REST_Response.
1046 + // addTaxonomyTerms returns false (not an array) for a taxonomy outside
1047 + // the registered catalog, e.g. the unshipped product-tags — that must
1048 + // fall into the 423 branch, not raise a count-on-bool warning.
1049 + if (is_array($ids) && count($ids)) {
1050 + return $this->response->send([
775 1051 'term_ids' => $ids,
776 1052 'names' => $termNames
777 1053 ]);
778 - } else {
779 - $this->response->json([
780 - 'message' => __('Unable To Create Term/s', 'fluent-cart'),
781 - ], 423);
782 1054 }
783 1055
1056 + return $this->response->sendError([
1057 + 'message' => __('Unable To Create Term/s', 'fluent-cart'),
1058 + ], 423);
784 1059 }
785 1060
786 1061 public function getProductTermsList(): array
787 1062 {
@@ -891,39 +1166,47 @@
891 1166 $name = Arr::get($data, 'search', '');
892 1167 }
893 1168 $ids = Arr::get($data, 'ids', []);
894 1169 $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 1170
920 1171 $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);
1172 + ->with(['variants' => function ($variantQuery) use ($name) {
1173 + if (!empty($name)) {
1174 + // Emit only variants the term actually hit: the variant's own
1175 + // title, or every variant of a product whose title matched.
1176 + // Without this, a product matched through one variant leaked
1177 + // all its non-matching siblings into the picker.
1178 + $variantQuery->where(function ($vq) use ($name) {
1179 + $vq->where('variation_title', 'like', '%' . $name . '%')
1180 + ->orWhereHas('product', function ($pq) use ($name) {
1181 + $pq->where('post_title', 'like', '%' . $name . '%');
1182 + });
1183 + });
1184 + }
1185 + // The relation query is shared across all matched parents, so this
1186 + // caps total child rows serialized per request for this
1187 + // remote-search picker.
1188 + $variantQuery->orderBy('id')->limit(100);
1189 + }])
1190 + ->when(!empty($name) || count($ids) > 0, function (Builder $q) use ($name, $ids) {
1191 + $q->where('post_status', 'publish');
1192 +
1193 + if (count($ids) > 0) {
1194 + $q->whereIn('ID', $ids);
1195 + }
1196 +
1197 + if (!empty($name)) {
1198 + // The endpoint's name is searchVariantByName: a term must match
1199 + // the product title OR any of its variants' variation_title.
1200 + // The previous search-helper query matched post_title only, so
1201 + // typing a variant's own title returned nothing.
1202 + $q->where(function (Builder $titleQuery) use ($name) {
1203 + $titleQuery->where('post_title', 'like', '%' . $name . '%')
1204 + ->orWhereHas('variants', function ($variantQuery) use ($name) {
1205 + $variantQuery->where('variation_title', 'like', '%' . $name . '%');
1206 + });
1207 + });
1208 + }
926 1209 })
927 1210 ->when(empty($name), function (Builder $q) {
928 1211 return $q->limit(10);
929 1212 })
@@ -1120,8 +1403,9 @@
1120 1403
1121 1404 public function fetchVariationsByIds(Request $request): array
1122 1405 {
1123 1406 $ids = $request->getSafe(['productIds.*' => 'intval']);
1407 + $ids = Arr::get($ids, 'productIds', []);
1124 1408 $ids = is_array($ids) ? $ids : [];
1125 1409 if (empty($ids)) {
1126 1410 return ['products' => []];
1127 1411 }
@@ -1256,8 +1540,13 @@
1256 1540 }
1257 1541
1258 1542 public function updateInventory(Request $request, $postId, $variantId)
1259 1543 {
1544 + if (!ModuleSettings::isActive('stock_management')) {
1545 + return $this->response->sendError([
1546 + 'message' => __('Stock Management module is disabled. Enable it from Settings to manage inventory.', 'fluent-cart')
1547 + ], 422);
1548 + }
1260 1549
1261 1550 $variant = ProductVariation::query()->find($variantId);
1262 1551
1263 1552 if (!$variant) {
@@ -1262,11 +1551,15 @@
1262 1551
1263 1552 if (!$variant) {
1264 1553 return $this->response->sendError([
1265 1554 'message' => __('Variant not found', 'fluent-cart')
1266 - ]);
1555 + ], 404);
1267 1556 }
1268 1557
1558 + // Capture old stock state before update
1559 + $oldAvailable = intval($variant->available);
1560 + $oldStockStatus = $variant->stock_status;
1561 +
1269 1562 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1270 1563
1271 1564 // get variations by post_id
1272 1565 $variations = ProductVariation::query()->where('post_id', $postId)->where('id', '!=', $variantId)->get();
@@ -1279,14 +1572,17 @@
1279 1572 'available' => $variation->available,
1280 1573 'stock_status' => $variation->stock_status
1281 1574 ];
1282 1575 }
1576 + $newAvailable = intval($request->get('available'));
1577 + $newStockStatus = $newAvailable > 0 ? 'in-stock' : 'out-of-stock';
1578 +
1283 1579 $updateData[] = [
1284 1580 'id' => $variantId,
1285 1581 'total_stock' => sanitize_text_field($request->get('total_stock')),
1286 - 'available' => sanitize_text_field($request->get('available')),
1582 + 'available' => $newAvailable,
1287 1583 'manage_stock' => 1,
1288 - 'stock_status' => $request->get('available') > 0 ? 'in-stock' : 'out-of-stock'
1584 + 'stock_status' => $newStockStatus
1289 1585 ];
1290 1586 // update variations
1291 1587 $isUpdated = ProductVariation::query()->batchUpdate($updateData);
1292 1588
@@ -1296,10 +1592,8 @@
1296 1592 $detail->stock_availability = $hasAvailableStock ? 'in-stock' : 'out-of-stock';
1297 1593 $detail->manage_stock = 1;
1298 1594 $detail->save();
1299 1595 }
1300 -
1301 -
1302 1596 if (is_wp_error($isUpdated)) {
1303 1597 return $this->response->sendError([
1304 1598 'message' => __('Inventory update failed', 'fluent-cart')
1305 1599 ]);
@@ -1304,8 +1598,13 @@
1304 1598 'message' => __('Inventory update failed', 'fluent-cart')
1305 1599 ]);
1306 1600 }
1307 1601
1602 + // Stock persisted — fire StockChanged only if it actually changed.
1603 + if ($oldAvailable !== $newAvailable || $oldStockStatus !== $newStockStatus) {
1604 + (new StockChanged([$postId]))->dispatch();
1605 + }
1606 +
1308 1607 return $this->response->sendSuccess([
1309 1608 'message' => __('Inventory updated successfully', 'fluent-cart')
1310 1609 ]);
1311 1610 }
@@ -1312,8 +1611,17 @@
1312 1611
1313 1612 public function updateManageStock(Request $request, $postId)
1314 1613 {
1315 1614 $manageStock = sanitize_text_field($request->get('manage_stock'));
1615 +
1616 + // Turning inventory ON requires the Stock Management module to be active.
1617 + // Turning it OFF stays allowed so a store that disables the module can
1618 + // still clean up products that were left with manage_stock = 1.
1619 + if ($manageStock == 1 && !ModuleSettings::isActive('stock_management')) {
1620 + return $this->response->sendError([
1621 + 'message' => __('Stock Management module is disabled. Enable it from Settings to manage inventory.', 'fluent-cart')
1622 + ], 422);
1623 + }
1316 1624
1317 1625 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1318 1626
1319 1627 $updateData = [