Changeset 3717978
- Timestamp:
- 09/28/2026 10:29:36 PM (4 weeks ago)
- Location:
- tarkva-expiry-tracking-and-discount-engine
- Files:
-
- 13 edited
- 6 copied
-
tags/1.9.1 (copied) (copied from tarkva-expiry-tracking-and-discount-engine/trunk )
-
tags/1.9.1/readme.txt (copied) (copied from tarkva-expiry-tracking-and-discount-engine/trunk/readme.txt ) (3 diffs)
-
tags/1.9.1/tarkva-expiry-tracking-and-discount-engine.php (copied) (copied from tarkva-expiry-tracking-and-discount-engine/trunk/tarkva-expiry-tracking-and-discount-engine.php ) (2 diffs)
-
tags/1.9.1/uninstall.php (copied) (copied from tarkva-expiry-tracking-and-discount-engine/trunk/uninstall.php )
-
tags/1.9.1/wcde-admin.php (copied) (copied from tarkva-expiry-tracking-and-discount-engine/trunk/wcde-admin.php )
-
tags/1.9.1/wcde-batch-orders.php (modified) (7 diffs)
-
tags/1.9.1/wcde-batch-source.php (modified) (1 diff)
-
tags/1.9.1/wcde-batch-store.php (copied) (copied from tarkva-expiry-tracking-and-discount-engine/trunk/wcde-batch-store.php ) (3 diffs)
-
tags/1.9.1/wcde-sales-log.php (modified) (2 diffs)
-
tags/1.9.1/wcde-stock-filter.php (modified) (3 diffs)
-
tags/1.9.1/wcde-stock-status.php (modified) (5 diffs)
-
trunk/readme.txt (modified) (3 diffs)
-
trunk/tarkva-expiry-tracking-and-discount-engine.php (modified) (2 diffs)
-
trunk/wcde-batch-orders.php (modified) (7 diffs)
-
trunk/wcde-batch-source.php (modified) (1 diff)
-
trunk/wcde-batch-store.php (modified) (3 diffs)
-
trunk/wcde-sales-log.php (modified) (2 diffs)
-
trunk/wcde-stock-filter.php (modified) (3 diffs)
-
trunk/wcde-stock-status.php (modified) (5 diffs)
Legend:
- Unmodified
- Added
- Removed
-
tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/readme.txt
r3685476 r3717978 5 5 Tested up to: 7.0 6 6 Requires PHP: 8.0 7 Stable tag: 1.9. 07 Stable tag: 1.9.1 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 111 111 112 112 == Changelog == 113 114 = 1.9.1 = 115 116 **Fixed** 117 118 * Stock and availability now update right after an order or refund changes a product's batches. With the Pro add-on's instant recompute, prices update at the same moment. 119 * Removing or selling a product's last batch now sets its stock to 0, including when saving a product whose batch list is already empty, and its expiry discount is removed by the next scheduled run. Stock left over from earlier deletions is corrected the same way. 120 * A product with no sellable batch is no longer shown as in stock. 121 * Refunds with restocking now return units to the batch they were sold from, even if that batch was already sold out or removed. 122 * Sold-out batches are now removed from the batch list. 123 * Discounts on individual variations are now recorded correctly for reporting. 113 124 114 125 = 1.9.0 = … … 276 287 == Upgrade Notice == 277 288 289 = 1.9.1 = 290 Fixes stock and discounts after a batch sells out, is removed or is refunded. Recommended for all users. 291 278 292 = 1.9.0 = 279 293 Fixes hand-set sale prices being reverted on already-discounted products. A product you price yourself no longer shows the expiry notice. -
tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/tarkva-expiry-tracking-and-discount-engine.php
r3685476 r3717978 3 3 * Plugin Name: Tarkva - Expiry Tracking and Discount Engine 4 4 * Description: Track batch/expiry dates natively and apply automatic clearance pricing as products approach their best-before or use-by date. Admin UI, front-end notices, and product-feed support. 5 * Version: 1.9. 05 * Version: 1.9.1 6 6 * Author: Tarkva 7 7 * Author URI: https://tarkva.com … … 19 19 // tarkva-expiry-tracking-and-discount-engine-pro.php). Keep this in sync with the Version: header above — 20 20 // they're two separate strings WordPress has no way to keep matched for you. 21 define( 'WCDE_VERSION', '1.9. 0' );21 define( 'WCDE_VERSION', '1.9.1' ); 22 22 23 23 /** -
tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-batch-orders.php
r3630783 r3717978 93 93 94 94 /** 95 * Which id carries this line item's batches (variation if it has its own, else product).96 95 * The id that carries this line item's batches (variation if it has its own, else product). 96 * Batch history counts, not just current rows, so a product whose batches have all sold out or 97 * been removed is still recorded. 97 98 */ 98 99 private function batch_product_id_for_item( $item ) { 99 100 $vid = is_callable( array( $item, 'get_variation_id' ) ) ? (int) $item->get_variation_id() : 0; 100 if ( $vid && WCDE_Batch_Store::get_batches( $vid ) ) { return $vid; } 101 $pid = (int) $item->get_product_id(); 102 if ( WCDE_Batch_Store::get_batches( $pid ) ) { return $pid; } 101 return $this->batch_owner( $vid, (int) $item->get_product_id() ); 102 } 103 104 private function batch_owner( $vid, $pid ) { 105 if ( $vid && WCDE_Batch_Store::has_batch_history( $vid ) ) { return (int) $vid; } 106 if ( $pid && WCDE_Batch_Store::has_batch_history( $pid ) ) { return (int) $pid; } 103 107 return 0; 104 108 } … … 154 158 $engine = $this->engine(); 155 159 if ( ! $engine ) { return; } 156 $notes = array(); 160 $notes = array(); 161 $touched = array(); 157 162 158 163 foreach ( $order->get_items() as $item_id => $item ) { … … 168 173 $plan = $this->plan_consumption( $pid, $qty ); 169 174 if ( ! $plan ) { continue; } 175 $touched[ $pid ] = true; 170 176 171 177 foreach ( $plan as $p ) { … … 185 191 $order->update_meta_data( self::REDUCED_FLAG, 'yes' ); 186 192 $order->save(); 193 194 // WooCommerce's stock hooks fired before the batches changed; re-run batch-driven checks. 195 foreach ( array_keys( $touched ) as $pid ) { 196 do_action( 'wcde_batches_changed', (int) $pid ); 197 } 187 198 } catch ( \Throwable $e ) { 188 199 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { … … 218 229 * WC restocked ($new-$old) units of $product from a refund. Put them back into the batches 219 230 * this product actually consumed, newest-consumed first, capped by the ledger so we never 220 * over-restock across multiple partial refunds. 231 * over-restock across multiple partial refunds. A batch that has since sold out or been 232 * removed is recreated. Units with no batch to return to are not kept: stock is then brought 233 * back in line with the batch total, so a tracked product never holds batchless stock. 221 234 */ 222 235 public function on_restock_refunded_item( $product_id, $old_stock, $new_stock, $order, $product ) { … … 229 242 230 243 // Resolve the batch id (variation vs parent) the same way reduce did. 231 $ pid = ( $product instanceof WC_Product && WCDE_Batch_Store::get_batches( $product->get_id() ) )232 ? (int) $product->get_id()233 : (int) $product_id;234 if ( ! WCDE_Batch_Store::get_batches( $pid ) ) { return; }244 $own = $product instanceof WC_Product ? (int) $product->get_id() : (int) $product_id; 245 $parent = $product instanceof WC_Product ? (int) $product->get_parent_id() : 0; 246 $pid = $parent ? $this->batch_owner( $own, $parent ) : $this->batch_owner( 0, $own ); 247 if ( ! $pid || ! WCDE_Expiry_Type::get( $pid ) ) { return; } 235 248 236 249 // Ledger rows for this product on this order that still have restock headroom. … … 268 281 $remaining -= $take; 269 282 } 270 // If $remaining > 0 here, the surplus came from dateless/overflow consumption with no 271 // batch to return it to — nothing to do, and WC's own stock has already been corrected.283 284 do_action( 'wcde_batches_changed', $pid ); 272 285 } catch ( \Throwable $e ) { 273 286 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { -
tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-batch-source.php
r3630783 r3717978 11 11 12 12 /** 13 * Product IDs that have native batches (optionally filtered by category slug). 13 * Product IDs the pricing engine works over: every tracked product, including one whose 14 * batches have all gone, so its discount is cleared. Optionally filtered by category slug 15 * (a variation matches through its parent). 14 16 */ 15 17 public static function native_product_ids( $cat, $limit ) { 16 global $wpdb;17 18 $limit = max( 1, (int) $limit ); 18 $bt = WCDE_Batch_Store::batches_table(); 19 $ids = WCDE_Batch_Store::tracked_product_ids(); 20 if ( $cat ) { 21 $ids = array_values( array_filter( $ids, function ( $pid ) use ( $cat ) { 22 $owner = (int) wp_get_post_parent_id( $pid ) ?: (int) $pid; 23 return has_term( $cat, 'product_cat', $owner ); 24 } ) ); 25 } 26 return array_slice( $ids, 0, $limit ); 27 } 19 28 20 if ( $cat ) { 21 return $wpdb->get_col( $wpdb->prepare( 22 "SELECT DISTINCT b.product_id 23 FROM %i b 24 JOIN {$wpdb->term_relationships} tr ON tr.object_id = b.product_id 25 JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'product_cat' 26 JOIN {$wpdb->terms} t ON t.term_id = tt.term_id AND t.slug = %s 27 LIMIT %d", 28 $bt, $cat, $limit 29 ) ); 29 /** Units in batches that can still be sold (positive qty, sellable deadline not yet passed). */ 30 public static function sellable_amount( $pid, $grace ) { 31 $n = 0; 32 foreach ( WCDE_Batch_Store::get_live_batches( (int) $pid ) as $b ) { 33 if ( WCDE_Dates::days_until( $b['date'], $grace ) >= 0 ) { $n += (int) $b['qty']; } 30 34 } 31 return $wpdb->get_col( $wpdb->prepare( 32 'SELECT DISTINCT product_id FROM %i LIMIT %d', 33 $bt, $limit 34 ) ); 35 return $n; 35 36 } 36 37 -
tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-batch-store.php
r3648865 r3717978 444 444 ) 445 445 ); 446 if ( $affected > 0 ) { self::invalidate_cache( $product_id ); } 446 if ( $affected > 0 ) { 447 $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE product_id = %d AND expiry_date = %s AND qty <= 0', $table, (int) $product_id, $date ) ); 448 self::invalidate_cache( $product_id ); 449 } 447 450 return $affected; 451 } 452 453 /** Remove every sold-out (qty <= 0) batch row. A refund recreates a row from the ledger if needed. */ 454 public static function purge_empty_batches() { 455 global $wpdb; 456 $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT product_id FROM %i WHERE qty <= 0', self::batches_table() ) ); 457 if ( ! $ids ) { return 0; } 458 $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE qty <= 0', self::batches_table() ) ); 459 foreach ( $ids as $pid ) { self::invalidate_cache( (int) $pid ); } 460 return count( $ids ); 461 } 462 463 /** Whether this product has, or has ever had, a batch: a live row, a sale from one, or logged waste. */ 464 public static function has_batch_history( $product_id ) { 465 $product_id = (int) $product_id; 466 if ( ! $product_id ) { return false; } 467 if ( self::get_batches( $product_id ) ) { return true; } 468 global $wpdb; 469 if ( $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE product_id = %d LIMIT 1', self::consumption_table(), $product_id ) ) ) { return true; } 470 return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE product_id = %d LIMIT 1', self::waste_log_table(), $product_id ) ); 471 } 472 473 /** 474 * Set WooCommerce stock to the batch total (expired units included — they are real stock the 475 * owner still holds). Only for batch-driven products: pass $force when the caller knows the 476 * product had batches, otherwise a product that has never had one keeps its stock untouched. 477 */ 478 public static function sync_stock( $product_id, $force = false ) { 479 $product_id = (int) $product_id; 480 if ( ! WCDE_Expiry_Type::get( $product_id ) ) { return; } 481 if ( ! $force && ! self::has_batch_history( $product_id ) ) { return; } 482 $product = wc_get_product( $product_id ); 483 if ( ! $product || $product->is_type( 'variable' ) ) { return; } 484 if ( ! $product->get_manage_stock() ) { 485 $product->set_manage_stock( true ); 486 $product->save(); 487 } 488 $sum = self::total_qty( $product_id ); 489 $cur = get_post_meta( $product_id, '_stock', true ); 490 if ( '' === $cur || null === $cur || (float) $cur !== (float) $sum ) { 491 wc_update_product_stock( $product, $sum, 'set' ); 492 } 448 493 } 449 494 … … 500 545 if ( ! $wants_tracking ) { 501 546 self::set_batches( $product_id, array() ); 547 do_action( 'wcde_batches_changed', $product_id ); 502 548 return true; 503 549 } 504 550 551 $had_rows = (bool) self::get_batches( $product_id ); 505 552 self::set_batches( $product_id, $batches ); 506 553 507 // Tracked: batches are the source of truth for stock. Sync WooCommerce _stock to the batch 508 // total (total physical), overwriting any manual stock edit; force manage-stock on. 509 if ( ! empty( $batches ) ) { 510 $product = wc_get_product( $product_id ); 511 if ( $product ) { 512 if ( ! $product->get_manage_stock() ) { 513 $product->set_manage_stock( true ); 514 $product->save(); 515 } 516 wc_update_product_stock( $product, self::total_qty( $product_id ), 'set' ); 517 } 518 } 554 // Tracked: batches are the source of truth for stock, including an emptied list (stock 0). 555 self::sync_stock( $product_id, $had_rows || ! empty( $batches ) ); 556 do_action( 'wcde_batches_changed', $product_id ); 519 557 return true; 520 558 } 521 559 522 560 /** 523 * Every product ID this store considers "tracked" — has batch rows, or has the expiry-type meta524 * set, or both. Shared by WCDE_Backup::snapshot() and CSV export so this union query exists in525 * exactly one place.561 * Every product ID this store considers "tracked" — has batch rows, the expiry-type meta, or 562 * batch sales/waste history. Shared by the nightly jobs, reporting, backups and CSV export so 563 * this union exists in exactly one place. 526 564 */ 527 565 public static function tracked_product_ids() { … … 533 571 ) ); 534 572 $ids = array_unique( array_map( 'intval', array_merge( (array) $ids1, (array) $ids2 ) ) ); 573 574 // Sold-out batch rows are deleted, so a product whose batches have all gone (a variation has 575 // no type meta of its own) is still tracked if it has sales or waste history. 576 $hist = array_merge( 577 (array) $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT product_id FROM %i WHERE expiry_date IS NOT NULL', self::consumption_table() ) ), 578 (array) $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT product_id FROM %i', self::waste_log_table() ) ) 579 ); 580 $hist = array_diff( array_unique( array_map( 'intval', $hist ) ), $ids ); 581 if ( $hist ) { 582 _prime_post_caches( $hist, false, true ); 583 foreach ( $hist as $pid ) { 584 if ( $pid && WCDE_Expiry_Type::get( $pid ) ) { $ids[] = $pid; } 585 } 586 } 535 587 sort( $ids ); 536 588 return $ids; -
tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-sales-log.php
r3630783 r3717978 29 29 public function tag_line_item( $item, $cart_item_key, $values, $order ) { 30 30 if ( ! empty( $values['wcde_full'] ) ) { return; } // full-price split line (Pro cart-split): not discounted 31 $pid = $values['product_id'] ?? 0;31 $pid = (int) ( $values['product_id'] ?? 0 ); 32 32 if ( ! $pid ) { return; } 33 $vid = (int) ( $values['variation_id'] ?? 0 ); 33 34 34 $product = wc_get_product( $ pid );35 $product = wc_get_product( $vid ?: $pid ); 35 36 36 37 // Per-unit regular ("list") price at time of purchase, tagged for EVERY line regardless of … … 45 46 } 46 47 48 // Discounts are priced per variation when a variation carries its own batches. 49 if ( $vid && 'yes' === get_post_meta( $vid, '_wcde_managed', true ) ) { $pid = $vid; } 47 50 if ( 'yes' !== get_post_meta( $pid, '_wcde_managed', true ) ) { return; } 48 51 -
tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-stock-filter.php
r3630783 r3717978 122 122 * ============================================================ */ 123 123 124 /** Units a shopper can actually buy: never more than the sellable batches hold. */ 125 private static function sellable( $pid, $stock ) { 126 $type = self::expiry_type( $pid ); 127 $batch_sellable = $type ? (float) WCDE_Batch_Source::sellable_amount( (int) $pid, self::grace_for( $type ) ) : 0; 128 return max( 0, min( (float) $stock - self::overdue( $pid ), $batch_sellable ) ); 129 } 130 124 131 public function filter_quantity( $value, $product ) { 125 132 if ( ! self::active() || ! self::customer_context() ) { return $value; } 126 133 if ( '' === $value || null === $value ) { return $value; } // not managing stock 134 if ( $product->is_type( 'variable' ) ) { return $value; } 127 135 $pid = $product->get_id(); 128 136 if ( ! self::expiry_type( $pid ) ) { return $value; } 129 137 130 $overdue = self::overdue( $pid ); 131 if ( $overdue <= 0 ) { return $value; } 132 return max( 0, (float) $value - $overdue ); 138 $sellable = self::sellable( $pid, $value ); 139 return ( (float) $value === $sellable ) ? $value : $sellable; 133 140 } 134 141 … … 136 143 if ( ! self::active() || ! self::availability_context() ) { return $status; } 137 144 if ( 'instock' !== $status ) { return $status; } // only ever downgrade to out-of-stock 145 if ( $product->is_type( 'variable' ) ) { return $status; } 138 146 $pid = $product->get_id(); 139 147 if ( ! self::expiry_type( $pid ) ) { return $status; } … … 141 149 $raw = self::raw_stock( $pid ); 142 150 if ( null === $raw ) { return $status; } // not managing stock -> leave alone 143 $sellable = $raw - self::overdue( $pid ); 144 return ( $sellable <= 0 ) ? 'outofstock' : $status; 151 return ( self::sellable( $pid, $raw ) <= 0 ) ? 'outofstock' : $status; 145 152 } 146 153 -
tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-stock-status.php
r3630783 r3717978 20 20 add_action( 'woocommerce_updated_product_stock', array( $this, 'refresh_id' ), 30 ); 21 21 add_action( 'woocommerce_product_set_stock', array( $this, 'refresh_obj' ), 30 ); 22 add_action( 'wcde_batches_changed', array( $this, 'on_batches_changed' ), 10 ); 22 23 23 24 if ( defined( 'WP_CLI' ) && WP_CLI ) { … … 38 39 } 39 40 40 /** Desired status for a product, or null if it shouldn't be touched. */ 41 /** 42 * Desired status for a product, or null if it shouldn't be touched. A tracked product is only 43 * in stock while it has a sellable batch — a leftover _stock number alone never makes it 44 * purchasable. Variable parents are left to WooCommerce, which derives them from variations. 45 */ 41 46 private static function desired_status( $pid ) { 42 47 $type = self::expiry_type( $pid ); … … 44 49 $raw = get_post_meta( $pid, '_stock', true ); 45 50 if ( '' === $raw || null === $raw ) { return null; } // not managing stock 46 $sellable = (float) $raw - (int) WCDE_Batch_Source::overdue_amount( $pid, self::grace_for( $type ) ); 51 if ( 'variable' === WC_Product_Factory::get_product_type( $pid ) ) { return null; } 52 $grace = self::grace_for( $type ); 53 $sellable = min( 54 (float) $raw - (int) WCDE_Batch_Source::overdue_amount( $pid, $grace ), 55 (float) WCDE_Batch_Source::sellable_amount( $pid, $grace ) 56 ); 47 57 return ( $sellable <= 0 ) ? 'outofstock' : 'instock'; 48 58 } 49 59 50 /** Enforce _stock = batch sum for trackedproducts (heals ATUM/manual drift). */60 /** Enforce _stock = batch total for batch-driven products (heals ATUM/manual drift). */ 51 61 public static function resync_stock( $pid ) { 52 62 if ( ! self::active() ) { return; } 53 $pid = (int) $pid; 54 if ( ! self::expiry_type( $pid ) ) { return; } 55 $batches = WCDE_Batch_Store::get_batches( $pid ); 56 if ( empty( $batches ) ) { return; } // no batches -> don't touch stock 57 $product = wc_get_product( $pid ); 58 if ( ! $product || ! $product->get_manage_stock() ) { return; } 59 $sum = (int) WCDE_Batch_Store::total_qty( $pid ); 60 $cur = get_post_meta( $pid, '_stock', true ); 61 if ( '' === $cur || (int) $cur !== $sum ) { 62 wc_update_product_stock( $product, $sum, 'set' ); 63 } 63 WCDE_Batch_Store::sync_stock( (int) $pid ); 64 } 65 66 public function on_batches_changed( $pid ) { 67 self::resync_stock( $pid ); 68 self::refresh( $pid ); 64 69 } 65 70 … … 87 92 // regardless of WooCommerce's current active state. 88 93 if ( ! self::active() || ! class_exists( 'WooCommerce' ) ) { return; } 89 global $wpdb;90 $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT product_id FROM %i', WCDE_Batch_Store::batches_table() ));94 WCDE_Batch_Store::purge_empty_batches(); 95 $ids = WCDE_Batch_Store::tracked_product_ids(); 91 96 if ( $ids ) { 92 97 _prime_post_caches( array_map( 'intval', $ids ), false, true ); … … 104 109 */ 105 110 public function cli_sweep( $args, $assoc ) { 106 global $wpdb; 107 $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT product_id FROM %i', WCDE_Batch_Store::batches_table() ) ); 111 $ids = WCDE_Batch_Store::tracked_product_ids(); 108 112 if ( $ids ) { 109 113 _prime_post_caches( array_map( 'intval', $ids ), false, true ); -
tarkva-expiry-tracking-and-discount-engine/trunk/readme.txt
r3685476 r3717978 5 5 Tested up to: 7.0 6 6 Requires PHP: 8.0 7 Stable tag: 1.9. 07 Stable tag: 1.9.1 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 111 111 112 112 == Changelog == 113 114 = 1.9.1 = 115 116 **Fixed** 117 118 * Stock and availability now update right after an order or refund changes a product's batches. With the Pro add-on's instant recompute, prices update at the same moment. 119 * Removing or selling a product's last batch now sets its stock to 0, including when saving a product whose batch list is already empty, and its expiry discount is removed by the next scheduled run. Stock left over from earlier deletions is corrected the same way. 120 * A product with no sellable batch is no longer shown as in stock. 121 * Refunds with restocking now return units to the batch they were sold from, even if that batch was already sold out or removed. 122 * Sold-out batches are now removed from the batch list. 123 * Discounts on individual variations are now recorded correctly for reporting. 113 124 114 125 = 1.9.0 = … … 276 287 == Upgrade Notice == 277 288 289 = 1.9.1 = 290 Fixes stock and discounts after a batch sells out, is removed or is refunded. Recommended for all users. 291 278 292 = 1.9.0 = 279 293 Fixes hand-set sale prices being reverted on already-discounted products. A product you price yourself no longer shows the expiry notice. -
tarkva-expiry-tracking-and-discount-engine/trunk/tarkva-expiry-tracking-and-discount-engine.php
r3685476 r3717978 3 3 * Plugin Name: Tarkva - Expiry Tracking and Discount Engine 4 4 * Description: Track batch/expiry dates natively and apply automatic clearance pricing as products approach their best-before or use-by date. Admin UI, front-end notices, and product-feed support. 5 * Version: 1.9. 05 * Version: 1.9.1 6 6 * Author: Tarkva 7 7 * Author URI: https://tarkva.com … … 19 19 // tarkva-expiry-tracking-and-discount-engine-pro.php). Keep this in sync with the Version: header above — 20 20 // they're two separate strings WordPress has no way to keep matched for you. 21 define( 'WCDE_VERSION', '1.9. 0' );21 define( 'WCDE_VERSION', '1.9.1' ); 22 22 23 23 /** -
tarkva-expiry-tracking-and-discount-engine/trunk/wcde-batch-orders.php
r3630783 r3717978 93 93 94 94 /** 95 * Which id carries this line item's batches (variation if it has its own, else product).96 95 * The id that carries this line item's batches (variation if it has its own, else product). 96 * Batch history counts, not just current rows, so a product whose batches have all sold out or 97 * been removed is still recorded. 97 98 */ 98 99 private function batch_product_id_for_item( $item ) { 99 100 $vid = is_callable( array( $item, 'get_variation_id' ) ) ? (int) $item->get_variation_id() : 0; 100 if ( $vid && WCDE_Batch_Store::get_batches( $vid ) ) { return $vid; } 101 $pid = (int) $item->get_product_id(); 102 if ( WCDE_Batch_Store::get_batches( $pid ) ) { return $pid; } 101 return $this->batch_owner( $vid, (int) $item->get_product_id() ); 102 } 103 104 private function batch_owner( $vid, $pid ) { 105 if ( $vid && WCDE_Batch_Store::has_batch_history( $vid ) ) { return (int) $vid; } 106 if ( $pid && WCDE_Batch_Store::has_batch_history( $pid ) ) { return (int) $pid; } 103 107 return 0; 104 108 } … … 154 158 $engine = $this->engine(); 155 159 if ( ! $engine ) { return; } 156 $notes = array(); 160 $notes = array(); 161 $touched = array(); 157 162 158 163 foreach ( $order->get_items() as $item_id => $item ) { … … 168 173 $plan = $this->plan_consumption( $pid, $qty ); 169 174 if ( ! $plan ) { continue; } 175 $touched[ $pid ] = true; 170 176 171 177 foreach ( $plan as $p ) { … … 185 191 $order->update_meta_data( self::REDUCED_FLAG, 'yes' ); 186 192 $order->save(); 193 194 // WooCommerce's stock hooks fired before the batches changed; re-run batch-driven checks. 195 foreach ( array_keys( $touched ) as $pid ) { 196 do_action( 'wcde_batches_changed', (int) $pid ); 197 } 187 198 } catch ( \Throwable $e ) { 188 199 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { … … 218 229 * WC restocked ($new-$old) units of $product from a refund. Put them back into the batches 219 230 * this product actually consumed, newest-consumed first, capped by the ledger so we never 220 * over-restock across multiple partial refunds. 231 * over-restock across multiple partial refunds. A batch that has since sold out or been 232 * removed is recreated. Units with no batch to return to are not kept: stock is then brought 233 * back in line with the batch total, so a tracked product never holds batchless stock. 221 234 */ 222 235 public function on_restock_refunded_item( $product_id, $old_stock, $new_stock, $order, $product ) { … … 229 242 230 243 // Resolve the batch id (variation vs parent) the same way reduce did. 231 $ pid = ( $product instanceof WC_Product && WCDE_Batch_Store::get_batches( $product->get_id() ) )232 ? (int) $product->get_id()233 : (int) $product_id;234 if ( ! WCDE_Batch_Store::get_batches( $pid ) ) { return; }244 $own = $product instanceof WC_Product ? (int) $product->get_id() : (int) $product_id; 245 $parent = $product instanceof WC_Product ? (int) $product->get_parent_id() : 0; 246 $pid = $parent ? $this->batch_owner( $own, $parent ) : $this->batch_owner( 0, $own ); 247 if ( ! $pid || ! WCDE_Expiry_Type::get( $pid ) ) { return; } 235 248 236 249 // Ledger rows for this product on this order that still have restock headroom. … … 268 281 $remaining -= $take; 269 282 } 270 // If $remaining > 0 here, the surplus came from dateless/overflow consumption with no 271 // batch to return it to — nothing to do, and WC's own stock has already been corrected.283 284 do_action( 'wcde_batches_changed', $pid ); 272 285 } catch ( \Throwable $e ) { 273 286 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { -
tarkva-expiry-tracking-and-discount-engine/trunk/wcde-batch-source.php
r3630783 r3717978 11 11 12 12 /** 13 * Product IDs that have native batches (optionally filtered by category slug). 13 * Product IDs the pricing engine works over: every tracked product, including one whose 14 * batches have all gone, so its discount is cleared. Optionally filtered by category slug 15 * (a variation matches through its parent). 14 16 */ 15 17 public static function native_product_ids( $cat, $limit ) { 16 global $wpdb;17 18 $limit = max( 1, (int) $limit ); 18 $bt = WCDE_Batch_Store::batches_table(); 19 $ids = WCDE_Batch_Store::tracked_product_ids(); 20 if ( $cat ) { 21 $ids = array_values( array_filter( $ids, function ( $pid ) use ( $cat ) { 22 $owner = (int) wp_get_post_parent_id( $pid ) ?: (int) $pid; 23 return has_term( $cat, 'product_cat', $owner ); 24 } ) ); 25 } 26 return array_slice( $ids, 0, $limit ); 27 } 19 28 20 if ( $cat ) { 21 return $wpdb->get_col( $wpdb->prepare( 22 "SELECT DISTINCT b.product_id 23 FROM %i b 24 JOIN {$wpdb->term_relationships} tr ON tr.object_id = b.product_id 25 JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'product_cat' 26 JOIN {$wpdb->terms} t ON t.term_id = tt.term_id AND t.slug = %s 27 LIMIT %d", 28 $bt, $cat, $limit 29 ) ); 29 /** Units in batches that can still be sold (positive qty, sellable deadline not yet passed). */ 30 public static function sellable_amount( $pid, $grace ) { 31 $n = 0; 32 foreach ( WCDE_Batch_Store::get_live_batches( (int) $pid ) as $b ) { 33 if ( WCDE_Dates::days_until( $b['date'], $grace ) >= 0 ) { $n += (int) $b['qty']; } 30 34 } 31 return $wpdb->get_col( $wpdb->prepare( 32 'SELECT DISTINCT product_id FROM %i LIMIT %d', 33 $bt, $limit 34 ) ); 35 return $n; 35 36 } 36 37 -
tarkva-expiry-tracking-and-discount-engine/trunk/wcde-batch-store.php
r3648865 r3717978 444 444 ) 445 445 ); 446 if ( $affected > 0 ) { self::invalidate_cache( $product_id ); } 446 if ( $affected > 0 ) { 447 $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE product_id = %d AND expiry_date = %s AND qty <= 0', $table, (int) $product_id, $date ) ); 448 self::invalidate_cache( $product_id ); 449 } 447 450 return $affected; 451 } 452 453 /** Remove every sold-out (qty <= 0) batch row. A refund recreates a row from the ledger if needed. */ 454 public static function purge_empty_batches() { 455 global $wpdb; 456 $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT product_id FROM %i WHERE qty <= 0', self::batches_table() ) ); 457 if ( ! $ids ) { return 0; } 458 $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE qty <= 0', self::batches_table() ) ); 459 foreach ( $ids as $pid ) { self::invalidate_cache( (int) $pid ); } 460 return count( $ids ); 461 } 462 463 /** Whether this product has, or has ever had, a batch: a live row, a sale from one, or logged waste. */ 464 public static function has_batch_history( $product_id ) { 465 $product_id = (int) $product_id; 466 if ( ! $product_id ) { return false; } 467 if ( self::get_batches( $product_id ) ) { return true; } 468 global $wpdb; 469 if ( $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE product_id = %d LIMIT 1', self::consumption_table(), $product_id ) ) ) { return true; } 470 return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE product_id = %d LIMIT 1', self::waste_log_table(), $product_id ) ); 471 } 472 473 /** 474 * Set WooCommerce stock to the batch total (expired units included — they are real stock the 475 * owner still holds). Only for batch-driven products: pass $force when the caller knows the 476 * product had batches, otherwise a product that has never had one keeps its stock untouched. 477 */ 478 public static function sync_stock( $product_id, $force = false ) { 479 $product_id = (int) $product_id; 480 if ( ! WCDE_Expiry_Type::get( $product_id ) ) { return; } 481 if ( ! $force && ! self::has_batch_history( $product_id ) ) { return; } 482 $product = wc_get_product( $product_id ); 483 if ( ! $product || $product->is_type( 'variable' ) ) { return; } 484 if ( ! $product->get_manage_stock() ) { 485 $product->set_manage_stock( true ); 486 $product->save(); 487 } 488 $sum = self::total_qty( $product_id ); 489 $cur = get_post_meta( $product_id, '_stock', true ); 490 if ( '' === $cur || null === $cur || (float) $cur !== (float) $sum ) { 491 wc_update_product_stock( $product, $sum, 'set' ); 492 } 448 493 } 449 494 … … 500 545 if ( ! $wants_tracking ) { 501 546 self::set_batches( $product_id, array() ); 547 do_action( 'wcde_batches_changed', $product_id ); 502 548 return true; 503 549 } 504 550 551 $had_rows = (bool) self::get_batches( $product_id ); 505 552 self::set_batches( $product_id, $batches ); 506 553 507 // Tracked: batches are the source of truth for stock. Sync WooCommerce _stock to the batch 508 // total (total physical), overwriting any manual stock edit; force manage-stock on. 509 if ( ! empty( $batches ) ) { 510 $product = wc_get_product( $product_id ); 511 if ( $product ) { 512 if ( ! $product->get_manage_stock() ) { 513 $product->set_manage_stock( true ); 514 $product->save(); 515 } 516 wc_update_product_stock( $product, self::total_qty( $product_id ), 'set' ); 517 } 518 } 554 // Tracked: batches are the source of truth for stock, including an emptied list (stock 0). 555 self::sync_stock( $product_id, $had_rows || ! empty( $batches ) ); 556 do_action( 'wcde_batches_changed', $product_id ); 519 557 return true; 520 558 } 521 559 522 560 /** 523 * Every product ID this store considers "tracked" — has batch rows, or has the expiry-type meta524 * set, or both. Shared by WCDE_Backup::snapshot() and CSV export so this union query exists in525 * exactly one place.561 * Every product ID this store considers "tracked" — has batch rows, the expiry-type meta, or 562 * batch sales/waste history. Shared by the nightly jobs, reporting, backups and CSV export so 563 * this union exists in exactly one place. 526 564 */ 527 565 public static function tracked_product_ids() { … … 533 571 ) ); 534 572 $ids = array_unique( array_map( 'intval', array_merge( (array) $ids1, (array) $ids2 ) ) ); 573 574 // Sold-out batch rows are deleted, so a product whose batches have all gone (a variation has 575 // no type meta of its own) is still tracked if it has sales or waste history. 576 $hist = array_merge( 577 (array) $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT product_id FROM %i WHERE expiry_date IS NOT NULL', self::consumption_table() ) ), 578 (array) $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT product_id FROM %i', self::waste_log_table() ) ) 579 ); 580 $hist = array_diff( array_unique( array_map( 'intval', $hist ) ), $ids ); 581 if ( $hist ) { 582 _prime_post_caches( $hist, false, true ); 583 foreach ( $hist as $pid ) { 584 if ( $pid && WCDE_Expiry_Type::get( $pid ) ) { $ids[] = $pid; } 585 } 586 } 535 587 sort( $ids ); 536 588 return $ids; -
tarkva-expiry-tracking-and-discount-engine/trunk/wcde-sales-log.php
r3630783 r3717978 29 29 public function tag_line_item( $item, $cart_item_key, $values, $order ) { 30 30 if ( ! empty( $values['wcde_full'] ) ) { return; } // full-price split line (Pro cart-split): not discounted 31 $pid = $values['product_id'] ?? 0;31 $pid = (int) ( $values['product_id'] ?? 0 ); 32 32 if ( ! $pid ) { return; } 33 $vid = (int) ( $values['variation_id'] ?? 0 ); 33 34 34 $product = wc_get_product( $ pid );35 $product = wc_get_product( $vid ?: $pid ); 35 36 36 37 // Per-unit regular ("list") price at time of purchase, tagged for EVERY line regardless of … … 45 46 } 46 47 48 // Discounts are priced per variation when a variation carries its own batches. 49 if ( $vid && 'yes' === get_post_meta( $vid, '_wcde_managed', true ) ) { $pid = $vid; } 47 50 if ( 'yes' !== get_post_meta( $pid, '_wcde_managed', true ) ) { return; } 48 51 -
tarkva-expiry-tracking-and-discount-engine/trunk/wcde-stock-filter.php
r3630783 r3717978 122 122 * ============================================================ */ 123 123 124 /** Units a shopper can actually buy: never more than the sellable batches hold. */ 125 private static function sellable( $pid, $stock ) { 126 $type = self::expiry_type( $pid ); 127 $batch_sellable = $type ? (float) WCDE_Batch_Source::sellable_amount( (int) $pid, self::grace_for( $type ) ) : 0; 128 return max( 0, min( (float) $stock - self::overdue( $pid ), $batch_sellable ) ); 129 } 130 124 131 public function filter_quantity( $value, $product ) { 125 132 if ( ! self::active() || ! self::customer_context() ) { return $value; } 126 133 if ( '' === $value || null === $value ) { return $value; } // not managing stock 134 if ( $product->is_type( 'variable' ) ) { return $value; } 127 135 $pid = $product->get_id(); 128 136 if ( ! self::expiry_type( $pid ) ) { return $value; } 129 137 130 $overdue = self::overdue( $pid ); 131 if ( $overdue <= 0 ) { return $value; } 132 return max( 0, (float) $value - $overdue ); 138 $sellable = self::sellable( $pid, $value ); 139 return ( (float) $value === $sellable ) ? $value : $sellable; 133 140 } 134 141 … … 136 143 if ( ! self::active() || ! self::availability_context() ) { return $status; } 137 144 if ( 'instock' !== $status ) { return $status; } // only ever downgrade to out-of-stock 145 if ( $product->is_type( 'variable' ) ) { return $status; } 138 146 $pid = $product->get_id(); 139 147 if ( ! self::expiry_type( $pid ) ) { return $status; } … … 141 149 $raw = self::raw_stock( $pid ); 142 150 if ( null === $raw ) { return $status; } // not managing stock -> leave alone 143 $sellable = $raw - self::overdue( $pid ); 144 return ( $sellable <= 0 ) ? 'outofstock' : $status; 151 return ( self::sellable( $pid, $raw ) <= 0 ) ? 'outofstock' : $status; 145 152 } 146 153 -
tarkva-expiry-tracking-and-discount-engine/trunk/wcde-stock-status.php
r3630783 r3717978 20 20 add_action( 'woocommerce_updated_product_stock', array( $this, 'refresh_id' ), 30 ); 21 21 add_action( 'woocommerce_product_set_stock', array( $this, 'refresh_obj' ), 30 ); 22 add_action( 'wcde_batches_changed', array( $this, 'on_batches_changed' ), 10 ); 22 23 23 24 if ( defined( 'WP_CLI' ) && WP_CLI ) { … … 38 39 } 39 40 40 /** Desired status for a product, or null if it shouldn't be touched. */ 41 /** 42 * Desired status for a product, or null if it shouldn't be touched. A tracked product is only 43 * in stock while it has a sellable batch — a leftover _stock number alone never makes it 44 * purchasable. Variable parents are left to WooCommerce, which derives them from variations. 45 */ 41 46 private static function desired_status( $pid ) { 42 47 $type = self::expiry_type( $pid ); … … 44 49 $raw = get_post_meta( $pid, '_stock', true ); 45 50 if ( '' === $raw || null === $raw ) { return null; } // not managing stock 46 $sellable = (float) $raw - (int) WCDE_Batch_Source::overdue_amount( $pid, self::grace_for( $type ) ); 51 if ( 'variable' === WC_Product_Factory::get_product_type( $pid ) ) { return null; } 52 $grace = self::grace_for( $type ); 53 $sellable = min( 54 (float) $raw - (int) WCDE_Batch_Source::overdue_amount( $pid, $grace ), 55 (float) WCDE_Batch_Source::sellable_amount( $pid, $grace ) 56 ); 47 57 return ( $sellable <= 0 ) ? 'outofstock' : 'instock'; 48 58 } 49 59 50 /** Enforce _stock = batch sum for trackedproducts (heals ATUM/manual drift). */60 /** Enforce _stock = batch total for batch-driven products (heals ATUM/manual drift). */ 51 61 public static function resync_stock( $pid ) { 52 62 if ( ! self::active() ) { return; } 53 $pid = (int) $pid; 54 if ( ! self::expiry_type( $pid ) ) { return; } 55 $batches = WCDE_Batch_Store::get_batches( $pid ); 56 if ( empty( $batches ) ) { return; } // no batches -> don't touch stock 57 $product = wc_get_product( $pid ); 58 if ( ! $product || ! $product->get_manage_stock() ) { return; } 59 $sum = (int) WCDE_Batch_Store::total_qty( $pid ); 60 $cur = get_post_meta( $pid, '_stock', true ); 61 if ( '' === $cur || (int) $cur !== $sum ) { 62 wc_update_product_stock( $product, $sum, 'set' ); 63 } 63 WCDE_Batch_Store::sync_stock( (int) $pid ); 64 } 65 66 public function on_batches_changed( $pid ) { 67 self::resync_stock( $pid ); 68 self::refresh( $pid ); 64 69 } 65 70 … … 87 92 // regardless of WooCommerce's current active state. 88 93 if ( ! self::active() || ! class_exists( 'WooCommerce' ) ) { return; } 89 global $wpdb;90 $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT product_id FROM %i', WCDE_Batch_Store::batches_table() ));94 WCDE_Batch_Store::purge_empty_batches(); 95 $ids = WCDE_Batch_Store::tracked_product_ids(); 91 96 if ( $ids ) { 92 97 _prime_post_caches( array_map( 'intval', $ids ), false, true ); … … 104 109 */ 105 110 public function cli_sweep( $args, $assoc ) { 106 global $wpdb; 107 $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT product_id FROM %i', WCDE_Batch_Store::batches_table() ) ); 111 $ids = WCDE_Batch_Store::tracked_product_ids(); 108 112 if ( $ids ) { 109 113 _prime_post_caches( array_map( 'intval', $ids ), false, true );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)