PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
← All changes | includes/Sync/Collection_Rules.php +159 -7 1.10.01.10.18 View file →
@@ -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