| @@ -7,8 +7,9 @@ | ||
| 7 | 7 | |
| 8 | 8 | namespace WCPOS\WooCommercePOS\Sync; |
| 9 | 9 | |
| 10 | 10 | use Automattic\WooCommerce\Utilities\OrderUtil; |
| 11 | +use WCPOS\WooCommercePOS\Services\Barcode_Field; | |
| 11 | 12 | use WP_REST_Request; |
| 12 | 13 | |
| 13 | 14 | /** |
| 14 | 15 | * THE declaration table for POS collection query behaviour — one Collection Rule per |
| @@ -33,11 +34,11 @@ | ||
| 33 | 34 | * collection yields an EMPTY plan whose `filter()` is the identity and whose |
| 34 | 35 | * `around()` merely runs its callable. That is the adoption mechanism: a collection |
| 35 | 36 | * can be routed through the module before it has any rows, and nothing changes. |
| 36 | 37 | * - `Collection_Rules_Plan::filter()` — the direct lane. Type-preserving clause |
| 37 | - * bodies; it NEVER touches global filter state, so a v1 controller keeps owning its | |
| 38 | - * own `add_filter` topology (Pro subclasses those callbacks). | |
| 39 | - * - `Collection_Rules_Plan::around()` — the proxy lane, and the ONLY install path. | |
| 38 | + * bodies; it NEVER touches global filter state. Legacy callbacks can still delegate | |
| 39 | + * to it; collection reads use `around()` for search and visibility. | |
| 40 | + * - `Collection_Rules_Plan::around()` — the scoped read lanes, and the ONLY install path. | |
| 40 | 41 | * Callbacks are installed, the forward runs, and every binding is unwound in reverse |
| 41 | 42 | * in a `finally`. |
| 42 | 43 | * - `orderby_enum()` / `collection_params()` — schema PROJECTIONS of the same rows, so |
| 43 | 44 | * the REST schema and the proxy's claim list cannot disagree with the clause logic. |
| @@ -77,9 +78,30 @@ | ||
| 77 | 78 | * @var string |
| 78 | 79 | */ |
| 79 | 80 | public const STORAGE_HPOS = 'hpos'; |
| 80 | 81 | |
| 82 | + /** Shared search bound; over-limit policies remain collection-specific. */ | |
| 83 | + public const SEARCH_TERM_CAP = 10; | |
| 84 | + | |
| 81 | 85 | /** |
| 86 | + * Split literal terms. Orders now also drop Unicode control characters between terms. | |
| 87 | + * | |
| 88 | + * Existing defaults: product phrase null uses WP_Query terms; orders use the supplied | |
| 89 | + * string; variation args/discovery default to '', and validation casts null to ''. | |
| 90 | + * Product phrases collapse over-cap terms; orders slice; v2 variations reject. | |
| 91 | + * Every product/variation lane uses this splitter; v1 collapses over-cap, v2 flat variations reject. | |
| 92 | + * Malformed UTF-8 yields an empty array, including offset-capture callers. | |
| 93 | + * | |
| 94 | + * @param string $search Search text. | |
| 95 | + * @param int $flags Split flags. | |
| 96 | + * @return array | |
| 97 | + */ | |
| 98 | + public static function search_terms( $search, $flags = PREG_SPLIT_NO_EMPTY ) { | |
| 99 | + $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', $search, -1, $flags ); | |
| 100 | + return false === $terms ? array() : $terms; | |
| 101 | + } | |
| 102 | + | |
| 103 | + /** | |
| 82 | 104 | * Legacy storage — `wp_posts` plus `wp_postmeta`. |
| 83 | 105 | * |
| 84 | 106 | * @var string |
| 85 | 107 | */ |
| @@ -85,9 +107,9 @@ | ||
| 85 | 107 | */ |
| 86 | 108 | public const STORAGE_POSTS = 'posts'; |
| 87 | 109 | |
| 88 | 110 | /** |
| 89 | - * Memoized plans, keyed by collection, request identity, storage and param map. | |
| 111 | + * Memoized plans, keyed by collection, request identity/content, storage and param map. | |
| 90 | 112 | * |
| 91 | 113 | * Each entry is `array( WP_REST_Request, Collection_Rules_Plan )`; the request is |
| 92 | 114 | * kept so a recycled `spl_object_id` can never serve another request's plan. |
| 93 | 115 | * |
| @@ -116,9 +138,9 @@ | ||
| 116 | 138 | * @return Collection_Rules_Plan |
| 117 | 139 | */ |
| 118 | 140 | public static function for_request( string $collection, WP_REST_Request $request, array $param_map = array(), ?string $storage = null ) { |
| 119 | 141 | $storage = $storage ?? self::detect_storage( $collection ); |
| 120 | - $key = $collection . '|' . spl_object_id( $request ) . '|' . $storage . '|' . md5( (string) wp_json_encode( $param_map ) ); | |
| 142 | + $key = $collection . '|' . spl_object_id( $request ) . '|' . $storage . '|' . md5( (string) wp_json_encode( array( $param_map, $request->get_route(), $request->get_params() ) ) ); | |
| 121 | 143 | |
| 122 | 144 | if ( isset( self::$plans[ $key ] ) && self::$plans[ $key ][0] === $request ) { |
| 123 | 145 | return self::$plans[ $key ][1]; |
| 124 | 146 | } |
| @@ -195,8 +217,13 @@ | ||
| 195 | 217 | * - `posts` => `array( 'posts_orderby' => <wp_posts column> )` for a column the |
| 196 | 218 | * WP_Query `orderby` vocabulary cannot express (rewritten through |
| 197 | 219 | * `posts_orderby`), OR |
| 198 | 220 | * `array( 'meta_key' => ..., 'orderby' => meta_value|meta_value_num )`. |
| 221 | + * - `posts` => `array( 'meta_sort' => array( 'key' => ..., 'numeric' => bool ) )` | |
| 222 | + * a postmeta sort that must NOT filter: applied as a LEFT JOIN through | |
| 223 | + * `posts_clauses`, with rows that have no value for the key ordered | |
| 224 | + * LAST in both directions. Use this for any user-facing column sort — | |
| 225 | + * `meta_key`/`orderby` INNER JOINs and silently drops rows. | |
| 199 | 226 | * |
| 200 | 227 | * Filter row shape: |
| 201 | 228 | * - `meta` => `array( 'key' => <meta key>, 'storage' => <optional storage lock> )` |
| 202 | 229 | * a `meta_query` row on the WC query args (works on both storages). |
| @@ -210,13 +237,27 @@ | ||
| 210 | 237 | * @internal |
| 211 | 238 | * |
| 212 | 239 | * @param string $collection Collection slug. |
| 213 | 240 | * |
| 214 | - * @return array{sorts?: array<string, array>, filters?: array<string, array>} | |
| 241 | + * @return array{sorts?: array<string, array>, filters?: array<string, array>, search?: array<string, mixed>, visibility?: array<string, mixed>} | |
| 215 | 242 | */ |
| 216 | 243 | public static function rules( string $collection ): array { |
| 217 | 244 | $rules = array( |
| 218 | 245 | 'orders' => array( |
| 246 | + 'search' => array( | |
| 247 | + 'param' => 'search', | |
| 248 | + 'term_cap' => self::SEARCH_TERM_CAP, | |
| 249 | + 'rank_exact' => false, | |
| 250 | + 'carriers' => array( 'id', 'billing_email', 'first_name', 'last_name', 'company', 'email', 'phone' ), | |
| 251 | + 'hpos' => array( | |
| 252 | + 'orders' => array( 'id', 'billing_email' ), | |
| 253 | + 'addresses' => array( 'first_name', 'last_name', 'company', 'email', 'phone' ), | |
| 254 | + ), | |
| 255 | + 'posts' => array( | |
| 256 | + 'id' => 'ID', | |
| 257 | + 'meta' => array( '_billing_first_name', '_billing_last_name', '_billing_company', '_billing_email', '_billing_phone' ), | |
| 258 | + ), | |
| 259 | + ), | |
| 219 | 260 | 'sorts' => array( |
| 220 | 261 | 'status' => array( |
| 221 | 262 | 'hpos' => array( 'column' => 'status' ), |
| 222 | 263 | 'posts' => array( 'posts_orderby' => 'post_status' ), |
| @@ -286,8 +327,71 @@ | ||
| 286 | 327 | ), |
| 287 | 328 | ), |
| 288 | 329 | |
| 289 | 330 | /* |
| 331 | + * The POS grid's SKU / barcode / stock columns, for the product grid and the | |
| 332 | + * variation grid alike — the SAME four rows, from one builder, because the two | |
| 333 | + * surfaces drifted apart once already and a cashier sorting a column expects | |
| 334 | + * the same thing of both. | |
| 335 | + * | |
| 336 | + * A `meta_sort` row sorts on a postmeta value WITHOUT letting the sort decide | |
| 337 | + * which records exist. The obvious encoding — WP_Query's `meta_key` + | |
| 338 | + * `orderby => meta_value` — INNER JOINs `postmeta`, so a record with no row for | |
| 339 | + * that key VANISHES from the result. On a default store the barcode field is | |
| 340 | + * `_global_unique_id`, which most catalogues never populate, so sorting by | |
| 341 | + * barcode returned an EMPTY page; `orderby=sku` silently dropped everything | |
| 342 | + * without a SKU. A sort must never hide a record from a cashier, so these rows | |
| 343 | + * are applied as a LEFT JOIN with the meta-less rows ordered LAST in both | |
| 344 | + * directions (`Collection_Rules_Plan::apply_meta_sort_clauses()`). | |
| 345 | + * | |
| 346 | + * Neither collection is ever HPOS — both are posts on every store — so there is | |
| 347 | + * no `hpos` half to these rows. | |
| 348 | + */ | |
| 349 | + 'products' => array( | |
| 350 | + 'search' => array( | |
| 351 | + 'param' => 'search', | |
| 352 | + // Both product lanes use the literal splitter and collapse over-cap terms to the phrase. | |
| 353 | + 'term_cap' => self::SEARCH_TERM_CAP, | |
| 354 | + 'rank_exact' => true, | |
| 355 | + 'carriers' => array_merge( array( 'post_title' ), Barcode_Field::search_keys() ), | |
| 356 | + 'posts' => array( 'meta' => Barcode_Field::search_keys() ), | |
| 357 | + 'hpos' => null, | |
| 358 | + ), | |
| 359 | + 'visibility' => array( | |
| 360 | + 'type' => array( | |
| 361 | + 'direct' => Pos_Visibility::PRODUCTS, | |
| 362 | + 'proxy' => Pos_Visibility::CATALOG, | |
| 363 | + ), | |
| 364 | + 'where_backstop' => true, | |
| 365 | + ), | |
| 366 | + 'sorts' => self::catalog_meta_sorts(), | |
| 367 | + ), | |
| 368 | + 'variations' => array( | |
| 369 | + 'search' => array( | |
| 370 | + 'param' => 'search', | |
| 371 | + 'term_cap' => self::SEARCH_TERM_CAP, | |
| 372 | + 'rank_exact' => false, | |
| 373 | + 'carriers' => Barcode_Field::search_keys(), | |
| 374 | + 'posts' => array( 'meta' => Barcode_Field::search_keys() ), | |
| 375 | + 'hpos' => null, | |
| 376 | + 'query' => 'meta_query', | |
| 377 | + 'exact_sku_param' => 'sku', | |
| 378 | + // Both lanes split literal terms; v1 keeps over-cap collapse/EXISTS, v2 rejects/meta_query. | |
| 379 | + 'lanes' => array( | |
| 380 | + 'direct' => array( | |
| 381 | + 'query' => 'wp_terms', | |
| 382 | + 'exact_sku_param' => null, | |
| 383 | + ), | |
| 384 | + ), | |
| 385 | + ), | |
| 386 | + 'visibility' => array( | |
| 387 | + 'type' => Pos_Visibility::VARIATIONS, | |
| 388 | + 'where_backstop' => true, | |
| 389 | + ), | |
| 390 | + 'sorts' => self::catalog_meta_sorts(), | |
| 391 | + ), | |
| 392 | + | |
| 393 | + /* | |
| 290 | 394 | * SORT NAMES ONLY — deliberately no clause bodies. |
| 291 | 395 | * |
| 292 | 396 | * Customers are a `WP_User_Query` over `wp_users`/`wp_usermeta`, a storage |
| 293 | 397 | * this table does not speak: it knows `hpos` and `posts`, and both are ORDER |
| @@ -316,8 +420,56 @@ | ||
| 316 | 420 | return $rules[ $collection ] ?? array(); |
| 317 | 421 | } |
| 318 | 422 | |
| 319 | 423 | /** |
| 424 | + * The four POS column sorts, shared by `products` and `variations`. | |
| 425 | + * | |
| 426 | + * One builder rather than two copied blocks: these two collections carry the same | |
| 427 | + * cashier-facing columns, and the previous copy-per-controller encoding is exactly how | |
| 428 | + * the variation lane kept a defect the product lane had already fixed. | |
| 429 | + * | |
| 430 | + * @return array<string, array> | |
| 431 | + */ | |
| 432 | + private static function catalog_meta_sorts(): array { | |
| 433 | + return array( | |
| 434 | + 'sku' => array( | |
| 435 | + 'posts' => array( | |
| 436 | + 'meta_sort' => array( 'key' => '_sku' ), | |
| 437 | + ), | |
| 438 | + ), | |
| 439 | + | |
| 440 | + /* | |
| 441 | + * The barcode meta key is a store setting, so the row reads the same accessor | |
| 442 | + * the controllers do rather than hard-coding a key that would drift. | |
| 443 | + */ | |
| 444 | + 'barcode' => array( | |
| 445 | + 'posts' => array( | |
| 446 | + 'meta_sort' => array( 'key' => Barcode_Field::orderby_key() ), | |
| 447 | + ), | |
| 448 | + ), | |
| 449 | + | |
| 450 | + /* | |
| 451 | + * `_stock` is written as NULL for everything that does not manage stock, so this | |
| 452 | + * row needs the same meta-less-last ordering as the rest — it is not a special | |
| 453 | + * case, it was merely the first one noticed. | |
| 454 | + */ | |
| 455 | + 'stock_quantity' => array( | |
| 456 | + 'posts' => array( | |
| 457 | + 'meta_sort' => array( | |
| 458 | + 'key' => '_stock', | |
| 459 | + 'numeric' => true, | |
| 460 | + ), | |
| 461 | + ), | |
| 462 | + ), | |
| 463 | + 'stock_status' => array( | |
| 464 | + 'posts' => array( | |
| 465 | + 'meta_sort' => array( 'key' => '_stock_status' ), | |
| 466 | + ), | |
| 467 | + ), | |
| 468 | + ); | |
| 469 | + } | |
| 470 | + | |
| 471 | + /** | |
| 320 | 472 | * Resolve the storage dialect for a collection when the caller did not name one. |
| 321 | 473 | * |
| 322 | 474 | * @param string $collection Collection slug. |
| 323 | 475 | * |
| @@ -322,9 +474,9 @@ | ||
| 322 | 474 | * @param string $collection Collection slug. |
| 323 | 475 | * |
| 324 | 476 | * @return string |
| 325 | 477 | */ |
| 326 | - private static function detect_storage( string $collection ): string { | |
| 478 | + public static function detect_storage( string $collection ): string { | |
| 327 | 479 | if ( 'orders' !== $collection ) { |
| 328 | 480 | return self::STORAGE_POSTS; |
| 329 | 481 | } |
| 330 | 482 | |