PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
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 1.9.13 All 162 releases
woocommerce-pos / includes / Sync / Pos_Visibility.php

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

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