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 +356 -84 1.4.2 → 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,35 +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 - 'tax_class' => 'standard',
157 - 'tax_exempt' => 'no',
158 - 'times' => '',
159 - 'repeat_interval' => '',
160 - 'trial_days' => '',
161 - 'billing_summary' => '',
162 - 'manage_setup_fee' => 'no',
163 - 'signup_fee_name' => '',
164 - 'signup_fee' => '',
165 - 'setup_fee_per_item' => 'no',
166 - 'is_bundle_product' => Arr::get($detail, 'other_info.is_bundle_product', 'no'),
167 - ]
168 - ]);
169 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 +
170 371 if ($createdProductDetail) {
171 372 return $this->sendSuccess([
172 373 'data' => [
173 374 'ID' => $createdPostId,
@@ -274,9 +475,9 @@
274 475 } catch (\RuntimeException $e) {
275 476 if ((int)$e->getCode() === 404) {
276 477 return $this->sendError([
277 478 'message' => __('Product not found', 'fluent-cart')
278 - ]);
479 + ], 404);
279 480 }
280 481 return $this->sendError([
281 482 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
282 483 ]);
@@ -308,8 +509,15 @@
308 509 public function update(ProductUpdateRequest $request, $postId)
309 510 {
310 511 $data = $request->getSafe($request->sanitize());
311 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 +
312 520 if (
313 521 Arr::get($data, 'detail.variation_type') === 'simple' &&
314 522 (empty(Arr::get($data, 'variants')) || empty(Arr::get($data, 'variants.0')))
315 523 ) {
@@ -326,9 +534,8 @@
326 534 // }
327 535
328 536 $isUpdated = ProductResource::update($data, $postId);
329 537
330 -
331 538 if (is_wp_error($isUpdated)) {
332 539 return $isUpdated;
333 540 }
334 541
@@ -339,8 +546,25 @@
339 546
340 547 return $this->response->sendSuccess($isUpdated);
341 548 }
342 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 +
343 567 public function updateLongDescEditorMode(Request $request, $postId)
344 568 {
345 569 // Validate input
346 570 $activeEditor = sanitize_text_field($request->get('active_editor'));
@@ -532,12 +756,15 @@
532 756 * @return WP_REST_Response
533 757 */
534 758 public function get(Request $request, $productId)
535 759 {
760 + // attrMap joins fct_atts_relations, populated by the Advanced Variation feature.
761 + $variantRelations = ['media', 'attrMap'];
762 +
536 763 $product = Product::with([
537 764 'detail',
538 - 'variants' => function ($query) {
539 - $query->with(['media'])
765 + 'variants' => function ($query) use ($variantRelations) {
766 + $query->with($variantRelations)
540 767 ->orderBy('serial_index', 'ASC');
541 768 }
542 769 ])->with('downloadable_files')->find($productId);
543 770
@@ -586,11 +813,17 @@
586 813
587 814 $featuredImageId = get_post_thumbnail_id($product->ID);
588 815 $productData = $product->toArray();
589 816 $productData['featured_image_id'] = $featuredImageId;
590 - //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 +
591 824 return $this->sendSuccess([
592 - 'product' => $productData,
825 + 'product' => Arr::get($payload, 'product', $productData),
593 826 'product_menu' => $productMenu ?? "",
594 827 'taxonomies' => $taxonomies,
595 828 ]);
596 829 } else {
@@ -785,17 +1018,18 @@
785 1018 }
786 1019
787 1020 public function updateVariantOption(Request $request, $postId)
788 1021 {
1022 + $data = $request->all();
789 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 + }
790 1031
791 - $data = $request->all();
792 - // ProductValidator::validate($data, [
793 -// 'variation_type' => 'required',
794 -// 'product_id' => 'required',
795 -// 'options.*.id' => 'required',
796 -// 'options.*.variants' => 'required',
797 -// ]);
798 1032 $isSynced = ProductResource::syncVariantOption($postId, $data);
799 1033
800 1034 if (is_wp_error($isSynced)) {
801 1035 return $isSynced;
@@ -823,19 +1057,24 @@
823 1057
824 1058 $termNames = explode(',', $name);
825 1059 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
826 1060
827 - if (count($ids)) {
828 - $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([
829 1069 'term_ids' => $ids,
830 1070 'names' => $termNames
831 1071 ]);
832 - } else {
833 - $this->response->json([
834 - 'message' => __('Unable To Create Term/s', 'fluent-cart'),
835 - ], 423);
836 1072 }
837 1073
1074 + return $this->response->sendError([
1075 + 'message' => __('Unable To Create Term/s', 'fluent-cart'),
1076 + ], 423);
838 1077 }
839 1078
840 1079 public function getProductTermsList(): array
841 1080 {
@@ -945,39 +1184,47 @@
945 1184 $name = Arr::get($data, 'search', '');
946 1185 }
947 1186 $ids = Arr::get($data, 'ids', []);
948 1187 $productVariations = [];
949 - $query = [];
950 - if (!empty($name) || count($ids) > 0) {
951 - $query = [
952 - "ID" =>
953 - [
954 - "column" => "ID",
955 - "operator" => "in",
956 - "value" => Arr::get($data, 'ids', [])
957 - ]
958 - ,
959 - "post_title" =>
960 - [
961 - "column" => "post_title",
962 - "operator" => "like",
963 - "value" => '%' . Arr::get($data, 'name') . '%'
964 - ],
965 - "post_status" =>
966 - [
967 - "column" => "post_status",
968 - "operator" => "=",
969 - "value" => 'publish'
970 - ]
971 - ];
972 - }
973 1188
974 1189 $products = Product::query()
975 - ->with('variants')
976 - ->when(count($query), function (Builder $q) use ($query) {
977 - return $q->search($query, function (Builder $query) {
978 - return $query;
979 - }, 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 + }
980 1227 })
981 1228 ->when(empty($name), function (Builder $q) {
982 1229 return $q->limit(10);
983 1230 })
@@ -1174,8 +1421,9 @@
1174 1421
1175 1422 public function fetchVariationsByIds(Request $request): array
1176 1423 {
1177 1424 $ids = $request->getSafe(['productIds.*' => 'intval']);
1425 + $ids = Arr::get($ids, 'productIds', []);
1178 1426 $ids = is_array($ids) ? $ids : [];
1179 1427 if (empty($ids)) {
1180 1428 return ['products' => []];
1181 1429 }
@@ -1310,8 +1558,13 @@
1310 1558 }
1311 1559
1312 1560 public function updateInventory(Request $request, $postId, $variantId)
1313 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 + }
1314 1567
1315 1568 $variant = ProductVariation::query()->find($variantId);
1316 1569
1317 1570 if (!$variant) {
@@ -1316,11 +1569,15 @@
1316 1569
1317 1570 if (!$variant) {
1318 1571 return $this->response->sendError([
1319 1572 'message' => __('Variant not found', 'fluent-cart')
1320 - ]);
1573 + ], 404);
1321 1574 }
1322 1575
1576 + // Capture old stock state before update
1577 + $oldAvailable = intval($variant->available);
1578 + $oldStockStatus = $variant->stock_status;
1579 +
1323 1580 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1324 1581
1325 1582 // get variations by post_id
1326 1583 $variations = ProductVariation::query()->where('post_id', $postId)->where('id', '!=', $variantId)->get();
@@ -1333,14 +1590,17 @@
1333 1590 'available' => $variation->available,
1334 1591 'stock_status' => $variation->stock_status
1335 1592 ];
1336 1593 }
1594 + $newAvailable = intval($request->get('available'));
1595 + $newStockStatus = $newAvailable > 0 ? 'in-stock' : 'out-of-stock';
1596 +
1337 1597 $updateData[] = [
1338 1598 'id' => $variantId,
1339 1599 'total_stock' => sanitize_text_field($request->get('total_stock')),
1340 - 'available' => sanitize_text_field($request->get('available')),
1600 + 'available' => $newAvailable,
1341 1601 'manage_stock' => 1,
1342 - 'stock_status' => $request->get('available') > 0 ? 'in-stock' : 'out-of-stock'
1602 + 'stock_status' => $newStockStatus
1343 1603 ];
1344 1604 // update variations
1345 1605 $isUpdated = ProductVariation::query()->batchUpdate($updateData);
1346 1606
@@ -1350,10 +1610,8 @@
1350 1610 $detail->stock_availability = $hasAvailableStock ? 'in-stock' : 'out-of-stock';
1351 1611 $detail->manage_stock = 1;
1352 1612 $detail->save();
1353 1613 }
1354 -
1355 -
1356 1614 if (is_wp_error($isUpdated)) {
1357 1615 return $this->response->sendError([
1358 1616 'message' => __('Inventory update failed', 'fluent-cart')
1359 1617 ]);
@@ -1358,8 +1616,13 @@
1358 1616 'message' => __('Inventory update failed', 'fluent-cart')
1359 1617 ]);
1360 1618 }
1361 1619
1620 + // Stock persisted — fire StockChanged only if it actually changed.
1621 + if ($oldAvailable !== $newAvailable || $oldStockStatus !== $newStockStatus) {
1622 + (new StockChanged([$postId]))->dispatch();
1623 + }
1624 +
1362 1625 return $this->response->sendSuccess([
1363 1626 'message' => __('Inventory updated successfully', 'fluent-cart')
1364 1627 ]);
1365 1628 }
@@ -1366,8 +1629,17 @@
1366 1629
1367 1630 public function updateManageStock(Request $request, $postId)
1368 1631 {
1369 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 + }
1370 1642
1371 1643 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1372 1644
1373 1645 $updateData = [