PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.1.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.1.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / variation.php

variation.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.1.0, at includes/classes/variation.php

772 lines 24.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 use StoreEngine\Classes\Data\AttributeData;
6 use StoreEngine\Utils\Helper;
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 class Variation {
13
14 protected int $id;
15 protected string $table;
16 protected string $name = '';
17
18 protected array $data = [
19 'sku' => '',
20 'barcode' => null,
21 'product_id' => '',
22 'price' => null,
23 'cost_price' => null,
24 'manage_stock' => 0,
25 'stock_quantity' => null,
26 'stock_status' => 'instock',
27 'backorders' => 'no',
28 'low_stock_threshold' => null,
29 ];
30 protected array $new_data = [];
31
32 protected array $data_format = [
33 'sku' => '%s',
34 'barcode' => '%s',
35 'product_id' => '%d',
36 'price' => '%f',
37 'cost_price' => '%f',
38 'manage_stock' => '%d',
39 'stock_quantity' => '%d',
40 'stock_status' => '%s',
41 'backorders' => '%s',
42 'low_stock_threshold' => '%d',
43 ];
44
45 protected array $meta_data = [];
46 protected array $json_meta = [];
47 protected array $add_meta_data = [];
48 protected array $update_meta_data = [];
49 protected array $delete_meta_data = [];
50 /**
51 * @var AttributeData[]
52 */
53 protected array $attributes = [];
54 protected array $new_attributes_term_ids = [];
55
56 public const CACHE_KEY = 'storeengine_variation_';
57 public const CACHE_GROUP = 'storeengine_variations';
58
59 public function __construct( int $id = 0 ) {
60 global $wpdb;
61 $this->id = $id;
62 $this->table = $wpdb->prefix . Helper::DB_PREFIX . 'product_variations';
63 }
64
65 public function get() {
66 global $wpdb;
67
68 $has_cache = wp_cache_get( self::CACHE_KEY . $this->get_id(), self::CACHE_GROUP );
69 if ( $has_cache ) {
70 return $this->set_data( $has_cache );
71 }
72
73 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
74 $result = $wpdb->get_row(
75 $wpdb->prepare(
76 "SELECT * FROM {$wpdb->prefix}storeengine_product_variations WHERE id = %d;",
77 $this->get_id()
78 )
79 );
80
81 if ( ! $result ) {
82 return false;
83 }
84
85 $variation_attributes = $wpdb->get_results(
86 $wpdb->prepare(
87 "
88 SELECT
89 t.term_id AS term_id,
90 tt.term_taxonomy_id AS term_taxonomy_id,
91 pl.variation_id AS variation_id,
92 t.name AS name,
93 t.slug AS slug,
94 tt.description AS description,
95 tt.taxonomy AS taxonomy,
96 tt.`count` AS count,
97 pl.term_order AS term_order
98 FROM
99 {$wpdb->prefix}storeengine_variation_term_relations pl
100 LEFT JOIN {$wpdb->terms} t ON t.term_id = pl.term_id
101 LEFT JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = pl.term_id
102 WHERE
103 pl.variation_id = %d
104 ORDER BY
105 pl.term_order DESC
106 ",
107 $this->get_id()
108 )
109 );
110 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
111
112 $result->attributes = is_array( $variation_attributes ) ? $variation_attributes : [];
113
114 wp_cache_set( self::CACHE_KEY . $this->get_id(), $result, self::CACHE_GROUP );
115
116 $this->get_metadata_from_db();
117
118 $this->set_data( $result );
119
120 return $this;
121 }
122
123 public function get_metadata_from_db() {
124 global $wpdb;
125 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
126 $results = $wpdb->get_results( $wpdb->prepare(
127 "SELECT * FROM {$wpdb->prefix}storeengine_product_variation_meta WHERE variation_id = %d", $this->get_id()
128 ) );
129
130 if ( empty( $results ) ) {
131 return;
132 }
133
134 foreach ( $results as $result ) {
135 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
136 $this->meta_data[ $result->meta_key ] = $result->meta_value;
137 }
138 }
139
140 public function save() {
141 $had_stock_change = array_key_exists( 'stock_quantity', $this->new_data )
142 || array_key_exists( 'manage_stock', $this->new_data );
143
144 $this->save_core();
145 $this->save_add_metadata();
146 $this->save_update_metadata();
147 $this->save_attributes();
148 $this->save_delete_metadata();
149
150 // Fire after persistence so listeners (e.g. inventory-pro) can mirror
151 // the new aggregate qty into per-location stock at the default
152 // location. Only fires when the editor actually changed stock.
153 if ( $had_stock_change && $this->get_id() && $this->manages_stock() ) {
154 $qty = $this->get_stock_quantity();
155 /**
156 * Variation aggregate stock just changed.
157 *
158 * @param int $product_id
159 * @param int $variation_id
160 * @param int|null $stock_quantity Current aggregate qty.
161 * @param string $context 'editor' (product editor save) for now.
162 */
163 do_action(
164 'storeengine/inventory/stock_quantity_set',
165 (int) $this->get_product_id(),
166 (int) $this->get_id(),
167 null === $qty ? null : (int) $qty,
168 'editor'
169 );
170 }
171 }
172
173 protected function save_core() {
174 if ( empty( $this->new_data ) ) {
175 return;
176 }
177
178 global $wpdb;
179 $data = array_merge( $this->data, $this->new_data );
180 $formatter = array_values( array_map(
181 fn( $key ) => $this->data_format[ $key ] ?? '%s',
182 array_keys( $data )
183 ) );
184
185 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
186 if ( 0 === $this->get_id() ) {
187 $wpdb->insert(
188 $this->table,
189 $data,
190 $formatter
191 );
192 $this->id = $wpdb->insert_id;
193 } else {
194 $wpdb->update( $this->table, $data, [ 'id' => $this->get_id() ], $formatter, [ '%d' ] );
195 wp_cache_delete( self::CACHE_KEY . $this->get_id(), self::CACHE_GROUP );
196 wp_cache_flush_group( AbstractProduct::CACHE_GROUP );
197 }
198 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
199 wp_cache_delete( self::CACHE_KEY . $this->get_id(), self::CACHE_GROUP );
200 wp_cache_flush_group( AbstractProduct::CACHE_GROUP );
201 }
202
203 protected function save_add_metadata() {
204 if ( empty( $this->add_meta_data ) ) {
205 return;
206 }
207
208 global $wpdb;
209 $values = [];
210 foreach ( $this->add_meta_data as $key => $value ) {
211 if ( in_array( $key, $this->json_meta, true ) ) {
212 $value = wp_json_encode( $value );
213 }
214
215 $type = in_array( gettype( $value ), [
216 'integer',
217 'boolean',
218 ], true ) ? '%d' : ( in_array( gettype( $value ), [ 'double', 'float' ], true ) ? '%f' : '%s' );
219
220 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
221 $values[] = $wpdb->prepare( "( %d, %s, $type )", $this->get_id(), $key, $value );
222 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
223 }
224 $values = implode( ', ', $values );
225
226 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
227 $wpdb->query( "INSERT INTO {$wpdb->prefix}storeengine_product_variation_meta (variation_id, meta_key, meta_value) VALUES $values" );
228 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
229 }
230
231 protected function save_update_metadata() {
232 if ( empty( $this->update_meta_data ) ) {
233 return;
234 }
235
236 global $wpdb;
237 $values = [];
238 foreach ( $this->update_meta_data as $key => $value ) {
239 if ( in_array( $key, $this->json_meta, true ) ) {
240 $value = wp_json_encode( $value );
241 }
242
243 $type = in_array( gettype( $value ), [
244 'integer',
245 'boolean',
246 ], true ) ? '%d' : ( in_array( gettype( $value ), [ 'double', 'float' ], true ) ? '%f' : '%s' );
247
248 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
249 $values[] = $wpdb->prepare( "WHEN variation_id = %d AND meta_key = %s THEN $type", $this->get_id(), $key, $value );
250 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
251 }
252 $values = implode( ' ', $values );
253
254 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
255 $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}storeengine_product_variation_meta
256 SET meta_value = CASE
257 $values
258 END
259 WHERE variation_id = %d", $this->get_id() ) );
260 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
261 }
262
263 protected function save_attributes() {
264 if ( empty( $this->attributes ) && empty( $this->new_attributes_term_ids ) ) {
265 return;
266 }
267
268 $term_ids = array_map( fn( $attribute ) => $attribute->term_id, $this->attributes );
269
270 $removed_items = array_values( array_diff( $term_ids, $this->new_attributes_term_ids ) );
271 $new_items = array_values( array_diff( $this->new_attributes_term_ids, $term_ids ) );
272
273 global $wpdb;
274 if ( ! empty( $removed_items ) ) {
275 $removed_placeholders = array_fill( 0, count( $removed_items ), '%d' );
276 $removed_placeholders = implode( ',', $removed_placeholders );
277
278 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
279 $wpdb->query( $wpdb->prepare(
280 "DELETE FROM {$wpdb->prefix}storeengine_variation_term_relations WHERE variation_id = %d AND term_id IN (" . $removed_placeholders . ')', $this->get_id(), ...$removed_items
281 ) );
282 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
283 wp_cache_delete( self::CACHE_KEY . $this->get_id(), self::CACHE_GROUP );
284 wp_cache_flush_group( AbstractProduct::CACHE_GROUP );
285 }
286
287 if ( ! empty( $new_items ) ) {
288 $values = [];
289 foreach ( $new_items as $item ) {
290 $values[] = $wpdb->prepare( '(%d, %d)', $this->get_id(), $item );
291 }
292 $values = implode( ', ', $values );
293
294 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- We've prepared values above.
295 $wpdb->query( "INSERT INTO {$wpdb->prefix}storeengine_variation_term_relations (variation_id, term_id) VALUES {$values}" );
296 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
297 wp_cache_flush_group( self::CACHE_GROUP );
298 }
299
300 // update order.
301 $update_query = "UPDATE {$wpdb->prefix}storeengine_variation_term_relations SET term_order = CASE ";
302 $values = [];
303
304 foreach ( $this->new_attributes_term_ids as $index => $new_attribute_term_id ) {
305 $update_query .= 'WHEN variation_id = %d AND term_id = %d THEN %d ';
306 $values[] = $this->get_id();
307 $values[] = $new_attribute_term_id;
308 $values[] = $index;
309 }
310 $update_query .= 'END WHERE variation_id = %d';
311 $values[] = $this->get_id();
312 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
313 $wpdb->query( $wpdb->prepare( $update_query, ...$values ) );
314 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
315
316 $this->get();
317 }
318
319 protected function save_delete_metadata() {
320 if ( empty( $this->delete_meta_data ) ) {
321 return;
322 }
323
324 $placeholders = array_fill( 0, count( $this->delete_meta_data ), '%s' );
325 $placeholders = implode( ',', $placeholders );
326
327 global $wpdb;
328 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- dynamically prepared.
329 $values = $wpdb->prepare( $placeholders, $this->delete_meta_data );
330
331 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery
332 $wpdb->query( $wpdb->prepare(
333 "DELETE FROM {$wpdb->prefix}storeengine_product_variation_meta WHERE variation_id = %d AND meta_key IN $values", $this->get_id()
334 ) );
335 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery
336 wp_cache_delete( self::CACHE_KEY . $this->get_id(), self::CACHE_GROUP );
337 }
338
339 public function delete(): bool {
340 if ( 0 === $this->get_id() ) {
341 return false;
342 }
343
344 global $wpdb;
345 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
346 $res = (bool) $wpdb->delete( $this->table, [ 'id' => $this->get_id() ], [ '%d' ] );
347 if ( $res ) {
348 $wpdb->delete( $wpdb->prefix . 'storeengine_product_variation_meta', [ 'variation_id' => $this->get_id() ], [ '%d' ] );
349 $wpdb->delete( $wpdb->prefix . 'storeengine_variation_term_relations', [ 'variation_id' => $this->get_id() ], [ '%d' ] );
350 }
351 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
352 wp_cache_delete( self::CACHE_KEY . $this->id, self::CACHE_GROUP );
353 wp_cache_flush_group( AbstractProduct::CACHE_GROUP );
354
355 return $res;
356 }
357
358 public function set_data( $data ): Variation {
359 $this->id = (int) $data->id;
360 $this->data['sku'] = $data->sku;
361 $this->data['barcode'] = isset( $data->barcode ) && '' !== $data->barcode ? (string) $data->barcode : null;
362 $this->data['product_id'] = (int) $data->product_id;
363 $this->data['price'] = $data->price ? (float) $data->price : null;
364 $this->data['cost_price'] = isset( $data->cost_price ) && '' !== $data->cost_price && null !== $data->cost_price ? (float) $data->cost_price : null;
365 $this->data['manage_stock'] = isset( $data->manage_stock ) ? (int) $data->manage_stock : 0;
366 $this->data['stock_quantity'] = isset( $data->stock_quantity ) && '' !== $data->stock_quantity && null !== $data->stock_quantity ? (int) $data->stock_quantity : null;
367 $this->data['stock_status'] = $data->stock_status ?? 'instock';
368 $this->data['backorders'] = $data->backorders ?? 'no';
369 $this->data['low_stock_threshold'] = isset( $data->low_stock_threshold ) && '' !== $data->low_stock_threshold && null !== $data->low_stock_threshold ? (int) $data->low_stock_threshold : null;
370 $attributes = $data->attributes;
371
372 if ( ! is_array( $attributes ) ) {
373 return $this;
374 }
375
376 usort( $attributes, fn( $a, $b ) => $a->term_order <=> $b->term_order );
377 $this->attributes = [];
378 foreach ( $attributes as $attribute ) {
379 $this->attributes[] = ( new AttributeData() )->set_data( $attribute );
380 }
381 $this->name = implode( ' / ', wp_list_pluck( $this->attributes, 'name' ) );
382
383 return $this;
384 }
385
386 public function get_id(): int {
387 return $this->id;
388 }
389
390 public function get_name(): string {
391 return $this->name;
392 }
393
394 public function get_sku() {
395 return $this->get_prop( 'sku' );
396 }
397
398 public function get_product_id() {
399 return $this->get_prop( 'product_id', 0 );
400 }
401
402 public function get_price() {
403 return $this->get_prop( 'price', null );
404 }
405
406 public function get_metadata( string $name ) {
407 return $this->get_prop_metadata( $name );
408 }
409
410 public function get_pricing_id() {
411 return $this->get_prop_metadata( '_pricing_id', null );
412 }
413
414 public function get_featured_image() {
415 return $this->get_prop_metadata( '_featured_image_id', null );
416 }
417
418 public function get_attributes(): array {
419 return $this->attributes;
420 }
421
422 public function set_product_id( int $value ) {
423 $this->new_data['product_id'] = $value;
424 }
425
426 public function set_sku( string $value ) {
427 $this->new_data['sku'] = $value;
428 }
429
430 public function get_barcode(): ?string {
431 $value = $this->get_prop( 'barcode', null );
432 return ( null === $value || '' === $value ) ? null : (string) $value;
433 }
434
435 public function set_barcode( ?string $value ): void {
436 $this->new_data['barcode'] = ( null === $value || '' === $value ) ? null : $value;
437 }
438
439 public function get_cost_price(): ?float {
440 $value = $this->get_prop( 'cost_price', null );
441 return ( null === $value || '' === $value ) ? null : (float) $value;
442 }
443
444 public function set_cost_price( ?float $value ): void {
445 $this->new_data['cost_price'] = $value;
446 }
447
448 /**
449 * @param float|null $value
450 *
451 * @return void
452 */
453 public function set_price( ?float $value ) {
454 $this->new_data['price'] = $value;
455 }
456
457 public function set_price_id( $value ) {
458 $this->set_prop_metadata( '_pricing_id', $value );
459 }
460
461 public function set_featured_image( ?int $attachment_id ) {
462 $this->set_prop_metadata( '_featured_image_id', $attachment_id );
463 }
464
465 public function add_metadata( string $name, $value ) {
466 $this->add_meta_data[ $name ] = $value;
467 }
468
469 public function update_metadata( string $name, $value ) {
470 $this->update_meta_data[ $name ] = $value;
471 }
472
473 public function remove_metadata( string $name ) {
474 $this->delete_meta_data[ $name ] = $name;
475 }
476
477 public function set_id( int $id ) {
478 $this->id = $id;
479 }
480
481 public function set_attributes( array $term_ids ) {
482 $this->new_attributes_term_ids = $term_ids;
483 }
484
485 public function set_metadata( array $data ) {
486 $this->meta_data = $data;
487 }
488
489 protected function get_prop_metadata( string $name, $default = '' ) {
490 if ( array_key_exists( $name, $this->update_meta_data ) ) {
491 return $this->update_meta_data[ $name ];
492 }
493
494 if ( array_key_exists( $name, $this->add_meta_data ) ) {
495 return $this->add_meta_data[ $name ];
496 }
497
498 return $this->meta_data[ $name ] ?? $default;
499 }
500
501 protected function get_prop( string $name, $default = '' ) {
502 if ( array_key_exists( $name, $this->new_data ) ) {
503 return $this->new_data[ $name ];
504 }
505
506 return $this->data[ $name ] ?? $default;
507 }
508
509 protected function set_prop_metadata( string $name, $value ) {
510 if ( array_key_exists( $name, $this->meta_data ) ) {
511 $this->update_meta_data[ $name ] = $value;
512 } else {
513 $this->add_meta_data[ $name ] = $value;
514 }
515 }
516
517 public function manages_stock(): bool {
518 return (bool) (int) $this->get_prop( 'manage_stock', 0 );
519 }
520
521 public function set_manage_stock( bool $value ): void {
522 $this->new_data['manage_stock'] = $value ? 1 : 0;
523 }
524
525 public function get_stock_quantity( string $context = 'view' ): ?int {
526 $value = $this->get_prop( 'stock_quantity', null );
527
528 if ( null === $value || '' === $value ) {
529 return null;
530 }
531
532 return (int) $value;
533 }
534
535 public function set_stock_quantity( ?int $qty ): void {
536 $this->new_data['stock_quantity'] = $qty;
537 }
538
539 public function get_stock_status(): string {
540 // When stock is tracked, derive the status from the on-hand quantity so
541 // it can never drift out of sync (a tracked variant at 0 must read
542 // "outofstock" even if a stale status meta still says "instock").
543 if ( $this->manages_stock() ) {
544 $qty = $this->get_stock_quantity();
545 if ( null !== $qty ) {
546 if ( $qty > 0 ) {
547 return 'instock';
548 }
549 return $this->backorders_allowed() ? 'onbackorder' : 'outofstock';
550 }
551 }
552
553 $status = $this->get_prop( 'stock_status', 'instock' );
554
555 if ( ! in_array( $status, [ 'instock', 'outofstock', 'onbackorder' ], true ) ) {
556 $status = 'instock';
557 }
558
559 return $status;
560 }
561
562 public function set_stock_status( string $status ): void {
563 if ( ! in_array( $status, [ 'instock', 'outofstock', 'onbackorder' ], true ) ) {
564 $status = 'instock';
565 }
566
567 $from = $this->get_stock_status();
568
569 $this->new_data['stock_status'] = $status;
570
571 if ( $from !== $status && $this->get_id() ) {
572 global $wpdb;
573 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
574 $wpdb->update( $this->table, [ 'stock_status' => $status ], [ 'id' => $this->get_id() ], [ '%s' ], [ '%d' ] );
575 // phpcs:enable
576 $this->data['stock_status'] = $status;
577 wp_cache_delete( self::CACHE_KEY . $this->get_id(), self::CACHE_GROUP );
578
579 do_action( 'storeengine/stock_status_changed', (int) $this->get_product_id(), $this->get_id(), $from, $status );
580 }
581 }
582
583 /**
584 * Stage a stock-status change to be flushed by save() — used during bulk
585 * variation persistence where we want a single DB write and a single hook
586 * fire from the caller.
587 */
588 public function new_data_set_stock_status( string $status ): void {
589 if ( ! in_array( $status, [ 'instock', 'outofstock', 'onbackorder' ], true ) ) {
590 $status = 'instock';
591 }
592 $this->new_data['stock_status'] = $status;
593 }
594
595 public function get_backorders(): string {
596 $value = $this->get_prop( 'backorders', 'no' );
597
598 if ( ! in_array( $value, [ 'no', 'notify', 'yes' ], true ) ) {
599 $value = 'no';
600 }
601
602 return $value;
603 }
604
605 public function set_backorders( string $value ): void {
606 if ( ! in_array( $value, [ 'no', 'notify', 'yes' ], true ) ) {
607 $value = 'no';
608 }
609 $this->new_data['backorders'] = $value;
610 }
611
612 public function backorders_allowed(): bool {
613 return in_array( $this->get_backorders(), [ 'yes', 'notify' ], true );
614 }
615
616 public function backorders_require_notification(): bool {
617 return 'notify' === $this->get_backorders();
618 }
619
620 public function get_low_stock_threshold(): ?int {
621 $value = $this->get_prop( 'low_stock_threshold', null );
622
623 if ( null === $value || '' === $value ) {
624 $global = (int) get_option( 'storeengine_low_stock_threshold', 0 );
625 return $global > 0 ? $global : null;
626 }
627
628 return (int) $value;
629 }
630
631 public function set_low_stock_threshold( ?int $value ): void {
632 $this->new_data['low_stock_threshold'] = $value;
633 }
634
635 public function get_reserved_stock(): int {
636 global $wpdb;
637
638 $table = $wpdb->prefix . 'storeengine_reserved_stock';
639
640 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
641 $qty = $wpdb->get_var( $wpdb->prepare(
642 "SELECT COALESCE(SUM(stock_quantity),0) FROM {$table} WHERE variation_id = %d AND expires > %s",
643 $this->get_id(),
644 current_time( 'mysql', true )
645 ) );
646 // phpcs:enable
647
648 return (int) $qty;
649 }
650
651 public function get_available_stock_quantity(): ?int {
652 $qty = $this->get_stock_quantity();
653
654 if ( null === $qty ) {
655 return null;
656 }
657
658 return max( 0, $qty - $this->get_reserved_stock() );
659 }
660
661 public function has_enough_stock( int $qty ): bool {
662 if ( ! $this->manages_stock() ) {
663 return $this->is_in_stock();
664 }
665
666 if ( $this->backorders_allowed() ) {
667 return true;
668 }
669
670 $available = $this->get_available_stock_quantity();
671
672 return null === $available || $available >= $qty;
673 }
674
675 public function is_low_stock(): bool {
676 if ( ! $this->manages_stock() ) {
677 return false;
678 }
679
680 $threshold = $this->get_low_stock_threshold();
681 $qty = $this->get_stock_quantity();
682
683 if ( null === $threshold || null === $qty ) {
684 return false;
685 }
686
687 return $qty > 0 && $qty <= $threshold;
688 }
689
690 public function is_in_stock(): bool {
691 if ( $this->manages_stock() ) {
692 // Mirror AbstractProduct::is_in_stock() — subtract live
693 // reservations so the cart's has_enough_stock() agrees with
694 // the storefront badge for variable products too.
695 $qty = $this->get_available_stock_quantity();
696
697 if ( null === $qty ) {
698 return $this->backorders_allowed();
699 }
700
701 return $qty > 0 || $this->backorders_allowed();
702 }
703
704 $status = $this->get_stock_status();
705
706 return 'instock' === $status || ( 'onbackorder' === $status && $this->backorders_allowed() );
707 }
708
709 public function is_sold_individually(): bool {
710 // Variations defer to the parent product for sold-individually behavior.
711 $product = $this->get_product_id() ? Helper::get_product( (int) $this->get_product_id() ) : null;
712
713 return $product && method_exists( $product, 'is_sold_individually' ) ? $product->is_sold_individually() : false;
714 }
715
716 public function reduce_stock( int $qty ): int {
717 if ( ! $this->manages_stock() ) {
718 return 0;
719 }
720
721 $current = (int) $this->get_stock_quantity();
722 $new_qty = $current - $qty;
723
724 global $wpdb;
725 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
726 $wpdb->update( $this->table, [ 'stock_quantity' => $new_qty ], [ 'id' => $this->get_id() ], [ '%d' ], [ '%d' ] );
727 // phpcs:enable
728 $this->data['stock_quantity'] = $new_qty;
729 wp_cache_delete( self::CACHE_KEY . $this->get_id(), self::CACHE_GROUP );
730
731 $this->maybe_sync_stock_status_from_quantity( $new_qty );
732
733 return $new_qty;
734 }
735
736 public function increase_stock( int $qty ): int {
737 if ( ! $this->manages_stock() ) {
738 return 0;
739 }
740
741 $current = (int) $this->get_stock_quantity();
742 $new_qty = $current + $qty;
743
744 global $wpdb;
745 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
746 $wpdb->update( $this->table, [ 'stock_quantity' => $new_qty ], [ 'id' => $this->get_id() ], [ '%d' ], [ '%d' ] );
747 // phpcs:enable
748 $this->data['stock_quantity'] = $new_qty;
749 wp_cache_delete( self::CACHE_KEY . $this->get_id(), self::CACHE_GROUP );
750
751 $this->maybe_sync_stock_status_from_quantity( $new_qty );
752
753 return $new_qty;
754 }
755
756 protected function maybe_sync_stock_status_from_quantity( int $new_qty ): void {
757 $old_status = $this->get_stock_status();
758
759 if ( $new_qty <= 0 && ! $this->backorders_allowed() ) {
760 $new_status = 'outofstock';
761 } elseif ( $new_qty <= 0 && $this->backorders_allowed() ) {
762 $new_status = 'onbackorder';
763 } else {
764 $new_status = 'instock';
765 }
766
767 if ( $old_status !== $new_status ) {
768 $this->set_stock_status( $new_status );
769 }
770 }
771 }
772