*/ private static array $pending = array(); /** * While true, lines the cart changes are not re-baselined (a restore * carries the earlier Session's baselines over itself). * * @var bool */ private static bool $paused = false; /** * The stored cart as it was before WooCommerce loaded it. * * @var array|null */ private static ?array $raw = null; /** * Hook registration. */ public static function init(): void { add_action( 'woocommerce_add_to_cart', array( __CLASS__, 'mark' ), 10, 1 ); add_action( 'woocommerce_after_cart_item_quantity_update', array( __CLASS__, 'mark' ), 10, 1 ); // Before WC_Cart_Session::set_session (1000) stores the cart. add_action( 'woocommerce_after_calculate_totals', array( __CLASS__, 'baseline' ), 999 ); add_action( 'woocommerce_load_cart_from_session', array( __CLASS__, 'before_load' ) ); add_action( 'woocommerce_cart_loaded_from_session', array( __CLASS__, 'after_load' ) ); } /** * Whether the cart in play is an agent Session's. */ private static function agent_cart(): bool { return function_exists( 'WC' ) && WC()->session instanceof AgentAdapt_WC_Session; } /** * The agent set a line (added it, or changed its quantity): record its * price after totals. * * @param string $key Cart line key. */ public static function mark( $key ): void { if ( ! self::$paused && self::agent_cart() && is_string( $key ) && '' !== $key ) { self::$pending[ $key ] = true; } } /** * Record a line's price after totals even while paused (a restored line * whose earlier Session recorded none). * * @param string $key Cart line key. */ public static function mark_now( string $key ): void { self::$pending[ $key ] = true; } /** * Pause or resume re-baselining. * * @param bool $paused Paused. */ public static function pause( bool $paused ): void { self::$paused = $paused; } /** * After totals: record the price of every line the agent just set. * * @param WC_Cart $cart Cart. */ public static function baseline( $cart ): void { if ( ! self::$pending || ! $cart instanceof WC_Cart || ! self::agent_cart() ) { return; } foreach ( array_keys( self::$pending ) as $key ) { $product = $cart->cart_contents[ $key ]['data'] ?? null; if ( $product instanceof WC_Product ) { $cart->cart_contents[ $key ][ self::SEEN ] = self::unit_price( $product ); } } self::$pending = array(); } /** * The unit price an agent is shown for a product, as money. * * @param WC_Product $product Product (the line's own object). * @return array{amount: string, currency: string}|null */ public static function unit_price( WC_Product $product ): ?array { return AgentAdapt_Money::from_decimal( wc_get_price_to_display( $product ), get_woocommerce_currency() ); } /** * A recorded baseline, or null when the line has none. * * @param array $line Cart line. * @return array{amount: string, currency: string}|null */ public static function seen( array $line ): ?array { $seen = $line[ self::SEEN ] ?? null; if ( ! is_array( $seen ) || ! is_string( $seen['amount'] ?? null ) || ! is_string( $seen['currency'] ?? null ) ) { return null; } return array( 'amount' => $seen['amount'], 'currency' => $seen['currency'] ); } /** * The notices for one cart line. * * @param array $line Cart line. * @param WC_Product $product Its product. * @return array> */ public static function for_line( array $line, WC_Product $product ): array { $out = array(); $seen = self::seen( $line ); $now = self::unit_price( $product ); if ( null !== $seen && null !== $now && ( $seen['amount'] !== $now['amount'] || $seen['currency'] !== $now['currency'] ) ) { $out[] = array( 'type' => 'price_changed', 'from' => $seen, 'to' => $now ); } if ( ! $product->is_in_stock() || ! $product->is_purchasable() ) { $out[] = array( 'type' => 'out_of_stock' ); } else { $left = self::low_stock( $product ); if ( null !== $left ) { $out[] = array( 'type' => 'low_stock', 'remaining' => $left ); } } return $out; } /** * Units left when stock is low and the store shows stock amounts, else * null. The same rule as WooCommerce's Store API low_stock_remaining, * which the catalogue reports: never when the store's stock display is * "never show quantity", only for products with a stock quantity, and * only at or below the product's low-stock threshold. Zero or less is * out of stock (or on backorder), not low. * * @param WC_Product $product Product. */ public static function low_stock( WC_Product $product ): ?int { if ( 'no_amount' === get_option( 'woocommerce_stock_format' ) ) { return null; } $qty = $product->get_stock_quantity(); if ( null === $qty ) { return null; } $qty = (int) $qty; return ( $qty >= 1 && $qty <= wc_get_low_stock_amount( $product ) ) ? $qty : null; } /** * Before WooCommerce loads an agent cart: keep the stored lines. */ public static function before_load(): void { self::$raw = self::agent_cart() ? (array) WC()->session->get( 'cart', array() ) : null; } /** * After it loaded: whatever it left out was removed by the store. Keep a * note of each, so the agent's next cart answer says so. * * @param WC_Cart $cart Cart. */ public static function after_load( $cart ): void { $raw = self::$raw; self::$raw = null; if ( ! $raw || ! $cart instanceof WC_Cart || ! self::agent_cart() ) { return; } $kept = $cart->get_cart_contents(); $removed = (array) WC()->session->get( self::REMOVED, array() ); $changed = false; foreach ( $raw as $key => $line ) { if ( ! is_array( $line ) || isset( $kept[ $key ] ) || 'customize-preview' === $key || (int) ( $line['quantity'] ?? 0 ) < 1 ) { continue; } $pid = (int) ( $line['product_id'] ?? 0 ); $vid = (int) ( $line['variation_id'] ?? 0 ); $product = wc_get_product( $vid ? $vid : $pid ); // A note, not the line: the line is gone from WooCommerce's cart. $removed[ (string) $key ] = array( 'product_id' => $pid, 'variation_id' => $vid, 'name' => $product ? AgentAdapt_Catalog::text( $product->get_name() ) : '', 'quantity' => (int) $line['quantity'], ); $changed = true; } if ( $changed ) { WC()->session->set( self::REMOVED, array_slice( $removed, -self::MAX_REMOVED, null, true ) ); } } /** * The lines the store removed since the agent last changed the cart. * * @return array> */ public static function removed(): array { $out = array(); foreach ( (array) WC()->session->get( self::REMOVED, array() ) as $line ) { if ( is_array( $line ) ) { $out[] = array( 'product_id' => (int) ( $line['product_id'] ?? 0 ), 'variation_id' => (int) ( $line['variation_id'] ?? 0 ), 'name' => (string) ( $line['name'] ?? '' ), 'quantity' => (int) ( $line['quantity'] ?? 0 ), 'notices' => array( array( 'type' => 'out_of_stock' ) ), ); } } return $out; } /** * The agent changed the cart: it has been told about removed lines. */ public static function clear_removed(): void { WC()->session->set( self::REMOVED, null ); } /** * Record each distinct notice in a cart answer once per Session * (`cart.notice`: object = the product or variation, outcome = the type, * meta = the line and the facts). Agents poll carts, so a notice shown * again is not recorded again; a different price or a different count is. * * @param AgentAdapt_Context $context Who was shown it. * @param array $cart The cart answer (AgentAdapt_Cart::shape()). */ public static function record_shown( AgentAdapt_Context $context, array $cart ): void { $shown = array(); foreach ( (array) ( $cart['items'] ?? array() ) as $item ) { foreach ( (array) ( $item['notices'] ?? array() ) as $n ) { $meta = array( 'key' => (string) $item['key'] ); if ( 'price_changed' === $n['type'] ) { $meta += array( 'from' => $n['from']['amount'], 'to' => $n['to']['amount'], 'currency' => $n['to']['currency'] ); } elseif ( 'low_stock' === $n['type'] ) { $meta['remaining'] = (int) $n['remaining']; } $shown[] = array( (int) ( $item['variation_id'] ? $item['variation_id'] : $item['product_id'] ), (string) $n['type'], $meta ); } } foreach ( (array) ( $cart['removed_by_store'] ?? array() ) as $line ) { $shown[] = array( (int) ( $line['variation_id'] ? $line['variation_id'] : $line['product_id'] ), 'out_of_stock', array( 'removed' => true ) ); } if ( ! $shown || AgentAdapt_Events::retention_days() <= 0 ) { return; } global $wpdb; $seen = array(); foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT object_id, outcome, meta FROM %i WHERE session_id = %s AND event = 'cart.notice'", AgentAdapt_Schema::table( 'events' ), $context->session_id ), ARRAY_A ) as $r ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $seen[ $r['object_id'] . '|' . $r['outcome'] . '|' . $r['meta'] ] = true; } foreach ( $shown as [ $object, $type, $meta ] ) { $fingerprint = $object . '|' . $type . '|' . wp_json_encode( $meta ); if ( isset( $seen[ $fingerprint ] ) ) { continue; } $seen[ $fingerprint ] = true; AgentAdapt_Events::record( 'cart.notice', $context, array( 'object_id' => $object, 'outcome' => $type, 'meta' => $meta ) ); } } /** * JSON Schema of a line's notices. * * @return array */ public static function schema(): array { $m = AgentAdapt_Money::schema(); return array( 'type' => 'array', 'description' => 'What the store changed under this line: price_changed (the unit price now differs from the price when the agent last set the line, by adding it, changing its quantity or restoring it; setting the same quantity again accepts the new price), out_of_stock (out of stock or no longer for sale), low_stock (stock at or below the store\'s low-stock threshold; only when the store shows stock amounts). Empty when nothing changed. The store never changes the cart because of these: totals are WooCommerce\'s own. Ignore types you don\'t know.', 'items' => array( 'type' => 'object', 'required' => array( 'type' ), 'properties' => array( 'type' => array( 'type' => 'string', 'enum' => array( 'price_changed', 'out_of_stock', 'low_stock' ) ), 'from' => $m + array( 'description' => 'price_changed: the unit price when the agent last set the line.' ), 'to' => $m + array( 'description' => 'price_changed: the unit price now.' ), 'remaining' => array( 'type' => 'integer', 'description' => 'low_stock: units left in stock.' ), ), ), ); } }