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/Modules/MCP/Tools/OrderTools.php +95 -17 1.5.3 → 1.6.5 View file →
@@ -5,8 +5,9 @@
5 5 use FluentCart\App\Helpers\Helper;
6 6 use FluentCart\App\Helpers\Status;
7 7 use FluentCart\App\Models\Order;
8 8 use FluentCart\App\Models\OrderTransaction;
9 +use FluentCart\App\Modules\MCP\Support\AdvancedSearch;
9 10 use FluentCart\App\Modules\MCP\Support\MCPHelper;
10 11 use FluentCart\App\Modules\MCP\Support\PermissionGate;
11 12 use FluentCart\App\Modules\MCP\Support\WriteGuard;
12 13 use FluentCart\App\Services\Payments\Refund;
@@ -34,28 +35,29 @@
34 35 class OrderTools
35 36 {
36 37 public static function definitions()
37 38 {
38 - $orderStatuses = ContextTools::ENUMS['order_statuses'];
39 - $paymentStatuses = ContextTools::ENUMS['payment_statuses'];
40 - $shippingStatuses = ContextTools::ENUMS['shipping_statuses'];
39 + $enums = ContextTools::enums();
40 + $orderStatuses = $enums['order_statuses'];
41 + $paymentStatuses = $enums['payment_statuses'];
42 + $shippingStatuses = $enums['shipping_statuses'];
41 43 // change-order-status cannot set an order back to "no shipping required".
42 44 $shippingWritable = array_values(array_diff($shippingStatuses, ['none']));
43 - // Only the statuses core actually accepts for a manual change — a subset
44 - // of the full order_statuses enum used for filtering (no draft/pending/
45 - // refunded/partial-refund: those are reached via payment/refund flows).
45 + // Only the statuses core accepts for a manual change — a subset of the
46 + // order_statuses enum used for filtering ('failed' is reached through a
47 + // payment flow, not a manual set).
46 48 $orderWritable = array_keys(Status::getEditableOrderStatuses());
47 - $orderTypes = ContextTools::ENUMS['order_types'];
49 + $orderTypes = $enums['order_types'];
48 50
49 51 return [
50 52 'fluent-cart/list-orders' => [
51 53 'label' => __('List Orders', 'fluent-cart'),
52 - 'description' => __('Find and filter orders. Returns compact rows (id, number, customer, total, statuses, date, plus an items list: each line item\'s product, title and quantity) — call get-order for the full money/refund breakdown. All filters optional; combine freely. For one customer\'s orders, pass customer_email or customer_id here. Money filters are in store currency (e.g. 49.99), not cents.', 'fluent-cart'),
54 + 'description' => __('Find and filter orders. Returns compact rows (id, number, customer, total, statuses, date, plus an items list: each line item\'s product, title and quantity) — call get-order for the full money/refund breakdown. All filters optional; combine freely. For one customer\'s orders, pass customer_email or customer_id here. Money filters are in store currency (e.g. 49.99), not cents. For conditions these flat filters cannot express (OR groups, relative dates, transaction/UTM/label/license properties) pass advanced_filters — call get-search-schema entity=orders first (Pro).', 'fluent-cart'),
53 55 'input_schema' => [
54 56 'type' => 'object',
55 57 'properties' => [
56 - 'status' => ['type' => 'string', 'enum' => $orderStatuses, 'description' => 'Order fulfillment/lifecycle status.'],
57 - 'payment_status' => ['type' => 'string', 'enum' => $paymentStatuses],
58 + 'status' => ['type' => 'string', 'enum' => $orderStatuses, 'description' => 'Order fulfillment/lifecycle status. To find refunded orders use payment_status (refunded / partially_refunded), NOT this field: refunds are recorded as payment state, and status=refunded only ever appears on stores migrated from WooCommerce. pending here means an unpaid store-managed renewal invoice, which is not the same as payment_status=pending; a COD order sits at status=on-hold with payment_status=pending.'],
59 + 'payment_status' => ['type' => 'string', 'enum' => $paymentStatuses, 'description' => 'Money state of the order — this is where refunded, partially_refunded, authorized and payment_scheduled live.'],
58 60 'shipping_status' => ['type' => 'string', 'enum' => $shippingStatuses],
59 61 'type' => ['type' => 'string', 'enum' => $orderTypes, 'description' => 'payment = first purchase, renewal = subscription renewal.'],
60 62 'customer_id' => ['type' => 'integer'],
61 63 'customer_email' => ['type' => 'string', 'description' => 'Exact email — the most reliable customer filter.'],
@@ -68,8 +70,9 @@
68 70 'created_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
69 71 'created_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
70 72 'mode' => ['type' => 'string', 'enum' => ['live', 'test'], 'description' => 'Defaults to all modes.'],
71 73 'search' => ['type' => 'string', 'description' => 'Matches invoice/receipt number, order uuid, and customer name/email.'],
74 + '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=orders FIRST for properties/operators/format. AND-combines with the other filters here. An empty array means no advanced filter.'],
72 75 'sort_by' => ['type' => 'string', 'enum' => ['id', 'created_at', 'completed_at', 'total_amount'], 'default' => 'id'],
73 76 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
74 77 'fields' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Optional: return only these row keys to shrink the payload (order_id is always kept). Available: number, label, status, payment_status, shipping_status, type, total, customer, items, created_at. Omit for the full row.'],
75 78 'page' => ['type' => 'integer', 'default' => 1],
@@ -84,9 +87,13 @@
84 87 ],
85 88
86 89 'fluent-cart/get-order' => [
87 90 'label' => __('Get Order', 'fluent-cart'),
88 - 'description' => __('Full detail for one order: money breakdown, line items, and customer by default. Add include[] for transactions, refunds, addresses, coupons, subscriptions. Identify the order by order_id (numeric, from list-orders) OR uuid OR invoice_no.', 'fluent-cart'),
91 + 'description' => sprintf(
92 + /* translators: %1$s: comma-separated include[] section names */
93 + __('Full detail for one order: money breakdown, line items, and customer by default. Add include[] for any of: %1$s. Identify the order by order_id (numeric, from list-orders) OR uuid OR invoice_no.', 'fluent-cart'),
94 + implode(', ', self::includeSections())
95 + ),
89 96 'input_schema' => [
90 97 'type' => 'object',
91 98 'properties' => [
92 99 'order_id' => ['type' => 'integer', 'description' => 'Numeric order id as returned by list-orders.'],
@@ -94,9 +101,9 @@
94 101 'invoice_no' => ['type' => 'string'],
95 102 'include' => [
96 103 'type' => 'array',
97 104 'description' => 'Optional heavier sections. items + customer are always included.',
98 - 'items' => ['type' => 'string', 'enum' => ['transactions', 'refunds', 'addresses', 'coupons', 'subscriptions']],
105 + 'items' => ['type' => 'string', 'enum' => self::includeSections()],
99 106 ],
100 107 'fields' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Optional: return only these top-level keys to shrink the payload (order_id is always kept). e.g. status, payment_status, totals, items, customer. Applies after include[]. Omit for the full record.'],
101 108 ],
102 109 ],
@@ -201,14 +208,29 @@
201 208 public static function listOrders($params = [])
202 209 {
203 210 $paging = MCPHelper::pagination($params);
204 211
212 + // advanced_filters routes through the admin filter engine (validated
213 + // first — a bad condition errors, never silently drops); the named
214 + // filters below then AND onto the same query either way.
215 + $advWarnings = [];
216 + if (!empty($params['advanced_filters'])) {
217 + $built = AdvancedSearch::buildQuery('orders', $params['advanced_filters']);
218 + if (is_wp_error($built)) {
219 + return $built;
220 + }
221 + $query = $built['query'];
222 + $advWarnings = $built['warnings'];
223 + } else {
224 + $query = Order::query();
225 + }
226 +
205 227 // Eager-load customer plus a TRIMMED order_items relation — only the
206 228 // columns needed for a "what's in this order" preview, never the full
207 229 // money/refund/fulfillment row (that's get-order's job). formatRow caps
208 230 // the preview, so even a large multi-item order can't flood the payload.
209 231 // The product_id filter uses whereHas (a join), independent of this load.
210 - $query = Order::query()->with([
232 + $query->with([
211 233 'customer',
212 234 'order_items' => function ($q) {
213 235 $q->select(['id', 'order_id', 'post_id', 'post_title', 'title', 'quantity']);
214 236 },
@@ -237,8 +259,13 @@
237 259 foreach (MCPHelper::paginatorItems($paginator) as $order) {
238 260 $rows[] = MCPHelper::pickFields(self::formatRow($order), $fields, ['order_id']);
239 261 }
240 262
263 + $meta = MCPHelper::pagingMeta($paginator);
264 + if ($advWarnings) {
265 + $meta['warnings'] = $advWarnings;
266 + }
267 +
241 268 return MCPHelper::envelope(
242 269 sprintf(
243 270 /* translators: %d: number of matching orders */
244 271 _n('%d order found.', '%d orders found.', $total, 'fluent-cart'),
@@ -244,9 +271,9 @@
244 271 _n('%d order found.', '%d orders found.', $total, 'fluent-cart'),
245 272 $total
246 273 ),
247 274 ['orders' => $rows],
248 - MCPHelper::pagingMeta($paginator)
275 + $meta
249 276 );
250 277 }
251 278
252 279 private static function applyFilters($query, $params)
@@ -374,9 +401,14 @@
374 401 $customer = ($order->relationLoaded('customer') && $order->customer) ? $order->customer : null;
375 402
376 403 return [
377 404 'order_id' => (int) $order->id,
378 - 'number' => $order->invoice_no ? $order->invoice_no : (string) $order->id,
405 + // null, not the raw id: an invoice number is only assigned once the
406 + // order is paid, and echoing the id here made unpaid orders look like
407 + // they had a number in a different format from every other row (and
408 + // disagreed with get-order, which already returns null). order_id is
409 + // right above it for referencing the record.
410 + 'number' => $order->invoice_no ? $order->invoice_no : null,
379 411 'label' => self::label($order, $customer),
380 412 'status' => $order->status,
381 413 'payment_status' => $order->payment_status,
382 414 'shipping_status' => self::shippingStatusOut($order),
@@ -451,10 +483,13 @@
451 483
452 484 $data = [
453 485 'order_id' => (int) $order->id,
454 486 'uuid' => $order->uuid,
455 - 'number' => $order->invoice_no,
456 - 'receipt_number' => $order->receipt_number,
487 + // Normalized to null when unassigned — the column stores '' for an
488 + // order that has not been invoiced yet, and an empty string reads as
489 + // "the number is blank" rather than "there is no number".
490 + 'number' => $order->invoice_no ? $order->invoice_no : null,
491 + 'receipt_number' => $order->receipt_number ? $order->receipt_number : null,
457 492 'status' => $order->status,
458 493 'payment_status' => $order->payment_status,
459 494 'shipping_status' => self::shippingStatusOut($order),
460 495 'type' => $order->type,
@@ -483,13 +518,56 @@
483 518 if (in_array('subscriptions', $include, true)) {
484 519 $data['subscriptions'] = self::subscriptionsBlock($order);
485 520 }
486 521
522 + /**
523 + * The assembled get-order payload, for add-on sections registered through
524 + * fluent_cart/mcp_order_include_sections. Listeners should add their key
525 + * only when it is present in $context['include'].
526 + *
527 + * @since 1.0.0
528 + *
529 + * @param array $data the order payload
530 + * @param array $context { order: Order, include: string[] }
531 + */
532 + $data = apply_filters('fluent_cart/mcp_order_data', $data, [
533 + 'order' => $order,
534 + 'include' => $include,
535 + ]);
536 +
487 537 // fields projection runs last, so it can trim both the base record and any
488 538 // include[] sections; order_id is always kept.
489 539 $fields = isset($params['fields']) ? $params['fields'] : null;
490 540
491 541 return MCPHelper::envelope(self::label($order, $order->customer), MCPHelper::pickFields($data, $fields, ['order_id']));
542 + }
543 +
544 + /**
545 + * The sections get-order's include[] accepts. Filterable so an integration
546 + * that owns order-adjacent context (the CRM contact behind the buyer, for
547 + * one) can offer it as an include rather than leaving the agent to guess
548 + * which other tool holds it.
549 + *
550 + * A section added here MUST be populated by a listener on
551 + * fluent_cart/mcp_order_data — an include the schema advertises but nothing
552 + * fills is worse than no include at all.
553 + *
554 + * @return array
555 + */
556 + private static function includeSections()
557 + {
558 + $sections = ['transactions', 'refunds', 'addresses', 'coupons', 'subscriptions'];
559 +
560 + /**
561 + * Extra include[] section names for get-order.
562 + *
563 + * @since 1.0.0
564 + *
565 + * @param array $sections section names offered in the include[] enum
566 + */
567 + $sections = apply_filters('fluent_cart/mcp_order_include_sections', $sections);
568 +
569 + return array_values(array_unique(array_map('strval', (array) $sections)));
492 570 }
493 571
494 572 private static function resolveOrder($params)
495 573 {