PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.27
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.27
1.10.27 1.10.26 1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 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 All 171 releases
woocommerce-pos / includes / Products.php

Products.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.27, at includes/Products.php

337 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * POS Product Class.
4 *
5 * @author Paul Kilmurray <[email protected]>
6 *
7 * @see https://wcpos.com
8 * @package WCPOS\WooCommercePOS
9 */
10
11 namespace WCPOS\WooCommercePOS;
12
13 use Automattic\WooCommerce\StoreApi\Exceptions\NotPurchasableException;
14 use WC_Product;
15 use WC_Product_Variation;
16 use WCPOS\WooCommercePOS\Services\Settings;
17 use WP_Query;
18
19 /**
20 * Products class.
21 */
22 class Products {
23 /**
24 * REST namespaces that are the merchant operating the shop, not a shopper browsing it.
25 *
26 * WooCommerce Analytics (`wc-analytics`) and the WooCommerce admin app (`wc-admin`)
27 * read the catalogue to manage and report on it, exactly as wp-admin does, and never
28 * show a product to a shopper. The Store API (`wc/store`) is NOT listed: it renders
29 * the storefront. The core WooCommerce API (`wc/v3`) is not listed either; WC_API
30 * owns that decision.
31 *
32 * @var string[]
33 */
34 const OPERATOR_REST_NAMESPACES = array( 'wc-analytics', 'wc-admin' );
35
36 /**
37 * Lower-cased route of the REST request being dispatched, or '' outside REST.
38 *
39 * @var string
40 */
41 private $rest_route = '';
42
43 /**
44 * Constructor.
45 */
46 public function __construct() {
47 add_action( 'woocommerce_product_set_stock', array( $this, 'product_set_stock' ) );
48 add_action( 'woocommerce_variation_set_stock', array( $this, 'product_set_stock' ) );
49
50 $pos_only_products = Settings::instance()->pos_only_products_enabled();
51
52 if ( $pos_only_products ) {
53 add_filter( 'rest_pre_dispatch', array( $this, 'record_rest_route' ), 10, 3 );
54 add_action( 'pre_get_posts', array( $this, 'hide_pos_only_products' ) );
55 add_filter( 'woocommerce_variation_is_visible', array( $this, 'hide_pos_only_variations' ), 10, 4 );
56 add_action( 'woocommerce_store_api_validate_add_to_cart', array( $this, 'store_api_prevent_pos_only_add_to_cart' ) );
57
58 // NOTE: this hook is marked as deprecated.
59 add_filter( 'woocommerce_add_to_cart_validation', array( $this, 'prevent_pos_only_add_to_cart' ), 10, 2 );
60
61 // add_filter( 'woocommerce_get_product_subcategories_args', array( $this, 'filter_category_count_exclude_pos_only' ) );.
62 }
63
64 /**
65 * Allow decimal quantities for products and variations.
66 *
67 * @TODO - this will affect the online store as well, should I make it POS only?
68 */
69 $allow_decimal_quantities = Settings::instance()->decimal_qty_enabled();
70 if ( $allow_decimal_quantities ) {
71 remove_filter( 'woocommerce_stock_amount', 'intval' );
72 add_filter( 'woocommerce_stock_amount', 'floatval' );
73 add_action( 'woocommerce_before_product_object_save', array( $this, 'save_decimal_quantities' ) );
74 }
75 }
76
77 /**
78 * Bump modified date on stock change.
79 * - variation->id = parent id.
80 *
81 * @param WC_Product $product The product object.
82 */
83 public function product_set_stock( WC_Product $product ): void {
84 $post_modified = current_time( 'mysql' );
85 $post_modified_gmt = current_time( 'mysql', true );
86 wp_update_post(
87 array(
88 'ID' => $product->get_id(),
89 'post_modified' => $post_modified,
90 'post_modified_gmt' => $post_modified_gmt,
91 )
92 );
93 }
94
95 /**
96 * Remember which REST route is being dispatched.
97 *
98 * `pre_get_posts` cannot see the request, and the `rest_route` query var is
99 * not set when a request is dispatched directly (batch sub-requests, tests), so
100 * the route is captured here, the same way WC_API does.
101 *
102 * @param mixed $result The dispatch result.
103 * @param \WP_REST_Server $server The server instance.
104 * @param \WP_REST_Request $request The request object.
105 *
106 * @return mixed The unchanged result.
107 */
108 public function record_rest_route( $result, $server, $request ) {
109 // WordPress matches routes case-insensitively, so compare lower-cased.
110 $this->rest_route = strtolower( $request->get_route() );
111
112 return $result;
113 }
114
115 /**
116 * Hide POS only products and variations from storefront queries.
117 *
118 * @param \WP_Query $query The WP_Query instance (passed by reference).
119 *
120 * @return void
121 */
122 public function hide_pos_only_products( \WP_Query $query ): void {
123 if ( ! $this->should_hide_pos_only_products( $query ) ) {
124 return;
125 }
126
127 // WP_Query post_type can be string or array.
128 $post_types = (array) $query->get( 'post_type' );
129 $settings_instance = Settings::instance();
130 $exclude_ids = array();
131
132 // Handle product queries.
133 if ( \in_array( 'product', $post_types, true ) ) {
134 $settings = $settings_instance->get_pos_only_product_visibility_settings();
135 if ( isset( $settings['ids'] ) && ! empty( $settings['ids'] ) ) {
136 $exclude_ids = array_merge(
137 $exclude_ids,
138 array_map( 'intval', (array) $settings['ids'] )
139 );
140 }
141 }
142
143 // Handle product_variation queries.
144 if ( \in_array( 'product_variation', $post_types, true ) ) {
145 $settings = $settings_instance->get_pos_only_variations_visibility_settings();
146 if ( isset( $settings['ids'] ) && ! empty( $settings['ids'] ) ) {
147 $exclude_ids = array_merge(
148 $exclude_ids,
149 array_map( 'intval', (array) $settings['ids'] )
150 );
151 }
152 }
153
154 // Apply exclusions if any.
155 if ( ! empty( $exclude_ids ) ) {
156 $existing_excludes = $query->get( 'post__not_in' );
157 if ( ! \is_array( $existing_excludes ) ) {
158 $existing_excludes = array();
159 }
160 $query->set( 'post__not_in', array_merge( $existing_excludes, $exclude_ids ) );
161 }
162 }
163
164 /**
165 * Whether the POS Only exclusion applies to the request running this query.
166 *
167 * Hiding POS Only products is a storefront rule: a shopper must not see or buy
168 * them. It does not apply where the merchant is operating the shop rather than
169 * shopping in it (wp-admin, WP-CLI, the WooCommerce admin REST namespaces), nor
170 * on POS requests, which are what POS Only products exist for. Applying it to
171 * WooCommerce Analytics, which resolves a category filter with wc_get_products()
172 * during a wc-analytics request, made a category of POS Only products report
173 * "No data" while the same products' sales showed when searched by name.
174 *
175 * @param \WP_Query $query The query being decided.
176 *
177 * @return bool
178 */
179 public function should_hide_pos_only_products( \WP_Query $query ): bool {
180 $hide = ! ( is_admin() || woocommerce_pos_request() || $this->is_cli_request() || $this->is_operator_rest_request() );
181
182 /**
183 * Filters whether POS Only products are excluded from a product query.
184 *
185 * The default is true on the storefront (catalogue, search, the Store API,
186 * feeds and sitemaps) and false in wp-admin, WP-CLI, the WooCommerce admin
187 * REST namespaces and the POS itself. Return false to let a context of
188 * your own see POS Only products.
189 *
190 * @param bool $hide Whether to exclude POS Only products from this query.
191 * @param \WP_Query $query The query being decided.
192 */
193 return (bool) apply_filters( 'woocommerce_pos_hide_pos_only_products', $hide, $query );
194 }
195
196 /**
197 * Whether the current REST request belongs to an operator namespace.
198 *
199 * @return bool
200 */
201 private function is_operator_rest_request(): bool {
202 if ( '' === $this->rest_route ) {
203 return false;
204 }
205 foreach ( self::OPERATOR_REST_NAMESPACES as $namespace ) {
206 if ( '/' . $namespace === $this->rest_route || 0 === strpos( $this->rest_route, '/' . $namespace . '/' ) ) {
207 return true;
208 }
209 }
210
211 return false;
212 }
213
214 /**
215 * Whether the request is a WP-CLI command.
216 *
217 * @return bool
218 */
219 private function is_cli_request(): bool {
220 return \defined( 'WP_CLI' ) && WP_CLI;
221 }
222
223 /**
224 * Remove POS Only variations from the storefront.
225 *
226 * @param bool $visible Whether the variation is visible.
227 * @param int $variation_id The variation ID.
228 * @param int $product_id The product ID.
229 * @param WC_Product_Variation $variation The variation object.
230 */
231 public function hide_pos_only_variations( $visible, $variation_id, $product_id, $variation ) {
232 if ( is_shop() || is_product_category() || is_product() ) {
233 $settings_instance = Settings::instance();
234 $pos_only = $settings_instance->is_variation_pos_only( $variation_id );
235
236 if ( $pos_only ) {
237 return false;
238 }
239 }
240
241 return $visible;
242 }
243
244 /**
245 * Filter category count to exclude pos_only products.
246 *
247 * @param array $args The arguments for getting product subcategories.
248 *
249 * @return array The modified arguments.
250 */
251 public function filter_category_count_exclude_pos_only( $args ) {
252 // @TODO: Do we need this?
253
254 return $args;
255 }
256
257 /**
258 * Prevent POS Only products from being added to the cart.
259 *
260 * NOTE: this hook is marked as deprecated.
261 *
262 * @param bool $passed Whether the product can be added to the cart.
263 * @param int $product_id The product ID.
264 *
265 * @return bool
266 */
267 public function prevent_pos_only_add_to_cart( $passed, $product_id ) {
268 $settings_instance = Settings::instance();
269 $product_pos_only = $settings_instance->is_product_pos_only( $product_id );
270 $variation_pos_only = $settings_instance->is_variation_pos_only( $product_id );
271
272 if ( $product_pos_only || $variation_pos_only ) {
273 return false;
274 }
275
276 return $passed;
277 }
278
279 /**
280 * Prevent POS Only products from being added to the cart via the Store API.
281 *
282 * @param WC_Product $product Product.
283 *
284 * @throws NotPurchasableException If the product is POS only.
285 *
286 * @return void
287 */
288 public function store_api_prevent_pos_only_add_to_cart( WC_Product $product ): void {
289 $settings_instance = Settings::instance();
290 if ( $product->is_type( 'variation' ) ) {
291 $pos_only = $settings_instance->is_variation_pos_only( $product->get_id() );
292 } else {
293 $pos_only = $settings_instance->is_product_pos_only( $product->get_id() );
294 }
295
296 if ( $pos_only ) {
297 // @phpstan-ignore-next-line.
298 throw new NotPurchasableException(
299 'woocommerce_pos_product_not_purchasable',
300 esc_html( $product->get_name() )
301 );
302 }
303 }
304
305 /**
306 * Save decimal quantities for products and variations.
307 *
308 * @param WC_Product $product Product.
309 */
310 public function save_decimal_quantities( WC_Product $product ): void {
311 // Without stock management there is no quantity to derive a status from;
312 // WooCommerce respects the manually chosen stock status, so leave it untouched.
313 if ( ! $product->get_manage_stock() ) {
314 return;
315 }
316
317 $stock_quantity = $product->get_stock_quantity();
318 $stock_notification_threshold = absint( get_option( 'woocommerce_notify_no_stock_amount', 0 ) );
319
320 // Mirrors WC_Product::validate_props() minus its (int) cast on the stock
321 // quantity, which would truncate fractional stock (e.g. 0.5) to 0 and
322 // mark it out of stock. Re-check against core on WooCommerce updates.
323 $stock_is_above_notification_threshold = ( $stock_quantity > $stock_notification_threshold );
324 $backorders_are_allowed = ( 'no' !== $product->get_backorders() );
325
326 if ( $stock_is_above_notification_threshold ) {
327 $new_stock_status = 'instock';
328 } elseif ( $backorders_are_allowed ) {
329 $new_stock_status = 'onbackorder';
330 } else {
331 $new_stock_status = 'outofstock';
332 }
333
334 $product->set_stock_status( $new_stock_status );
335 }
336 }
337