| @@ -8,14 +8,15 @@ | ||
| 8 | 8 | namespace WCPOS\WooCommercePOS\API\V2; |
| 9 | 9 | |
| 10 | 10 | use WC_Product_Variation; |
| 11 | 11 | use WC_REST_Product_Variations_Controller; |
| 12 | +use WCPOS\WooCommercePOS\Services\Barcode_Field; | |
| 12 | 13 | use WCPOS\WooCommercePOS\Sync\Api; |
| 13 | 14 | use WCPOS\WooCommercePOS\Sync\Collection_Rules; |
| 14 | 15 | use WCPOS\WooCommercePOS\Sync\Collection_Rules_Plan; |
| 15 | 16 | use WCPOS\WooCommercePOS\Sync\Digest_Index; |
| 16 | 17 | use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions; |
| 17 | -use WCPOS\WooCommercePOS\Sync\Product_Search; | |
| 18 | +use WCPOS\WooCommercePOS\Sync\Pos_Visibility; | |
| 18 | 19 | use WCPOS\WooCommercePOS\Sync\Product_Serializer; |
| 19 | 20 | use WP_Error; |
| 20 | 21 | use WP_Query; |
| 21 | 22 | use WP_REST_Request; |
| @@ -58,13 +59,12 @@ | ||
| 58 | 59 | */ |
| 59 | 60 | private const WCPOS_SORT_PARAM_MAP = array( |
| 60 | 61 | 'orderby' => 'orderby', |
| 61 | 62 | 'order' => 'order', |
| 62 | - 'search' => 'search', | |
| 63 | 63 | ); |
| 64 | 64 | |
| 65 | 65 | /** |
| 66 | - * The request whose declared rules wrap the collection query. | |
| 66 | + * The request whose declared sort `wcpos_posts_clauses()` applies. | |
| 67 | 67 | * |
| 68 | 68 | * @var null|WP_REST_Request |
| 69 | 69 | */ |
| 70 | 70 | private $wcpos_sort_request = null; |
| @@ -73,8 +73,9 @@ | ||
| 73 | 73 | |
| 74 | 74 | private const MAX_SKU_LENGTH = 4096; |
| 75 | 75 | private const MAX_SKU_TERMS = 100; |
| 76 | 76 | private const MAX_SEARCH_LENGTH = 256; |
| 77 | + private const MAX_SEARCH_TERMS = 10; | |
| 77 | 78 | private const MAX_PAGE = 1000; |
| 78 | 79 | |
| 79 | 80 | |
| 80 | 81 | public function register_routes(): void { |
| @@ -151,8 +152,49 @@ | ||
| 151 | 152 | */ |
| 152 | 153 | $args['post_type'] = $this->post_type; |
| 153 | 154 | |
| 154 | 155 | /* |
| 156 | + * `search` means the barcode CARRIERS here, not the post title. | |
| 157 | + * | |
| 158 | + * WooCommerce maps `search` onto `s`, which searches post_title/content — useless for a | |
| 159 | + * variation, whose title is a generated attribute string. The POS searches what a cashier | |
| 160 | + * actually types or scans: the SKU and whichever meta key the store configured as its | |
| 161 | + * barcode field (`Barcode_Field::search_keys()`). Every term must match at least one carrier. | |
| 162 | + * | |
| 163 | + * `sku` is left to WooCommerce: its own exact/comma-list handling is what the | |
| 164 | + * sku-beats-search precedence rule relies on. | |
| 165 | + */ | |
| 166 | + $search = (string) ( $request->get_param( 'search' ) ?? '' ); | |
| 167 | + if ( '' !== $sku ) { | |
| 168 | + // SKU is an exact lookup and outranks a fuzzy one; leaving WooCommerce's post-title | |
| 169 | + // `s` in place would AND the two and return nothing. | |
| 170 | + unset( $args['s'] ); | |
| 171 | + } | |
| 172 | + if ( '' !== $search && '' === $sku ) { | |
| 173 | + unset( $args['s'] ); | |
| 174 | + $args['wcpos_variation_search'] = true; | |
| 175 | + $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 176 | + if ( false === $terms ) { | |
| 177 | + $terms = array(); | |
| 178 | + } | |
| 179 | + $carriers = array( 'relation' => 'AND' ); | |
| 180 | + foreach ( $terms as $term ) { | |
| 181 | + $term_carriers = array( 'relation' => 'OR' ); | |
| 182 | + foreach ( Barcode_Field::search_keys() as $key ) { | |
| 183 | + $term_carriers[] = array( | |
| 184 | + 'key' => $key, | |
| 185 | + 'value' => $term, | |
| 186 | + 'compare' => 'LIKE', | |
| 187 | + ); | |
| 188 | + } | |
| 189 | + $carriers[] = $term_carriers; | |
| 190 | + } | |
| 191 | + if ( 1 < \count( $carriers ) ) { | |
| 192 | + $args['meta_query'] = $this->add_meta_query( $args, $carriers ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query | |
| 193 | + } | |
| 194 | + } | |
| 195 | + | |
| 196 | + /* | |
| 155 | 197 | * This route only ever offers what the store owner has for sale — on EVERY lane, including |
| 156 | 198 | * `include`. |
| 157 | 199 | * |
| 158 | 200 | * WooCommerce's Enabled checkbox on the variation metabox writes `post_status = private` |
| @@ -171,25 +213,38 @@ | ||
| 171 | 213 | * Set after `parent::prepare_objects_query()` so an explicit `status` param cannot widen it. |
| 172 | 214 | */ |
| 173 | 215 | $args['post_status'] = 'publish'; |
| 174 | 216 | |
| 217 | + /* | |
| 218 | + * Leg-3 (ADR 0014 WP-M5): POS-hidden (`online_only`) variations are never served. As a | |
| 219 | + * query exclusion rather than a post-hoc filter of the result, so paging and totals count | |
| 220 | + * the same set the client is allowed to see. | |
| 221 | + * | |
| 222 | + * Through the helper, NOT a raw `post__not_in` merge: `parent::prepare_objects_query()` | |
| 223 | + * maps `include` to `post__in`, and WP_Query IGNORES `post__not_in` when `post__in` is | |
| 224 | + * present — so `?search=X&include=<hidden id>` would have served a hidden variation. | |
| 225 | + * `apply_to_wp_query_args()` already owns that trap: it intersects `post__in` with the | |
| 226 | + * hidden set and pins an empty intersection to `array( 0 )`. | |
| 227 | + */ | |
| 228 | + $args = ( new Pos_Visibility() )->apply_to_wp_query_args( $args, Pos_Visibility::VARIATIONS ); | |
| 229 | + | |
| 230 | + /* | |
| 231 | + * The POS sorts on fields WooCommerce does not offer as orderby values. They are | |
| 232 | + * declared in Sync\Collection_Rules and projected into get_collection_params() | |
| 233 | + * below — without that, `orderby=sku` is rejected by REST argument validation | |
| 234 | + * before anything here runs. | |
| 235 | + * | |
| 236 | + * They are applied as SQL clauses, NOT as `meta_key` + `orderby => meta_value`: | |
| 237 | + * that pair INNER JOINs postmeta and drops every variation with no value for the | |
| 238 | + * key, so the sort silently filtered. `wcpos_posts_clauses()` LEFT JOINs instead | |
| 239 | + * and orders the meta-less rows last. | |
| 240 | + */ | |
| 175 | 241 | $this->wcpos_sort_request = $request; |
| 176 | - $plan = Collection_Rules::for_request( 'variations', $request, self::WCPOS_SORT_PARAM_MAP ); | |
| 177 | - $args = $plan->filter( Collection_Rules_Plan::HOOK_PREPARE_ARGS, $args ); | |
| 242 | + add_filter( 'posts_clauses', array( $this, 'wcpos_posts_clauses' ), 10, 2 ); | |
| 178 | 243 | |
| 179 | 244 | return $args; |
| 180 | 245 | } |
| 181 | 246 | |
| 182 | - /** Apply the same rule topology to discovery, collection pages, and named includes. */ | |
| 183 | - protected function get_objects( $query_args ) { | |
| 184 | - $plan = Collection_Rules::for_request( 'variations', $this->wcpos_sort_request, self::WCPOS_SORT_PARAM_MAP ); | |
| 185 | - return $plan->around( | |
| 186 | - function () use ( $query_args ) { | |
| 187 | - return parent::get_objects( $query_args ); | |
| 188 | - } | |
| 189 | - ); | |
| 190 | - } | |
| 191 | - | |
| 192 | 247 | /** |
| 193 | 248 | * GET /variations — the flat collection's three lanes, one response shape. |
| 194 | 249 | * |
| 195 | 250 | * `?sku=`/`?search=` discovers by barcode carrier; a bare request serves one |
| @@ -395,10 +450,13 @@ | ||
| 395 | 450 | } |
| 396 | 451 | if ( self::MAX_SEARCH_LENGTH < $characters ) { |
| 397 | 452 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not exceed 256 characters', array( 'status' => 400 ) ); |
| 398 | 453 | } |
| 399 | - $terms = Collection_Rules::search_terms( trim( $search ) ); | |
| 400 | - if ( Collection_Rules::rules( 'variations' )['search']['term_cap'] < \count( $terms ) ) { | |
| 454 | + $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 455 | + if ( false === $terms ) { | |
| 456 | + return new WP_Error( 'woocommerce_pos_variations_search_invalid', 'search must be valid UTF-8', array( 'status' => 400 ) ); | |
| 457 | + } | |
| 458 | + if ( self::MAX_SEARCH_TERMS < \count( $terms ) ) { | |
| 401 | 459 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not contain more than 10 whitespace-separated terms', array( 'status' => 400 ) ); |
| 402 | 460 | } |
| 403 | 461 | } |
| 404 | 462 | |
| @@ -418,9 +476,8 @@ | ||
| 418 | 476 | * |
| 419 | 477 | * @param array $clauses Associative array of the clauses for the query. |
| 420 | 478 | * @param WP_Query $wp_query The WP_Query instance. |
| 421 | 479 | * |
| 422 | - * @deprecated Collection Rules now installs this behavior; retained for Pro callers. | |
| 423 | 480 | * @return array |
| 424 | 481 | */ |
| 425 | 482 | public function wcpos_posts_clauses( array $clauses, WP_Query $wp_query ): array { |
| 426 | 483 | if ( null === $this->wcpos_sort_request ) { |
| @@ -466,13 +523,13 @@ | ||
| 466 | 523 | * De-duplicate variation searches joined through matching meta rows. |
| 467 | 524 | * |
| 468 | 525 | * @param string $groupby Existing GROUP BY clause. |
| 469 | 526 | * @param WP_Query $query Query being filtered. |
| 470 | - * | |
| 471 | - * @deprecated Collection Rules owns variation grouping. | |
| 472 | 527 | */ |
| 473 | 528 | public function group_search_results( string $groupby, WP_Query $query ): string { |
| 474 | - return Product_Search::variation_groupby( $groupby, $query->query_vars ); | |
| 529 | + global $wpdb; | |
| 530 | + | |
| 531 | + return ! empty( $query->query_vars['wcpos_variation_search'] ) ? "{$wpdb->posts}.ID" : $groupby; | |
| 475 | 532 | } |
| 476 | 533 | |
| 477 | 534 | /** |
| 478 | 535 | * Does this discovery request still carry a term after normalization? |
| @@ -486,9 +543,12 @@ | ||
| 486 | 543 | return true; |
| 487 | 544 | } |
| 488 | 545 | |
| 489 | 546 | $search = (string) ( $request->get_param( 'search' ) ?? '' ); |
| 490 | - $terms = Collection_Rules::search_terms( trim( $search ) ); | |
| 547 | + $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 548 | + if ( false === $terms ) { | |
| 549 | + return false; | |
| 550 | + } | |
| 491 | 551 | |
| 492 | 552 | return array() !== $terms; |
| 493 | 553 | } |
| 494 | 554 | |
| @@ -563,9 +623,14 @@ | ||
| 563 | 623 | ), |
| 564 | 624 | ); |
| 565 | 625 | } |
| 566 | 626 | |
| 567 | - $results = $this->get_objects( $query_args ); | |
| 627 | + add_filter( 'posts_groupby', array( $this, 'group_search_results' ), 10, 2 ); | |
| 628 | + try { | |
| 629 | + $results = $this->get_objects( $query_args ); | |
| 630 | + } finally { | |
| 631 | + remove_filter( 'posts_groupby', array( $this, 'group_search_results' ), 10 ); | |
| 632 | + } | |
| 568 | 633 | |
| 569 | 634 | $ids = array(); |
| 570 | 635 | foreach ( $results['objects'] as $object ) { |
| 571 | 636 | if ( $object instanceof WC_Product_Variation ) { |