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 +336 -82 1.4.1 → 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,35 +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 - '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 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 +
170 353 if ($createdProductDetail) {
171 354 return $this->sendSuccess([
172 355 'data' => [
173 356 'ID' => $createdPostId,
@@ -274,9 +457,9 @@
274 457 } catch (\RuntimeException $e) {
275 458 if ((int)$e->getCode() === 404) {
276 459 return $this->sendError([
277 460 'message' => __('Product not found', 'fluent-cart')
278 - ]);
461 + ], 404);
279 462 }
280 463 return $this->sendError([
281 464 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
282 465 ]);
@@ -308,8 +491,15 @@
308 491 public function update(ProductUpdateRequest $request, $postId)
309 492 {
310 493 $data = $request->getSafe($request->sanitize());
311 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 +
312 502 if (
313 503 Arr::get($data, 'detail.variation_type') === 'simple' &&
314 504 (empty(Arr::get($data, 'variants')) || empty(Arr::get($data, 'variants.0')))
315 505 ) {
@@ -326,9 +516,8 @@
326 516 // }
327 517
328 518 $isUpdated = ProductResource::update($data, $postId);
329 519
330 -
331 520 if (is_wp_error($isUpdated)) {
332 521 return $isUpdated;
333 522 }
334 523
@@ -339,8 +528,25 @@
339 528
340 529 return $this->response->sendSuccess($isUpdated);
341 530 }
342 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 +
343 549 public function updateLongDescEditorMode(Request $request, $postId)
344 550 {
345 551 // Validate input
346 552 $activeEditor = sanitize_text_field($request->get('active_editor'));
@@ -532,12 +738,15 @@
532 738 * @return WP_REST_Response
533 739 */
534 740 public function get(Request $request, $productId)
535 741 {
742 + // attrMap joins fct_atts_relations, populated by the Advanced Variation feature.
743 + $variantRelations = ['media', 'attrMap'];
744 +
536 745 $product = Product::with([
537 746 'detail',
538 - 'variants' => function ($query) {
539 - $query->with(['media'])
747 + 'variants' => function ($query) use ($variantRelations) {
748 + $query->with($variantRelations)
540 749 ->orderBy('serial_index', 'ASC');
541 750 }
542 751 ])->with('downloadable_files')->find($productId);
543 752
@@ -586,11 +795,17 @@
586 795
587 796 $featuredImageId = get_post_thumbnail_id($product->ID);
588 797 $productData = $product->toArray();
589 798 $productData['featured_image_id'] = $featuredImageId;
590 - //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 +
591 806 return $this->sendSuccess([
592 - 'product' => $productData,
807 + 'product' => Arr::get($payload, 'product', $productData),
593 808 'product_menu' => $productMenu ?? "",
594 809 'taxonomies' => $taxonomies,
595 810 ]);
596 811 } else {
@@ -785,17 +1000,18 @@
785 1000 }
786 1001
787 1002 public function updateVariantOption(Request $request, $postId)
788 1003 {
1004 + $data = $request->all();
789 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 + }
790 1013
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 1014 $isSynced = ProductResource::syncVariantOption($postId, $data);
799 1015
800 1016 if (is_wp_error($isSynced)) {
801 1017 return $isSynced;
@@ -823,19 +1039,24 @@
823 1039
824 1040 $termNames = explode(',', $name);
825 1041 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
826 1042
827 - if (count($ids)) {
828 - $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([
829 1051 'term_ids' => $ids,
830 1052 'names' => $termNames
831 1053 ]);
832 - } else {
833 - $this->response->json([
834 - 'message' => __('Unable To Create Term/s', 'fluent-cart'),
835 - ], 423);
836 1054 }
837 1055
1056 + return $this->response->sendError([
1057 + 'message' => __('Unable To Create Term/s', 'fluent-cart'),
1058 + ], 423);
838 1059 }
839 1060
840 1061 public function getProductTermsList(): array
841 1062 {
@@ -945,39 +1166,47 @@
945 1166 $name = Arr::get($data, 'search', '');
946 1167 }
947 1168 $ids = Arr::get($data, 'ids', []);
948 1169 $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 1170
974 1171 $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);
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 + }
980 1209 })
981 1210 ->when(empty($name), function (Builder $q) {
982 1211 return $q->limit(10);
983 1212 })
@@ -1174,8 +1403,9 @@
1174 1403
1175 1404 public function fetchVariationsByIds(Request $request): array
1176 1405 {
1177 1406 $ids = $request->getSafe(['productIds.*' => 'intval']);
1407 + $ids = Arr::get($ids, 'productIds', []);
1178 1408 $ids = is_array($ids) ? $ids : [];
1179 1409 if (empty($ids)) {
1180 1410 return ['products' => []];
1181 1411 }
@@ -1310,8 +1540,13 @@
1310 1540 }
1311 1541
1312 1542 public function updateInventory(Request $request, $postId, $variantId)
1313 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 + }
1314 1549
1315 1550 $variant = ProductVariation::query()->find($variantId);
1316 1551
1317 1552 if (!$variant) {
@@ -1316,11 +1551,15 @@
1316 1551
1317 1552 if (!$variant) {
1318 1553 return $this->response->sendError([
1319 1554 'message' => __('Variant not found', 'fluent-cart')
1320 - ]);
1555 + ], 404);
1321 1556 }
1322 1557
1558 + // Capture old stock state before update
1559 + $oldAvailable = intval($variant->available);
1560 + $oldStockStatus = $variant->stock_status;
1561 +
1323 1562 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1324 1563
1325 1564 // get variations by post_id
1326 1565 $variations = ProductVariation::query()->where('post_id', $postId)->where('id', '!=', $variantId)->get();
@@ -1333,14 +1572,17 @@
1333 1572 'available' => $variation->available,
1334 1573 'stock_status' => $variation->stock_status
1335 1574 ];
1336 1575 }
1576 + $newAvailable = intval($request->get('available'));
1577 + $newStockStatus = $newAvailable > 0 ? 'in-stock' : 'out-of-stock';
1578 +
1337 1579 $updateData[] = [
1338 1580 'id' => $variantId,
1339 1581 'total_stock' => sanitize_text_field($request->get('total_stock')),
1340 - 'available' => sanitize_text_field($request->get('available')),
1582 + 'available' => $newAvailable,
1341 1583 'manage_stock' => 1,
1342 - 'stock_status' => $request->get('available') > 0 ? 'in-stock' : 'out-of-stock'
1584 + 'stock_status' => $newStockStatus
1343 1585 ];
1344 1586 // update variations
1345 1587 $isUpdated = ProductVariation::query()->batchUpdate($updateData);
1346 1588
@@ -1350,10 +1592,8 @@
1350 1592 $detail->stock_availability = $hasAvailableStock ? 'in-stock' : 'out-of-stock';
1351 1593 $detail->manage_stock = 1;
1352 1594 $detail->save();
1353 1595 }
1354 -
1355 -
1356 1596 if (is_wp_error($isUpdated)) {
1357 1597 return $this->response->sendError([
1358 1598 'message' => __('Inventory update failed', 'fluent-cart')
1359 1599 ]);
@@ -1358,8 +1598,13 @@
1358 1598 'message' => __('Inventory update failed', 'fluent-cart')
1359 1599 ]);
1360 1600 }
1361 1601
1602 + // Stock persisted — fire StockChanged only if it actually changed.
1603 + if ($oldAvailable !== $newAvailable || $oldStockStatus !== $newStockStatus) {
1604 + (new StockChanged([$postId]))->dispatch();
1605 + }
1606 +
1362 1607 return $this->response->sendSuccess([
1363 1608 'message' => __('Inventory updated successfully', 'fluent-cart')
1364 1609 ]);
1365 1610 }
@@ -1366,8 +1611,17 @@
1366 1611
1367 1612 public function updateManageStock(Request $request, $postId)
1368 1613 {
1369 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 + }
1370 1624
1371 1625 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1372 1626
1373 1627 $updateData = [