| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace FluentCart\App\Modules\MCP\Tools; |
| 4 | 4 | |
| 5 | +use FluentCart\App\App; | |
| 6 | +use FluentCart\App\Helpers\Status; | |
| 5 | 7 | use FluentCart\App\Models\Order; |
| 6 | 8 | use FluentCart\App\Models\Customer; |
| 7 | 9 | use FluentCart\App\Models\Subscription; |
| 8 | 10 | use FluentCart\App\Modules\MCP\AbilitiesRegistrar; |
| @@ -33,22 +35,28 @@ | ||
| 33 | 35 | const CACHE_TTL = 60; |
| 34 | 36 | |
| 35 | 37 | const CACHE_PREFIX = 'fluent_cart_mcp_context_'; |
| 36 | 38 | |
| 37 | - // The verified FluentCart domain enums. Hardcoded (with a filter override) | |
| 38 | - // rather than scraped, so the agent always gets the complete valid set even | |
| 39 | - // if a status currently has zero rows. | |
| 39 | + // Baseline domain enums. The status families are re-read from the canonical | |
| 40 | + // Status helper at runtime by enums() — this literal is only the fallback for | |
| 41 | + // a family Status cannot answer for. Do not hand-maintain the status lists | |
| 42 | + // here: an enum the agent trusts but the column can never hold turns every | |
| 43 | + // filter built from it into a silent zero-row result. | |
| 40 | 44 | const ENUMS = [ |
| 41 | - 'order_statuses' => ['draft', 'pending', 'on-hold', 'processing', 'completed', 'canceled', 'failed', 'refunded', 'partial-refund'], | |
| 42 | - // Kept in sync with Status::getPaymentStatuses(); 'authorized' is a valid | |
| 43 | - // persisted status (card authorized, not yet captured) and must be listed | |
| 44 | - // so clients can filter authorized orders through list-orders. | |
| 45 | - 'payment_statuses' => ['paid', 'pending', 'failed', 'refunded', 'partially_refunded', 'partially_paid', 'authorized'], | |
| 45 | + // Status::getOrderStatuses() plus PERSISTED_ONLY_ORDER_STATUSES — see that | |
| 46 | + // constant for why the canonical helper is not the whole set. | |
| 47 | + // 'partial-refund' is deliberately absent: unlike the others below, no code | |
| 48 | + // path writes it (the column COMMENT lists it, but nothing persists it). | |
| 49 | + 'order_statuses' => ['draft', 'pending', 'processing', 'completed', 'on-hold', 'canceled', 'failed', 'refunded'], | |
| 50 | + // Kept in sync with Status::getPaymentStatuses(); 'authorized' (card | |
| 51 | + // authorized, not yet captured) and 'payment_scheduled' are valid | |
| 52 | + // persisted statuses and must be listed so clients can filter them. | |
| 53 | + 'payment_statuses' => ['pending', 'paid', 'partially_paid', 'failed', 'refunded', 'partially_refunded', 'authorized', 'payment_scheduled'], | |
| 46 | 54 | // 'none' = no shipping required (e.g. digital orders); reported when the |
| 47 | 55 | // stored value is empty. It is read-only — change-order-status won't set it. |
| 48 | 56 | 'shipping_statuses' => ['none', 'unshipped', 'shipped', 'delivered', 'unshippable'], |
| 49 | 57 | 'order_types' => ['payment', 'renewal', 'subscription'], |
| 50 | - 'subscription_statuses' => ['active', 'trialing', 'paused', 'canceled', 'failing', 'expired', 'expiring', 'past_due', 'intended', 'pending', 'completed'], | |
| 58 | + 'subscription_statuses' => ['pending', 'active', 'failing', 'paused', 'expired', 'expiring', 'canceled', 'trialing', 'intended', 'past_due', 'completed'], | |
| 51 | 59 | // installment = fixed-term split-pay plan (a lifetime license paid off in |
| 52 | 60 | // a finite number of charges, bill_times > 0); recurring = open-ended |
| 53 | 61 | // subscription (bill_times = 0). Derived from bill_times, never the title. |
| 54 | 62 | 'plan_types' => ['installment', 'recurring'], |
| @@ -57,8 +65,87 @@ | ||
| 57 | 65 | 'coupon_types' => ['fixed', 'percentage'], |
| 58 | 66 | 'order_modes' => ['live', 'test'], |
| 59 | 67 | ]; |
| 60 | 68 | |
| 69 | + /** | |
| 70 | + * Order statuses fct_orders.status genuinely holds that Status::getOrderStatuses() | |
| 71 | + * does NOT list, because that helper answers "what may an admin SET an order to", | |
| 72 | + * not "what can this column contain". | |
| 73 | + * | |
| 74 | + * An enum is wrong in two directions, and only one of them is loud. Listing a | |
| 75 | + * value the column can never hold gives the agent a filter that silently returns | |
| 76 | + * zero rows. OMITTING a value the column does hold is worse: those rows become | |
| 77 | + * unreachable, and because the value is missing from the input_schema enum the | |
| 78 | + * call is rejected outright, so the agent cannot even discover the rows exist. | |
| 79 | + * | |
| 80 | + * Each of these is written by a core path, verified in source: | |
| 81 | + * - draft: the column DEFAULT (database/Migrations/OrdersMigrator.php). | |
| 82 | + * - pending: every store-managed renewal invoice | |
| 83 | + * (StoreManagedRenewal/Services/RenewalService.php:113). | |
| 84 | + * - refunded: the WooCommerce migrator maps wc-refunded to it | |
| 85 | + * (WooCommerceMigrator/Services/OrderMigrationService.php). | |
| 86 | + * | |
| 87 | + * So a store using store-managed renewals, or migrated from WooCommerce, has rows | |
| 88 | + * the five-value helper cannot describe. Keep this list in step with the writers, | |
| 89 | + * not with the admin dropdown. | |
| 90 | + * | |
| 91 | + * Note COD is NOT one of them: a COD checkout creates the order as 'on-hold' and | |
| 92 | + * only its payment_status is pending. Cod::maybeUpdatePayments() looks like an | |
| 93 | + * order-status writer but has no callers. | |
| 94 | + */ | |
| 95 | + const PERSISTED_ONLY_ORDER_STATUSES = ['draft', 'pending', 'refunded']; | |
| 96 | + | |
| 97 | + /** | |
| 98 | + * The enums the agent is told to trust, with every status family re-read from | |
| 99 | + * the canonical Status helper so this payload can never drift from what the | |
| 100 | + * columns actually hold (a drifted enum is worse than a missing one — the | |
| 101 | + * agent builds a valid-looking filter that always returns zero rows). | |
| 102 | + * | |
| 103 | + * Status::get*Statuses() are themselves filtered, so a Pro/add-on status | |
| 104 | + * registered through those hooks shows up here automatically. | |
| 105 | + * | |
| 106 | + * @return array | |
| 107 | + */ | |
| 108 | + public static function enums() | |
| 109 | + { | |
| 110 | + $enums = self::ENUMS; | |
| 111 | + | |
| 112 | + $live = [ | |
| 113 | + 'order_statuses' => [Status::class, 'getOrderStatuses'], | |
| 114 | + 'payment_statuses' => [Status::class, 'getPaymentStatuses'], | |
| 115 | + 'shipping_statuses' => [Status::class, 'getShippingStatuses'], | |
| 116 | + 'subscription_statuses' => [Status::class, 'getSubscriptionStatuses'], | |
| 117 | + ]; | |
| 118 | + | |
| 119 | + foreach ($live as $key => $callable) { | |
| 120 | + try { | |
| 121 | + $values = array_values(array_map('strval', array_keys((array) call_user_func($callable)))); | |
| 122 | + } catch (\Throwable $e) { | |
| 123 | + // Keep the baseline rather than shipping an empty enum: an empty | |
| 124 | + // list reads as "no valid values" and blocks every filter. | |
| 125 | + continue; | |
| 126 | + } | |
| 127 | + if (!$values) { | |
| 128 | + continue; | |
| 129 | + } | |
| 130 | + // 'none' is an MCP-only reported value (empty stored shipping status) | |
| 131 | + // that Status has no constant for — re-add it after the live overlay. | |
| 132 | + if ($key === 'shipping_statuses') { | |
| 133 | + array_unshift($values, 'none'); | |
| 134 | + } | |
| 135 | + // Statuses the column holds that the helper does not list. Unioned, not | |
| 136 | + // overwritten: getOrderStatuses() is the settable list, so overwriting | |
| 137 | + // would drop 'pending'/'draft'/'refunded' and make those real rows | |
| 138 | + // unfilterable. See PERSISTED_ONLY_ORDER_STATUSES. | |
| 139 | + if ($key === 'order_statuses') { | |
| 140 | + $values = array_merge($values, self::PERSISTED_ONLY_ORDER_STATUSES); | |
| 141 | + } | |
| 142 | + $enums[$key] = array_values(array_unique($values)); | |
| 143 | + } | |
| 144 | + | |
| 145 | + return $enums; | |
| 146 | + } | |
| 147 | + | |
| 61 | 148 | // Payment statuses that count as realized revenue. Centralized so every |
| 62 | 149 | // tool (context, reports, aggregates) agrees on what "paid" means. |
| 63 | 150 | const PAID_STATUSES = ['paid', 'partially_paid', 'partially_refunded']; |
| 64 | 151 | |
| @@ -138,12 +225,19 @@ | ||
| 138 | 225 | $store = [ |
| 139 | 226 | 'name' => get_bloginfo('name'), |
| 140 | 227 | 'url' => site_url(), |
| 141 | 228 | 'version' => defined('FLUENTCART_VERSION') ? FLUENTCART_VERSION : null, |
| 142 | - 'pro_active' => defined('FLUENT_CART_PRO') || defined('FLUENTCART_PRO_VERSION'), | |
| 229 | + // Must agree with the App::isProActive() check the Pro-gated paths | |
| 230 | + // (advanced_filters, get-search-schema) actually run — a false here on | |
| 231 | + // a Pro store makes an agent skip the whole advanced-search surface. | |
| 232 | + 'pro_active' => App::isProActive(), | |
| 143 | 233 | 'currency' => MCPHelper::currencyContext(), |
| 144 | 234 | 'timezone' => wp_timezone_string(), |
| 145 | 235 | 'current_time' => MCPHelper::toIso8601(DateTime::gmtNow()), |
| 236 | + // Named for the capability rather than the licence, so an agent does | |
| 237 | + // not have to infer what pro_active buys it before spending a call on | |
| 238 | + // get-search-schema (which rejects outright without Pro). | |
| 239 | + 'advanced_search' => App::isProActive() ? 'available' : 'unavailable', | |
| 146 | 240 | ]; |
| 147 | 241 | |
| 148 | 242 | // Headline stats are dashboard data: gate them on dashboard_stats/view so |
| 149 | 243 | // a narrow read role can still get context (enums, currency, permissions) |
| @@ -156,9 +250,9 @@ | ||
| 156 | 250 | [ |
| 157 | 251 | 'you' => $you, |
| 158 | 252 | 'store' => $store, |
| 159 | 253 | 'stats' => $stats, |
| 160 | - 'enums' => apply_filters('fluent_cart/mcp_enums', self::ENUMS), | |
| 254 | + 'enums' => apply_filters('fluent_cart/mcp_enums', self::enums()), | |
| 161 | 255 | 'reference_kinds' => self::referenceKinds(), |
| 162 | 256 | 'tool_index' => self::toolIndex(), |
| 163 | 257 | 'guidelines' => self::guidelines(), |
| 164 | 258 | ] |
| @@ -262,8 +356,9 @@ | ||
| 262 | 356 | // [category, one-line "use this when…"], keyed by ability name. |
| 263 | 357 | $hints = [ |
| 264 | 358 | 'fluent-cart/get-store-context' => ['discovery', 'Call first — identity, permissions, currency, enums, headline stats, and this index.'], |
| 265 | 359 | 'fluent-cart/list-reference-data' => ['discovery', 'Resolve names to ids: coupons, labels, gateways, tax classes, shipping zones, product categories.'], |
| 360 | + 'fluent-cart/get-search-schema' => ['discovery', 'The advanced_filters reference for one entity — every filterable property, operators, value formats. Call before building an advanced search.'], | |
| 266 | 361 | 'fluent-cart/list-orders' => ['find', 'Find orders by status / payment / customer / product / date.'], |
| 267 | 362 | 'fluent-cart/list-customers' => ['find', 'Find customers by name / email / location / LTV.'], |
| 268 | 363 | 'fluent-cart/list-products' => ['find', 'Find products by title / category / price.'], |
| 269 | 364 | 'fluent-cart/list-subscriptions' => ['find', 'Find subscriptions by status / plan / product; summary_only for a fast aggregate.'], |
| @@ -319,8 +414,14 @@ | ||
| 319 | 414 | . 'Money is returned as both a number (amount) and a formatted string (display) — quote display, compare amount. ' |
| 320 | 415 | . 'Dates are ISO-8601 UTC; pass a relative range (e.g. last_30_days) or explicit start_date/end_date to report tools. ' |
| 321 | 416 | . 'Use the exact enum values from this payload — never invent a status. ' |
| 322 | 417 | . 'Reports never sum across currencies; filter by one currency if the store has several. ' |
| 418 | + // Stated as a fact about THIS store, not a generic "requires Pro": | |
| 419 | + // an agent that reads the generic form still builds the filter and | |
| 420 | + // only discovers the gate when the call is rejected. | |
| 421 | + . (App::isProActive() | |
| 422 | + ? 'When a list tool\'s named filters cannot express a segmentation (OR groups, relative dates, per-property operators, relation properties like transactions/UTM/labels), call get-search-schema for the entity and pass advanced_filters to its list tool. ' | |
| 423 | + : 'Advanced search is UNAVAILABLE on this store (store.advanced_search = unavailable): FluentCart Pro is not active, so get-search-schema and the advanced_filters parameter will be rejected. Do not build advanced_filters — use the named filters on the list-* tools and the query-* tools for aggregation. ') | |
| 323 | 424 | . 'Writes (refund-order, change-subscription-status:cancel) require a dry_run preview first.'; |
| 324 | 425 | |
| 325 | 426 | return apply_filters('fluent_cart/mcp_guidelines', $default); |
| 326 | 427 | } |