Plugin Directory

Changeset 3717978


Ignore:
Timestamp:
09/28/2026 10:29:36 PM (4 weeks ago)
Author:
tarkva
Message:

Release 1.9.1 - stock and discounts follow the batches after a sell-out, removal or refund; sold-out batch rows removed; refunds restore the original batch; variation discounts tagged for reporting

Location:
tarkva-expiry-tracking-and-discount-engine
Files:
13 edited
6 copied

Legend:

Unmodified
Added
Removed
  • tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/readme.txt

    r3685476 r3717978  
    55Tested up to: 7.0
    66Requires PHP: 8.0
    7 Stable tag: 1.9.0
     7Stable tag: 1.9.1
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    111111
    112112== 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.
    113124
    114125= 1.9.0 =
    … …  
    276287== Upgrade Notice ==
    277288
     289= 1.9.1 =
     290Fixes stock and discounts after a batch sells out, is removed or is refunded. Recommended for all users.
     291
    278292= 1.9.0 =
    279293Fixes 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  
    33 * Plugin Name: Tarkva - Expiry Tracking and Discount Engine
    44 * 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.0
     5 * Version: 1.9.1
    66 * Author: Tarkva
    77 * Author URI: https://tarkva.com
    … …  
    1919// tarkva-expiry-tracking-and-discount-engine-pro.php). Keep this in sync with the Version: header above —
    2020// they're two separate strings WordPress has no way to keep matched for you.
    21 define( 'WCDE_VERSION', '1.9.0' );
     21define( 'WCDE_VERSION', '1.9.1' );
    2222
    2323/**
  • tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-batch-orders.php

    r3630783 r3717978  
    9393
    9494        /**
    95          * Which id carries this line item's batches (variation if it has its own, else product).
    9695         * 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.
    9798         */
    9899        private function batch_product_id_for_item( $item ) {
    99100                $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; }
    103107                return 0;
    104108        }
    … …  
    154158                        $engine = $this->engine();
    155159                        if ( ! $engine ) { return; }
    156                         $notes = array();
     160                        $notes   = array();
     161                        $touched = array();
    157162
    158163                        foreach ( $order->get_items() as $item_id => $item ) {
    … …  
    168173                                $plan = $this->plan_consumption( $pid, $qty );
    169174                                if ( ! $plan ) { continue; }
     175                                $touched[ $pid ] = true;
    170176
    171177                                foreach ( $plan as $p ) {
    … …  
    185191                        $order->update_meta_data( self::REDUCED_FLAG, 'yes' );
    186192                        $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                        }
    187198                } catch ( \Throwable $e ) {
    188199                        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
    … …  
    218229         * WC restocked ($new-$old) units of $product from a refund. Put them back into the batches
    219230         * 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.
    221234         */
    222235        public function on_restock_refunded_item( $product_id, $old_stock, $new_stock, $order, $product ) {
    … …  
    229242
    230243                        // 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; }
    235248
    236249                        // Ledger rows for this product on this order that still have restock headroom.
    … …  
    268281                                $remaining -= $take;
    269282                        }
    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 );
    272285                } catch ( \Throwable $e ) {
    273286                        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
  • tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-batch-source.php

    r3630783 r3717978  
    1111
    1212        /**
    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).
    1416         */
    1517        public static function native_product_ids( $cat, $limit ) {
    16                 global $wpdb;
    1718                $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        }
    1928
    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']; }
    3034                }
    31                 return $wpdb->get_col( $wpdb->prepare(
    32                         'SELECT DISTINCT product_id FROM %i LIMIT %d',
    33                         $bt, $limit
    34                 ) );
     35                return $n;
    3536        }
    3637
  • tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-batch-store.php

    r3648865 r3717978  
    444444                        )
    445445                );
    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                }
    447450                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                }
    448493        }
    449494
    … …  
    500545                if ( ! $wants_tracking ) {
    501546                        self::set_batches( $product_id, array() );
     547                        do_action( 'wcde_batches_changed', $product_id );
    502548                        return true;
    503549                }
    504550
     551                $had_rows = (bool) self::get_batches( $product_id );
    505552                self::set_batches( $product_id, $batches );
    506553
    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 );
    519557                return true;
    520558        }
    521559
    522560        /**
    523          * Every product ID this store considers "tracked" — has batch rows, or has the expiry-type meta
    524          * set, or both. Shared by WCDE_Backup::snapshot() and CSV export so this union query exists in
    525          * 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.
    526564         */
    527565        public static function tracked_product_ids() {
    … …  
    533571                ) );
    534572                $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                }
    535587                sort( $ids );
    536588                return $ids;
  • tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-sales-log.php

    r3630783 r3717978  
    2929        public function tag_line_item( $item, $cart_item_key, $values, $order ) {
    3030                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 );
    3232                if ( ! $pid ) { return; }
     33                $vid = (int) ( $values['variation_id'] ?? 0 );
    3334
    34                 $product = wc_get_product( $pid );
     35                $product = wc_get_product( $vid ?: $pid );
    3536
    3637                // Per-unit regular ("list") price at time of purchase, tagged for EVERY line regardless of
    … …  
    4546                }
    4647
     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; }
    4750                if ( 'yes' !== get_post_meta( $pid, '_wcde_managed', true ) ) { return; }
    4851
  • tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-stock-filter.php

    r3630783 r3717978  
    122122         * ============================================================ */
    123123
     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
    124131        public function filter_quantity( $value, $product ) {
    125132                if ( ! self::active() || ! self::customer_context() ) { return $value; }
    126133                if ( '' === $value || null === $value ) { return $value; } // not managing stock
     134                if ( $product->is_type( 'variable' ) ) { return $value; }
    127135                $pid = $product->get_id();
    128136                if ( ! self::expiry_type( $pid ) ) { return $value; }
    129137
    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;
    133140        }
    134141
    … …  
    136143                if ( ! self::active() || ! self::availability_context() ) { return $status; }
    137144                if ( 'instock' !== $status ) { return $status; } // only ever downgrade to out-of-stock
     145                if ( $product->is_type( 'variable' ) ) { return $status; }
    138146                $pid = $product->get_id();
    139147                if ( ! self::expiry_type( $pid ) ) { return $status; }
    … …  
    141149                $raw = self::raw_stock( $pid );
    142150                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;
    145152        }
    146153
  • tarkva-expiry-tracking-and-discount-engine/tags/1.9.1/wcde-stock-status.php

    r3630783 r3717978  
    2020                add_action( 'woocommerce_updated_product_stock', array( $this, 'refresh_id' ), 30 );
    2121                add_action( 'woocommerce_product_set_stock', array( $this, 'refresh_obj' ), 30 );
     22                add_action( 'wcde_batches_changed', array( $this, 'on_batches_changed' ), 10 );
    2223
    2324                if ( defined( 'WP_CLI' ) && WP_CLI ) {
    … …  
    3839        }
    3940
    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         */
    4146        private static function desired_status( $pid ) {
    4247                $type = self::expiry_type( $pid );
    … …  
    4449                $raw = get_post_meta( $pid, '_stock', true );
    4550                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                );
    4757                return ( $sellable <= 0 ) ? 'outofstock' : 'instock';
    4858        }
    4959
    50         /** Enforce _stock = batch sum for tracked products (heals ATUM/manual drift). */
     60        /** Enforce _stock = batch total for batch-driven products (heals ATUM/manual drift). */
    5161        public static function resync_stock( $pid ) {
    5262                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 );
    6469        }
    6570
    … …  
    8792                // regardless of WooCommerce's current active state.
    8893                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();
    9196                if ( $ids ) {
    9297                        _prime_post_caches( array_map( 'intval', $ids ), false, true );
    … …  
    104109         */
    105110        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();
    108112                if ( $ids ) {
    109113                        _prime_post_caches( array_map( 'intval', $ids ), false, true );
  • tarkva-expiry-tracking-and-discount-engine/trunk/readme.txt

    r3685476 r3717978  
    55Tested up to: 7.0
    66Requires PHP: 8.0
    7 Stable tag: 1.9.0
     7Stable tag: 1.9.1
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    111111
    112112== 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.
    113124
    114125= 1.9.0 =
    … …  
    276287== Upgrade Notice ==
    277288
     289= 1.9.1 =
     290Fixes stock and discounts after a batch sells out, is removed or is refunded. Recommended for all users.
     291
    278292= 1.9.0 =
    279293Fixes 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  
    33 * Plugin Name: Tarkva - Expiry Tracking and Discount Engine
    44 * 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.0
     5 * Version: 1.9.1
    66 * Author: Tarkva
    77 * Author URI: https://tarkva.com
    … …  
    1919// tarkva-expiry-tracking-and-discount-engine-pro.php). Keep this in sync with the Version: header above —
    2020// they're two separate strings WordPress has no way to keep matched for you.
    21 define( 'WCDE_VERSION', '1.9.0' );
     21define( 'WCDE_VERSION', '1.9.1' );
    2222
    2323/**
  • tarkva-expiry-tracking-and-discount-engine/trunk/wcde-batch-orders.php

    r3630783 r3717978  
    9393
    9494        /**
    95          * Which id carries this line item's batches (variation if it has its own, else product).
    9695         * 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.
    9798         */
    9899        private function batch_product_id_for_item( $item ) {
    99100                $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; }
    103107                return 0;
    104108        }
    … …  
    154158                        $engine = $this->engine();
    155159                        if ( ! $engine ) { return; }
    156                         $notes = array();
     160                        $notes   = array();
     161                        $touched = array();
    157162
    158163                        foreach ( $order->get_items() as $item_id => $item ) {
    … …  
    168173                                $plan = $this->plan_consumption( $pid, $qty );
    169174                                if ( ! $plan ) { continue; }
     175                                $touched[ $pid ] = true;
    170176
    171177                                foreach ( $plan as $p ) {
    … …  
    185191                        $order->update_meta_data( self::REDUCED_FLAG, 'yes' );
    186192                        $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                        }
    187198                } catch ( \Throwable $e ) {
    188199                        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
    … …  
    218229         * WC restocked ($new-$old) units of $product from a refund. Put them back into the batches
    219230         * 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.
    221234         */
    222235        public function on_restock_refunded_item( $product_id, $old_stock, $new_stock, $order, $product ) {
    … …  
    229242
    230243                        // 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; }
    235248
    236249                        // Ledger rows for this product on this order that still have restock headroom.
    … …  
    268281                                $remaining -= $take;
    269282                        }
    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 );
    272285                } catch ( \Throwable $e ) {
    273286                        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
  • tarkva-expiry-tracking-and-discount-engine/trunk/wcde-batch-source.php

    r3630783 r3717978  
    1111
    1212        /**
    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).
    1416         */
    1517        public static function native_product_ids( $cat, $limit ) {
    16                 global $wpdb;
    1718                $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        }
    1928
    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']; }
    3034                }
    31                 return $wpdb->get_col( $wpdb->prepare(
    32                         'SELECT DISTINCT product_id FROM %i LIMIT %d',
    33                         $bt, $limit
    34                 ) );
     35                return $n;
    3536        }
    3637
  • tarkva-expiry-tracking-and-discount-engine/trunk/wcde-batch-store.php

    r3648865 r3717978  
    444444                        )
    445445                );
    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                }
    447450                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                }
    448493        }
    449494
    … …  
    500545                if ( ! $wants_tracking ) {
    501546                        self::set_batches( $product_id, array() );
     547                        do_action( 'wcde_batches_changed', $product_id );
    502548                        return true;
    503549                }
    504550
     551                $had_rows = (bool) self::get_batches( $product_id );
    505552                self::set_batches( $product_id, $batches );
    506553
    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 );
    519557                return true;
    520558        }
    521559
    522560        /**
    523          * Every product ID this store considers "tracked" — has batch rows, or has the expiry-type meta
    524          * set, or both. Shared by WCDE_Backup::snapshot() and CSV export so this union query exists in
    525          * 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.
    526564         */
    527565        public static function tracked_product_ids() {
    … …  
    533571                ) );
    534572                $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                }
    535587                sort( $ids );
    536588                return $ids;
  • tarkva-expiry-tracking-and-discount-engine/trunk/wcde-sales-log.php

    r3630783 r3717978  
    2929        public function tag_line_item( $item, $cart_item_key, $values, $order ) {
    3030                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 );
    3232                if ( ! $pid ) { return; }
     33                $vid = (int) ( $values['variation_id'] ?? 0 );
    3334
    34                 $product = wc_get_product( $pid );
     35                $product = wc_get_product( $vid ?: $pid );
    3536
    3637                // Per-unit regular ("list") price at time of purchase, tagged for EVERY line regardless of
    … …  
    4546                }
    4647
     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; }
    4750                if ( 'yes' !== get_post_meta( $pid, '_wcde_managed', true ) ) { return; }
    4851
  • tarkva-expiry-tracking-and-discount-engine/trunk/wcde-stock-filter.php

    r3630783 r3717978  
    122122         * ============================================================ */
    123123
     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
    124131        public function filter_quantity( $value, $product ) {
    125132                if ( ! self::active() || ! self::customer_context() ) { return $value; }
    126133                if ( '' === $value || null === $value ) { return $value; } // not managing stock
     134                if ( $product->is_type( 'variable' ) ) { return $value; }
    127135                $pid = $product->get_id();
    128136                if ( ! self::expiry_type( $pid ) ) { return $value; }
    129137
    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;
    133140        }
    134141
    … …  
    136143                if ( ! self::active() || ! self::availability_context() ) { return $status; }
    137144                if ( 'instock' !== $status ) { return $status; } // only ever downgrade to out-of-stock
     145                if ( $product->is_type( 'variable' ) ) { return $status; }
    138146                $pid = $product->get_id();
    139147                if ( ! self::expiry_type( $pid ) ) { return $status; }
    … …  
    141149                $raw = self::raw_stock( $pid );
    142150                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;
    145152        }
    146153
  • tarkva-expiry-tracking-and-discount-engine/trunk/wcde-stock-status.php

    r3630783 r3717978  
    2020                add_action( 'woocommerce_updated_product_stock', array( $this, 'refresh_id' ), 30 );
    2121                add_action( 'woocommerce_product_set_stock', array( $this, 'refresh_obj' ), 30 );
     22                add_action( 'wcde_batches_changed', array( $this, 'on_batches_changed' ), 10 );
    2223
    2324                if ( defined( 'WP_CLI' ) && WP_CLI ) {
    … …  
    3839        }
    3940
    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         */
    4146        private static function desired_status( $pid ) {
    4247                $type = self::expiry_type( $pid );
    … …  
    4449                $raw = get_post_meta( $pid, '_stock', true );
    4550                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                );
    4757                return ( $sellable <= 0 ) ? 'outofstock' : 'instock';
    4858        }
    4959
    50         /** Enforce _stock = batch sum for tracked products (heals ATUM/manual drift). */
     60        /** Enforce _stock = batch total for batch-driven products (heals ATUM/manual drift). */
    5161        public static function resync_stock( $pid ) {
    5262                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 );
    6469        }
    6570
    … …  
    8792                // regardless of WooCommerce's current active state.
    8893                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();
    9196                if ( $ids ) {
    9297                        _prime_post_caches( array_map( 'intval', $ids ), false, true );
    … …  
    104109         */
    105110        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();
    108112                if ( $ids ) {
    109113                        _prime_post_caches( array_map( 'intval', $ids ), false, true );
Note: See TracChangeset for help on using the changeset viewer.