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

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