PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Modules / MCP / Tools / ProductTools.php

ProductTools.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/MCP/Tools/ProductTools.php

430 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\MCP\Tools;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\Models\Product;
7 use FluentCart\App\Models\ProductVariation;
8 use FluentCart\App\Models\OrderItem;
9 use FluentCart\App\Modules\MCP\Support\AdvancedSearch;
10 use FluentCart\App\Modules\MCP\Support\MCPHelper;
11 use FluentCart\App\Modules\MCP\Support\PermissionGate;
12
13 /**
14 * Product & inventory tools.
15 *
16 * Note on money: product/variation prices are stored as cents (numeric), so the
17 * shared money() helper formats them correctly — same as orders. We never mix a
18 * raw price into prose.
19 *
20 * Parameter design:
21 * - list-products filters on what a merchant browses by (status, fulfillment,
22 * stock, price band, category). Price filters are in store currency.
23 * - get-product is lean by default (detail + variations); sales rollup and
24 * downloadable files are opt-in via include[].
25 * - get-inventory is the dedicated "what do I need to restock?" view — distinct
26 * from sales reporting. It only lists stock-managed variations at risk.
27 */
28 class ProductTools
29 {
30 public static function definitions()
31 {
32 return [
33 'fluent-cart/list-products' => [
34 'label' => __('List Products', 'fluent-cart'),
35 'description' => __('Find and filter products. Compact rows: title, status, price range, variation count, fulfillment, stock status. Use get-product for full detail and per-variation stock. Price filters are in store currency, not cents. For conditions these flat filters cannot express (OR groups, order/variation counts, available stock quantity, taxonomy terms) pass advanced_filters — call get-search-schema entity=products first (Pro).', 'fluent-cart'),
36 'input_schema' => [
37 'type' => 'object',
38 'properties' => [
39 'search' => ['type' => 'string', 'description' => 'Matches product title.'],
40 'status' => ['type' => 'string', 'enum' => ['publish', 'draft', 'private', 'pending'], 'description' => 'WordPress post status.'],
41 'fulfillment_type' => ['type' => 'string', 'enum' => ['physical', 'digital']],
42 'variation_type' => ['type' => 'string', 'enum' => ['simple', 'simple_variations', 'advanced_variations']],
43 'stock_status' => ['type' => 'string', 'enum' => ['in-stock', 'out-of-stock']],
44 'category' => ['type' => 'string', 'description' => 'Category term slug.'],
45 'min_price' => ['type' => 'number', 'description' => 'Minimum price in store currency.'],
46 'max_price' => ['type' => 'number', 'description' => 'Maximum price in store currency.'],
47 'advanced_filters' => ['type' => 'array', 'items' => ['type' => ['object', 'array']], 'description' => 'Pro: condition groups {property, operator, value} — outer array = OR groups, inner = AND. Call get-search-schema entity=products FIRST for properties/operators/format. AND-combines with the other filters here. An empty array means no advanced filter.'],
48 'sort_by' => ['type' => 'string', 'enum' => ['id', 'title', 'date'], 'default' => 'date'],
49 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
50 'page' => ['type' => 'integer', 'default' => 1],
51 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'],
52 ],
53 ],
54 'execute_callback' => [self::class, 'listProducts'],
55 'permission_callback' => function () {
56 return PermissionGate::can('products/view');
57 },
58 'annotations' => ['readonly' => true],
59 ],
60
61 'fluent-cart/get-product' => [
62 'label' => __('Get Product', 'fluent-cart'),
63 'description' => __('Full detail for one product: description, variations with SKU/price/stock/subscription terms, categories and tags. Add include[] for sales (lifetime units + revenue) and downloads. Identify by product_id.', 'fluent-cart'),
64 'input_schema' => [
65 'type' => 'object',
66 'properties' => [
67 'product_id' => ['type' => 'integer'],
68 'include' => [
69 'type' => 'array',
70 'items' => ['type' => 'string', 'enum' => ['sales', 'downloads']],
71 'description' => 'Optional sections. detail + variations + taxonomy are always returned.',
72 ],
73 ],
74 'required' => ['product_id'],
75 ],
76 'execute_callback' => [self::class, 'getProduct'],
77 'permission_callback' => function () {
78 return PermissionGate::can('products/view');
79 },
80 'annotations' => ['readonly' => true],
81 ],
82
83 'fluent-cart/get-inventory' => [
84 'label' => __('Get Inventory Status', 'fluent-cart'),
85 'description' => __('Stock-managed variations that need attention: out-of-stock, or at/below a threshold. Answers "what do I need to restock?" — this is inventory health, not sales. Shows available vs committed vs on-hold.', 'fluent-cart'),
86 'input_schema' => [
87 'type' => 'object',
88 'properties' => [
89 'threshold' => ['type' => 'integer', 'default' => 5, 'description' => 'Flag variations with available stock at or below this.'],
90 'only_out_of_stock' => ['type' => 'boolean', 'default' => false],
91 'page' => ['type' => 'integer', 'default' => 1],
92 'per_page' => ['type' => 'integer', 'default' => 25, 'description' => 'Max 100.'],
93 ],
94 ],
95 'execute_callback' => [self::class, 'getInventory'],
96 'permission_callback' => function () {
97 return PermissionGate::can('products/view');
98 },
99 'annotations' => ['readonly' => true],
100 ],
101 ];
102 }
103
104 public static function listProducts($params = [])
105 {
106 $paging = MCPHelper::pagination($params);
107
108 // advanced_filters routes through the admin filter engine (validated
109 // first — a bad condition errors, never silently drops); the named
110 // filters below then AND onto the same query either way.
111 $advWarnings = [];
112 if (!empty($params['advanced_filters'])) {
113 $built = AdvancedSearch::buildQuery('products', $params['advanced_filters']);
114 if (is_wp_error($built)) {
115 return $built;
116 }
117 $query = $built['query'];
118 $advWarnings = $built['warnings'];
119 } else {
120 // Product model adds a global scope pinning post_type to the canonical
121 // CPT (fluent-products) — don't re-add it here, a wrong literal would
122 // AND against the scope and match nothing.
123 $query = Product::query();
124 }
125 $query->with('detail');
126
127 if (!empty($params['search'])) {
128 $query->where('post_title', 'LIKE', '%' . sanitize_text_field($params['search']) . '%');
129 }
130 if (!empty($params['status'])) {
131 $query->where('post_status', sanitize_text_field($params['status']));
132 } else {
133 $query->whereIn('post_status', ['publish', 'draft', 'private', 'pending']);
134 }
135
136 // Detail-scoped filters (fulfillment, variation type, stock, price band).
137 $detailFilters = [
138 'fulfillment_type' => isset($params['fulfillment_type']) ? sanitize_text_field($params['fulfillment_type']) : null,
139 'variation_type' => isset($params['variation_type']) ? sanitize_text_field($params['variation_type']) : null,
140 'stock_status' => isset($params['stock_status']) ? sanitize_text_field($params['stock_status']) : null,
141 'min_price' => isset($params['min_price']) ? Helper::toCent($params['min_price']) : null,
142 'max_price' => isset($params['max_price']) ? Helper::toCent($params['max_price']) : null,
143 ];
144 if (array_filter($detailFilters, function ($v) { return $v !== null; })) {
145 $query->whereHas('detail', function ($q) use ($detailFilters) {
146 if ($detailFilters['fulfillment_type'] !== null) {
147 $q->where('fulfillment_type', $detailFilters['fulfillment_type']);
148 }
149 if ($detailFilters['variation_type'] !== null) {
150 $q->where('variation_type', $detailFilters['variation_type']);
151 }
152 if ($detailFilters['stock_status'] !== null) {
153 $q->where('stock_availability', $detailFilters['stock_status']);
154 }
155 if ($detailFilters['min_price'] !== null) {
156 $q->where('min_price', '>=', $detailFilters['min_price']);
157 }
158 if ($detailFilters['max_price'] !== null) {
159 $q->where('max_price', '<=', $detailFilters['max_price']);
160 }
161 });
162 }
163
164 if (!empty($params['category'])) {
165 $cat = sanitize_text_field($params['category']);
166 $query->whereHas('categories', function ($q) use ($cat) {
167 $q->where('slug', $cat);
168 });
169 }
170
171 $sortMap = ['id' => 'ID', 'title' => 'post_title', 'date' => 'post_date'];
172 $sortBy = isset($params['sort_by']) && isset($sortMap[$params['sort_by']]) ? $sortMap[$params['sort_by']] : 'post_date';
173 $sortType = strtoupper(isset($params['sort_type']) ? $params['sort_type'] : 'DESC') === 'ASC' ? 'ASC' : 'DESC';
174 $query->orderBy($sortBy, $sortType);
175 if ($sortBy !== 'ID') {
176 $query->orderBy('ID', 'DESC');
177 }
178
179 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
180 $total = self::total($paginator);
181
182 $rows = [];
183 foreach (MCPHelper::paginatorItems($paginator) as $product) {
184 $rows[] = self::formatRow($product);
185 }
186
187 $meta = MCPHelper::pagingMeta($paginator);
188 if ($advWarnings) {
189 $meta['warnings'] = $advWarnings;
190 }
191
192 return MCPHelper::envelope(
193 sprintf(
194 /* translators: %d: number of matching products */
195 _n('%d product found.', '%d products found.', $total, 'fluent-cart'),
196 $total
197 ),
198 ['products' => $rows],
199 $meta
200 );
201 }
202
203 private static function formatRow($product)
204 {
205 $detail = ($product->relationLoaded('detail') && $product->detail) ? $product->detail : null;
206
207 return [
208 'product_id' => (int) $product->ID,
209 'title' => $product->post_title,
210 'status' => $product->post_status,
211 'price_range' => self::priceRange($detail),
212 'fulfillment_type' => $detail ? $detail->fulfillment_type : null,
213 'variation_type' => $detail ? $detail->variation_type : null,
214 'stock_status' => $detail ? $detail->stock_availability : null,
215 ];
216 }
217
218 private static function priceRange($detail)
219 {
220 if (!$detail) {
221 return null;
222 }
223 $min = (int) $detail->min_price;
224 $max = (int) $detail->max_price;
225 if ($min === $max) {
226 return ['from' => MCPHelper::moneyCompact($min), 'to' => MCPHelper::moneyCompact($max), 'single' => true];
227 }
228 return ['from' => MCPHelper::moneyCompact($min), 'to' => MCPHelper::moneyCompact($max), 'single' => false];
229 }
230
231 public static function getProduct($params = [])
232 {
233 if (empty($params['product_id'])) {
234 return MCPHelper::error('missing_identifier', __('product_id is required.', 'fluent-cart'));
235 }
236
237 // post_type is pinned by the model's global scope; filtering by ID only.
238 $product = Product::query()
239 ->where('ID', (int) $params['product_id'])
240 ->with(['detail', 'variants'])
241 ->first();
242
243 if (!$product) {
244 return MCPHelper::error('product_not_found', __('No product found for the given product_id.', 'fluent-cart'));
245 }
246
247 $include = isset($params['include']) ? (array) $params['include'] : [];
248 $detail = $product->detail;
249
250 $data = [
251 'product_id' => (int) $product->ID,
252 'title' => $product->post_title,
253 'status' => $product->post_status,
254 // Cap the detail description so a very long product body can't blow
255 // the agent's context window (generous vs the 150-char list preview).
256 'description' => MCPHelper::preview($product->post_content, 2000),
257 'fulfillment_type' => $detail ? $detail->fulfillment_type : null,
258 'variation_type' => $detail ? $detail->variation_type : null,
259 'stock_status' => $detail ? $detail->stock_availability : null,
260 'price_range' => self::priceRange($detail),
261 'variations' => self::variations($product),
262 'categories' => self::terms($product),
263 'created_at' => MCPHelper::toIso8601($product->post_date_gmt ? $product->post_date_gmt : $product->post_date),
264 ];
265
266 if (in_array('sales', $include, true)) {
267 $data['sales'] = self::salesRollup((int) $product->ID);
268 }
269 if (in_array('downloads', $include, true)) {
270 $data['downloads'] = self::downloads($product);
271 }
272
273 return MCPHelper::envelope(
274 sprintf(
275 /* translators: 1: product title, 2: product status */
276 __('Product "%1$s" — %2$s', 'fluent-cart'),
277 $product->post_title,
278 $product->post_status
279 ),
280 $data
281 );
282 }
283
284 private static function variations($product)
285 {
286 if (!$product->relationLoaded('variants')) {
287 $product->load('variants');
288 }
289 $out = [];
290 foreach ($product->variants as $v) {
291 $out[] = [
292 'variation_id' => (int) $v->id,
293 'title' => $v->variation_title,
294 'sku' => $v->sku,
295 'price' => MCPHelper::money($v->item_price),
296 'payment_type' => $v->payment_type,
297 'stock_status' => $v->stock_status,
298 'manage_stock' => (bool) $v->manage_stock,
299 'stock' => [
300 'total' => (int) $v->total_stock,
301 'available' => (int) $v->available,
302 'committed' => (int) $v->committed,
303 'on_hold' => (int) $v->on_hold,
304 ],
305 ];
306 }
307 return $out;
308 }
309
310 private static function terms($product)
311 {
312 try {
313 $terms = $product->getCategories();
314 } catch (\Throwable $e) {
315 return [];
316 }
317 $out = [];
318 foreach ((array) $terms as $term) {
319 $obj = is_object($term) ? get_object_vars($term) : (is_array($term) ? $term : []);
320 $id = isset($obj['term_id']) ? (int) $obj['term_id'] : (isset($obj['id']) ? (int) $obj['id'] : null);
321 $name = isset($obj['name']) ? $obj['name'] : null;
322 $slug = isset($obj['slug']) ? $obj['slug'] : null;
323 // Skip phantom/empty entries rather than emitting all-null objects.
324 if (!$id && ($name === null || $name === '')) {
325 continue;
326 }
327 $out[] = ['id' => $id, 'name' => $name, 'slug' => $slug];
328 }
329 return $out;
330 }
331
332 /**
333 * Lifetime sales for this product, from order items on realized-revenue
334 * orders only. Revenue is net of item-level refunds (line_total - refund_total).
335 * Scoped to paid / partially_refunded orders — unpaid/canceled items don't
336 * count, and partially_paid is excluded (partial payments are not implemented).
337 */
338 private static function salesRollup($productId)
339 {
340 $paidScope = function ($q) {
341 $q->whereIn('payment_status', ['paid', 'partially_refunded']);
342 };
343
344 $row = OrderItem::query()
345 ->where('post_id', $productId)
346 ->whereHas('order', $paidScope)
347 ->selectRaw(
348 'COALESCE(SUM(quantity), 0) as units, '
349 . 'COALESCE(SUM(line_total - refund_total), 0) as revenue, '
350 . 'COUNT(DISTINCT order_id) as orders'
351 )
352 ->first();
353
354 return [
355 'units_sold' => $row ? (int) $row->units : 0,
356 'revenue' => MCPHelper::money($row ? (int) $row->revenue : 0),
357 'order_count' => $row ? (int) $row->orders : 0,
358 ];
359 }
360
361 private static function downloads($product)
362 {
363 $product->load('downloadable_files');
364 $out = [];
365 if (!$product->relationLoaded('downloadable_files')) {
366 return $out;
367 }
368 foreach ($product->downloadable_files as $file) {
369 $out[] = [
370 'id' => (int) $file->id,
371 'title' => $file->title,
372 'size' => $file->file_size ? (int) $file->file_size : null,
373 ];
374 }
375 return $out;
376 }
377
378 public static function getInventory($params = [])
379 {
380 $paging = MCPHelper::pagination($params, 25);
381 $threshold = isset($params['threshold']) ? max((int) $params['threshold'], 0) : 5;
382 $onlyOut = !empty($params['only_out_of_stock']);
383
384 $query = ProductVariation::query()->where('manage_stock', 1);
385
386 if ($onlyOut) {
387 $query->where('stock_status', 'out-of-stock');
388 } else {
389 $query->where(function ($q) use ($threshold) {
390 $q->where('available', '<=', $threshold)->orWhere('stock_status', 'out-of-stock');
391 });
392 }
393
394 $query->orderBy('available', 'ASC')->orderBy('id', 'DESC');
395
396 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
397 $total = self::total($paginator);
398
399 $rows = [];
400 foreach (MCPHelper::paginatorItems($paginator) as $v) {
401 $rows[] = [
402 'variation_id' => (int) $v->id,
403 'product_id' => (int) $v->post_id,
404 'title' => $v->variation_title,
405 'sku' => $v->sku,
406 'stock_status' => $v->stock_status,
407 'available' => (int) $v->available,
408 'committed' => (int) $v->committed,
409 'on_hold' => (int) $v->on_hold,
410 'total' => (int) $v->total_stock,
411 ];
412 }
413
414 return MCPHelper::envelope(
415 sprintf(
416 /* translators: %d: number of variations needing attention */
417 _n('%d variation needs attention.', '%d variations need attention.', $total, 'fluent-cart'),
418 $total
419 ),
420 ['variations' => $rows],
421 MCPHelper::pagingMeta($paginator)
422 );
423 }
424
425 private static function total($paginator)
426 {
427 return MCPHelper::paginatorTotal($paginator);
428 }
429 }
430