| @@ -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 | { |