| 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 |
|