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

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

431 lines 19.6 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, 'categories'),
263 'tags' => self::terms($product, 'tags'),
264 'created_at' => MCPHelper::toIso8601($product->post_date_gmt ? $product->post_date_gmt : $product->post_date),
265 ];
266
267 if (in_array('sales', $include, true)) {
268 $data['sales'] = self::salesRollup((int) $product->ID);
269 }
270 if (in_array('downloads', $include, true)) {
271 $data['downloads'] = self::downloads($product);
272 }
273
274 return MCPHelper::envelope(
275 sprintf(
276 /* translators: 1: product title, 2: product status */
277 __('Product "%1$s" — %2$s', 'fluent-cart'),
278 $product->post_title,
279 $product->post_status
280 ),
281 $data
282 );
283 }
284
285 private static function variations($product)
286 {
287 if (!$product->relationLoaded('variants')) {
288 $product->load('variants');
289 }
290 $out = [];
291 foreach ($product->variants as $v) {
292 $out[] = [
293 'variation_id' => (int) $v->id,
294 'title' => $v->variation_title,
295 'sku' => $v->sku,
296 'price' => MCPHelper::money($v->item_price),
297 'payment_type' => $v->payment_type,
298 'stock_status' => $v->stock_status,
299 'manage_stock' => (bool) $v->manage_stock,
300 'stock' => [
301 'total' => (int) $v->total_stock,
302 'available' => (int) $v->available,
303 'committed' => (int) $v->committed,
304 'on_hold' => (int) $v->on_hold,
305 ],
306 ];
307 }
308 return $out;
309 }
310
311 private static function terms($product, $which)
312 {
313 try {
314 $terms = $which === 'tags' ? $product->getTags() : $product->getCategories();
315 } catch (\Throwable $e) {
316 return [];
317 }
318 $out = [];
319 foreach ((array) $terms as $term) {
320 $obj = is_object($term) ? get_object_vars($term) : (is_array($term) ? $term : []);
321 $id = isset($obj['term_id']) ? (int) $obj['term_id'] : (isset($obj['id']) ? (int) $obj['id'] : null);
322 $name = isset($obj['name']) ? $obj['name'] : null;
323 $slug = isset($obj['slug']) ? $obj['slug'] : null;
324 // Skip phantom/empty entries rather than emitting all-null objects.
325 if (!$id && ($name === null || $name === '')) {
326 continue;
327 }
328 $out[] = ['id' => $id, 'name' => $name, 'slug' => $slug];
329 }
330 return $out;
331 }
332
333 /**
334 * Lifetime sales for this product, from order items on realized-revenue
335 * orders only. Revenue is net of item-level refunds (line_total - refund_total).
336 * Scoped to paid / partially_refunded orders — unpaid/canceled items don't
337 * count, and partially_paid is excluded (partial payments are not implemented).
338 */
339 private static function salesRollup($productId)
340 {
341 $paidScope = function ($q) {
342 $q->whereIn('payment_status', ['paid', 'partially_refunded']);
343 };
344
345 $row = OrderItem::query()
346 ->where('post_id', $productId)
347 ->whereHas('order', $paidScope)
348 ->selectRaw(
349 'COALESCE(SUM(quantity), 0) as units, '
350 . 'COALESCE(SUM(line_total - refund_total), 0) as revenue, '
351 . 'COUNT(DISTINCT order_id) as orders'
352 )
353 ->first();
354
355 return [
356 'units_sold' => $row ? (int) $row->units : 0,
357 'revenue' => MCPHelper::money($row ? (int) $row->revenue : 0),
358 'order_count' => $row ? (int) $row->orders : 0,
359 ];
360 }
361
362 private static function downloads($product)
363 {
364 $product->load('downloadable_files');
365 $out = [];
366 if (!$product->relationLoaded('downloadable_files')) {
367 return $out;
368 }
369 foreach ($product->downloadable_files as $file) {
370 $out[] = [
371 'id' => (int) $file->id,
372 'title' => $file->title,
373 'size' => $file->file_size ? (int) $file->file_size : null,
374 ];
375 }
376 return $out;
377 }
378
379 public static function getInventory($params = [])
380 {
381 $paging = MCPHelper::pagination($params, 25);
382 $threshold = isset($params['threshold']) ? max((int) $params['threshold'], 0) : 5;
383 $onlyOut = !empty($params['only_out_of_stock']);
384
385 $query = ProductVariation::query()->where('manage_stock', 1);
386
387 if ($onlyOut) {
388 $query->where('stock_status', 'out-of-stock');
389 } else {
390 $query->where(function ($q) use ($threshold) {
391 $q->where('available', '<=', $threshold)->orWhere('stock_status', 'out-of-stock');
392 });
393 }
394
395 $query->orderBy('available', 'ASC')->orderBy('id', 'DESC');
396
397 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
398 $total = self::total($paginator);
399
400 $rows = [];
401 foreach (MCPHelper::paginatorItems($paginator) as $v) {
402 $rows[] = [
403 'variation_id' => (int) $v->id,
404 'product_id' => (int) $v->post_id,
405 'title' => $v->variation_title,
406 'sku' => $v->sku,
407 'stock_status' => $v->stock_status,
408 'available' => (int) $v->available,
409 'committed' => (int) $v->committed,
410 'on_hold' => (int) $v->on_hold,
411 'total' => (int) $v->total_stock,
412 ];
413 }
414
415 return MCPHelper::envelope(
416 sprintf(
417 /* translators: %d: number of variations needing attention */
418 _n('%d variation needs attention.', '%d variations need attention.', $total, 'fluent-cart'),
419 $total
420 ),
421 ['variations' => $rows],
422 MCPHelper::pagingMeta($paginator)
423 );
424 }
425
426 private static function total($paginator)
427 {
428 return MCPHelper::paginatorTotal($paginator);
429 }
430 }
431