PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
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
woocommerce-pos / includes / Sync / Pos_Visibility.php

Pos_Visibility.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.0, at includes/Sync/Pos_Visibility.php

347 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS POS visibility authority.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use WCPOS\WooCommercePOS\Services\Settings;
11
12 /**
13 * POS visibility contract (ADR 0014 phase 3, WP-M5) — the SOLE authority for "which products and
14 * variations may the POS be served".
15 *
16 * Every read lane (wcpos/v1 WP_Query + bulk-id SQL, wcpos/v2 catalog proxy, variations, resolve,
17 * integrity, and the variable-price range helper) asks THIS class instead of re-deriving the rule.
18 *
19 * # Storage
20 *
21 * A SINGLE wp_option `woocommerce_pos_settings_visibility` holding ID LISTS — NOT the per-record
22 * `_pos_visibility` postmeta that older versions used (that was removed to avoid spamming postmeta).
23 * Shape:
24 *
25 * woocommerce_pos_settings_visibility = array(
26 * 'products' => array( <scope> => array(
27 * 'pos_only' => array( 'ids' => array( 1, 2, 3 ) ), // hidden from the WEB store
28 * 'online_only' => array( 'ids' => array( 4, 5, 6 ) ), // hidden from the POS <-- what we filter on
29 * ) ),
30 * 'variations' => array( <scope> => array( 'pos_only' => ..., 'online_only' => ... ) ),
31 * )
32 *
33 * `<scope>` is `'default'` or a store id (multi-store, per-store visibility). The POS servable set is
34 * every product/variation EXCEPT the `online_only` ids for the scope; `pos_only` is a web-store
35 * concern, not ours. An empty/absent option means nothing is hidden — the safe default (feature
36 * simply off).
37 *
38 * # Reads go through the Settings service
39 *
40 * The option is NEVER read directly here. `Services\Settings` owns the Visibility section's
41 * defaults-merge and migration, and applies the public extension filters
42 * (`woocommerce_pos_product_visibility_settings`, `woocommerce_pos_variations_visibility_settings`,
43 * `woocommerce_pos_online_only_product_visibility_settings`,
44 * `woocommerce_pos_online_only_variations_visibility_settings`). Reading the raw option would bypass
45 * all of it, so an extension that hides a product would be honoured on some lanes and ignored on
46 * others. Routing every lane through this class routes every lane through those filters.
47 *
48 * # Feature gate
49 *
50 * The `pos_only_products` toggle (General settings, `Settings::pos_only_products_enabled()`) is
51 * enforced INSIDE this module: when the feature is off every method here reports an empty hidden set
52 * and leaves its input untouched. Callers must not re-implement the gate to decide the servable set;
53 * a caller may still short-circuit on the gate purely to avoid attaching a filter it knows will do
54 * nothing.
55 */
56 final class Pos_Visibility {
57 /**
58 * The single wp_option the POS app writes (db_prefix `woocommerce_pos_settings_` + `visibility`).
59 *
60 * @var string
61 */
62 public const OPTION = 'woocommerce_pos_settings_visibility';
63
64 /**
65 * Hidden-set type: products only.
66 *
67 * @var string
68 */
69 public const PRODUCTS = 'products';
70
71 /**
72 * Hidden-set type: variations only.
73 *
74 * @var string
75 */
76 public const VARIATIONS = 'variations';
77
78 /**
79 * Hidden-set type: products UNION variations.
80 *
81 * Products and variations share the `wp_posts` id-space, so the two lists union safely whenever a
82 * query can return either post type.
83 *
84 * @var string
85 */
86 public const CATALOG = 'catalog';
87
88 /**
89 * The scope used when a caller does not ask for a store-specific one.
90 *
91 * @var string
92 */
93 public const DEFAULT_SCOPE = 'default';
94
95 /**
96 * The ids hidden from the POS for one type and scope — the servable-set exclusion list.
97 *
98 * Returns unique positive ints in stored order. Empty whenever the `pos_only_products` feature is
99 * off, the option is absent, or the scope was never configured, so a missing option can never
100 * accidentally hide the whole catalog.
101 *
102 * @param string $type One of self::PRODUCTS, self::VARIATIONS, self::CATALOG. The singular
103 * forms `product` / `variation` are accepted as aliases.
104 * @param null|string $scope Visibility scope: `default` or a store id. Null means default.
105 *
106 * @return int[]
107 */
108 public function hidden_ids( string $type, ?string $scope = null ): array {
109 if ( ! Settings::instance()->pos_only_products_enabled() ) {
110 return array();
111 }
112
113 $type = self::normalize_type( $type );
114 $scope = self::normalize_scope( $scope );
115
116 if ( self::CATALOG === $type ) {
117 return array_values(
118 array_unique(
119 array_merge(
120 $this->online_only_ids( self::PRODUCTS, $scope ),
121 $this->online_only_ids( self::VARIATIONS, $scope )
122 )
123 )
124 );
125 }
126
127 return $this->online_only_ids( $type, $scope );
128 }
129
130 /**
131 * Apply the exclusion to a WP_Query / WC REST `*_object_query` argument array.
132 *
133 * Owns the `post__in` trap: WooCommerce maps `include=` to `post__in`, and WP_Query IGNORES
134 * `post__not_in` whenever `post__in` is present (they are mutually exclusive in core's WHERE
135 * builder). So for a targeted pull we must INTERSECT — subtract the hidden ids from the include
136 * list itself — or a targeted pull of a hidden id would leak. An empty intersection must yield an
137 * EMPTY response, not an unfiltered one, so `post__in` is pinned to a non-existent id (0) rather
138 * than left empty.
139 *
140 * The `products` collection excludes hidden PRODUCTS AND VARIATIONS: WooCommerce widens
141 * `post_type` to `product_variation` on SKU-ish params (sku/search_sku/search_name_or_sku/
142 * search_fields), so variation rows can ride a `/products` response. The union is harmless when
143 * `post_type` is not widened because variation ids never match a plain product query.
144 *
145 * @param array $args Query args to filter.
146 * @param string $collection Collection being queried: `products` or `variations`. Any other
147 * value returns the args untouched.
148 * @param null|string $scope Visibility scope: `default` or a store id. Null means default.
149 *
150 * @return array
151 */
152 public function apply_to_wp_query_args( array $args, string $collection, ?string $scope = null ): array {
153 $type = self::collection_type( $collection );
154 if ( null === $type ) {
155 return $args;
156 }
157
158 $exclude = $this->hidden_ids( $type, $scope );
159 if ( array() === $exclude ) {
160 return $args;
161 }
162
163 if ( isset( $args['post__in'] ) && array() !== (array) $args['post__in'] ) {
164 $intersected = array_values( array_diff( array_map( 'intval', (array) $args['post__in'] ), $exclude ) );
165 $args['post__in'] = array() === $intersected ? array( 0 ) : $intersected;
166
167 return $args;
168 }
169
170 $existing = isset( $args['post__not_in'] ) && \is_array( $args['post__not_in'] ) ? $args['post__not_in'] : array();
171 $args['post__not_in'] = array_values( array_unique( array_merge( $existing, $exclude ) ) );
172
173 return $args;
174 }
175
176 /**
177 * Append the exclusion to a SQL WHERE clause for the bulk-id lanes.
178 *
179 * The returned fragment is already prepared, so the caller must NOT re-run the hidden-id values
180 * through `$wpdb->prepare()`. A lane that assembles placeholders and defers `prepare()` to the end
181 * should call `hidden_ids()` and build its own `IN` list instead.
182 *
183 * @param string $where The WHERE clause (or partial SQL) to extend.
184 * @param string $id_column The fully qualified id column to test, e.g. `wp_posts.ID`. Caller
185 * supplied and never derived from a request.
186 * @param string $type One of self::PRODUCTS, self::VARIATIONS, self::CATALOG.
187 * @param null|string $scope Visibility scope: `default` or a store id. Null means default.
188 *
189 * @return string
190 */
191 public function apply_to_sql_where( string $where, string $id_column, string $type, ?string $scope = null ): string {
192 global $wpdb;
193
194 $hidden = $this->hidden_ids( $type, $scope );
195 if ( array() === $hidden ) {
196 return $where;
197 }
198
199 $ids_format = implode( ',', array_fill( 0, \count( $hidden ), '%d' ) );
200
201 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column name is caller-supplied code, ids use %d placeholders.
202 return $where . $wpdb->prepare( " AND {$id_column} NOT IN ($ids_format) ", $hidden );
203 }
204
205 /**
206 * Drop POS-hidden variations from a list of variation ids.
207 *
208 * WooCommerce's own visible-children resolution applies the store's WEB visibility rules, NOT the
209 * POS exclusion, so a child hidden from the POS would otherwise leak — its price into a served
210 * price range, or the row itself into a targeted pull. Also used for `include=` variation lists.
211 *
212 * @param array $ids Variation ids.
213 * @param null|string $scope Visibility scope: `default` or a store id. Null means default.
214 *
215 * @return int[]
216 */
217 public function filter_visible_children( array $ids, ?string $scope = null ): array {
218 $ids = array_values( array_map( 'intval', $ids ) );
219 $hidden = $this->hidden_ids( self::VARIATIONS, $scope );
220
221 if ( array() === $hidden ) {
222 return $ids;
223 }
224
225 return array_values( array_diff( $ids, $hidden ) );
226 }
227
228 /**
229 * Product ids hidden from the POS for the given scope.
230 *
231 * @deprecated Use hidden_ids( self::PRODUCTS, $scope ). Kept as a thin delegate so callers outside
232 * this repository keep working.
233 *
234 * @param string $scope Visibility scope: `default` or a store id.
235 *
236 * @return int[]
237 */
238 public function online_only_product_ids( string $scope = self::DEFAULT_SCOPE ): array {
239 return $this->hidden_ids( self::PRODUCTS, $scope );
240 }
241
242 /**
243 * Variation ids hidden from the POS for the given scope.
244 *
245 * @deprecated Use hidden_ids( self::VARIATIONS, $scope ). Kept as a thin delegate so callers
246 * outside this repository keep working.
247 *
248 * @param string $scope Visibility scope: `default` or a store id.
249 *
250 * @return int[]
251 */
252 public function online_only_variation_ids( string $scope = self::DEFAULT_SCOPE ): array {
253 return $this->hidden_ids( self::VARIATIONS, $scope );
254 }
255
256 /**
257 * Map a collection slug to the hidden-set type it must exclude.
258 *
259 * @param string $collection Collection slug.
260 *
261 * @return null|string Null when the collection carries no POS visibility rule.
262 */
263 private static function collection_type( string $collection ): ?string {
264 switch ( $collection ) {
265 case 'products':
266 case self::CATALOG:
267 return self::CATALOG;
268 case 'variation':
269 case self::VARIATIONS:
270 return self::VARIATIONS;
271 default:
272 return null;
273 }
274 }
275
276 /**
277 * Accept the singular aliases for the stored post-type keys.
278 *
279 * @param string $type Requested type.
280 *
281 * @return string
282 */
283 private static function normalize_type( string $type ): string {
284 switch ( $type ) {
285 case 'product':
286 case self::PRODUCTS:
287 return self::PRODUCTS;
288 case 'variation':
289 case self::VARIATIONS:
290 return self::VARIATIONS;
291 default:
292 return self::CATALOG;
293 }
294 }
295
296 /**
297 * Null or empty scope means the default scope.
298 *
299 * @param null|string $scope Requested scope.
300 *
301 * @return string
302 */
303 private static function normalize_scope( ?string $scope ): string {
304 return ( null === $scope || '' === $scope ) ? self::DEFAULT_SCOPE : $scope;
305 }
306
307 /**
308 * The `online_only` id list for one post-type + scope, read through the Settings service so the
309 * section's defaults-merge, migration and the public visibility filters all apply.
310 *
311 * Every level of the stored shape can be missing (POS app not installed, scope never configured,
312 * key absent). The Settings accessors index `[ $post_type ][ $scope ][ 'online_only' ]` directly,
313 * so the path is checked here first — a missing one yields an empty list rather than a PHP warning.
314 *
315 * @param string $post_type `products` or `variations`.
316 * @param string $scope Visibility scope.
317 *
318 * @return int[]
319 */
320 private function online_only_ids( string $post_type, string $scope ): array {
321 $settings = Settings::instance();
322 $stored = $settings->get_visibility_settings();
323
324 if ( ! isset( $stored[ $post_type ][ $scope ]['online_only'] ) ) {
325 return array();
326 }
327
328 $view = self::PRODUCTS === $post_type
329 ? $settings->get_online_only_product_visibility_settings( $scope )
330 : $settings->get_online_only_variations_visibility_settings( $scope );
331
332 $ids = \is_array( $view ) && isset( $view['ids'] ) ? $view['ids'] : array();
333 if ( ! \is_array( $ids ) ) {
334 return array();
335 }
336
337 $ids = array_filter(
338 array_map( 'intval', $ids ),
339 static function ( $id ) {
340 return $id > 0;
341 }
342 );
343
344 return array_values( array_unique( $ids ) );
345 }
346 }
347