units ] still on backorder const DONE_META = '_pwsplit_backorders_done'; // handled or dismissed /** @var Module */ private $module; public function __construct( Module $module ) { $this->module = $module; add_action( 'woocommerce_reduce_order_stock', [ $this, 'record' ] ); add_action( 'patchwork_split_completed', [ $this, 'after_split' ], 10, 3 ); add_filter( 'patchwork_split_order_badges', [ $this, 'badge' ], 5, 2 ); add_action( 'admin_notices', [ $this, 'notice' ] ); add_action( 'rest_api_init', [ $this, 'register_routes' ] ); } // ─── Mode ───────────────────────────────────────────────────────────────── public static function mode(): string { $mode = (string) get_option( self::OPT_MODE, 'suggest' ); return isset( self::modes()[ $mode ] ) ? $mode : 'suggest'; } /** Available modes; Pro adds 'auto'. */ public static function modes(): array { return (array) apply_filters( 'patchwork_split_backorder_modes', [ 'off' => __( 'Off', 'patchwork-order-split' ), 'suggest' => __( 'Suggest — flag the order so I can split it myself', 'patchwork-order-split' ), ] ); } // ─── Recording ──────────────────────────────────────────────────────────── /** * Right after WooCommerce reduces stock for an order. * * @param \WC_Order $order */ public function record( $order ): void { if ( ! $order instanceof \WC_Order ) return; if ( $order->get_meta( self::DONE_META ) || $order->get_meta( '_pwsplit_parent_id' ) ) return; $units = self::units_from_stock( $order ); if ( ! $units ) return; $order->update_meta_data( self::QTY_META, $units ); $order->save_meta_data(); do_action( 'patchwork_split_backorders_recorded', $order, $units ); } /** * Backordered units per line, from stock right after this order reduced it: * stock_before = stock_after + qty, so backordered = min( qty, -stock_after ). * (WooCommerce's own "Backordered" label only exists for products set to * "Allow, but notify customer", so stock levels are the reliable source.) * * @return array item_id => units */ public static function units_from_stock( \WC_Order $order ): array { $units = []; foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) { $product = $item->get_product(); if ( ! $product || ! $product->managing_stock() || ! (int) $item->get_meta( '_reduced_stock' ) ) continue; $after = (int) wc_get_product( $product->get_stock_managed_by_id() )->get_stock_quantity(); $back = min( (int) $item->get_quantity(), max( 0, -$after ) ); if ( $back > 0 ) $units[ $item_id ] = $back; } return $units; } /** * Units still on backorder: recorded ones, or WooCommerce's label for orders * placed before Patchwork Split recorded them. Capped at each line's quantity. * * @return array item_id => units */ public static function units( \WC_Order $order ): array { if ( $order->get_meta( self::DONE_META ) || $order->get_meta( '_pwsplit_parent_id' ) ) return []; $recorded = Splitter::meta_list( $order, self::QTY_META ); $label = self::wc_label_key(); $out = []; foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) { $back = isset( $recorded[ $item_id ] ) ? (int) $recorded[ $item_id ] : ( $recorded ? 0 : (int) $item->get_meta( $label ) ); $back = min( $back, (int) $item->get_quantity() ); if ( $back > 0 ) $out[ $item_id ] = $back; } return $out; } /** * Should the order be flagged? Only when there's something to act on: some * units on backorder AND some that can ship now, on an order that can be split. */ public static function suggestion( \WC_Order $order ): array { $units = self::units( $order ); if ( ! $units || Splitter::order_problem( $order ) ) return []; $total = array_sum( array_map( function( $i ) { return (int) $i->get_quantity(); }, $order->get_items( 'line_item' ) ) ); return array_sum( $units ) < $total ? $units : []; } /** Split lines for the backordered units. */ public static function lines( \WC_Order $order ): array { $lines = []; foreach ( self::units( $order ) as $item_id => $qty ) { $lines[] = [ 'item_id' => $item_id, 'action' => 'split', 'qty' => $qty, 'reason' => self::reason() ]; } return $lines; } public static function reason(): string { return __( 'On backorder', 'patchwork-order-split' ); } /** WooCommerce's item meta key for its "Backordered" label (a translated string). */ public static function wc_label_key(): string { // WooCommerce's own filter and string, applied exactly as WooCommerce does, so the key matches on translated stores. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound, WordPress.WP.I18n.TextDomainMismatch return (string) apply_filters( 'woocommerce_backordered_item_meta_name', __( 'Backordered', 'woocommerce' ), null ); } public static function mark_done( \WC_Order $order ): void { $order->delete_meta_data( self::QTY_META ); $order->update_meta_data( self::DONE_META, time() ); $order->save(); } /** * After any split / unable batch: take the handled units off the record, and * drop WooCommerce's "Backordered" label from units that are no longer on * backorder. Clears the suggestion when nothing is left. */ public function after_split( array $result, \WC_Order $order, array $lines ): void { // Once per batch, even if this hook were registered twice static $seen = []; $batch = $order->get_id() . ':' . ( $result['child_id'] ?? 0 ) . ':' . ( $result['unable_id'] ?? 0 ) . ':' . ( $result['refund_id'] ?? 0 ); if ( isset( $seen[ $batch ] ) ) return; $seen[ $batch ] = true; $order = wc_get_order( $order->get_id() ); if ( ! $order || $order->get_meta( self::DONE_META ) ) return; $units = self::units( $order ); if ( ! $units && ! Splitter::meta_list( $order, self::QTY_META ) ) return; // Recorded units refer to the line before the batch: subtract what was handled. $recorded = Splitter::meta_list( $order, self::QTY_META ); foreach ( $lines as $item_id => $line ) { if ( isset( $recorded[ $item_id ] ) ) $recorded[ $item_id ] -= (int) $line['qty']; } $items = $order->get_items( 'line_item' ); foreach ( $recorded as $item_id => $qty ) { $qty = isset( $items[ $item_id ] ) ? min( $qty, (int) $items[ $item_id ]->get_quantity() ) : 0; if ( $qty > 0 ) $recorded[ $item_id ] = $qty; else unset( $recorded[ $item_id ] ); } $label = self::wc_label_key(); foreach ( $items as $item_id => $item ) { if ( ! isset( $recorded[ $item_id ] ) && $item->get_meta( $label ) !== '' ) { $item->delete_meta_data( $label ); $item->save(); } } if ( $recorded ) { $order->update_meta_data( self::QTY_META, $recorded ); $order->save(); } else { self::mark_done( $order ); } } // ─── UI: badge, notice, dismiss ─────────────────────────────────────────── private static function tip(): array { $module = \Patchwork\Core\Core::instance()->module( 'split' ); if ( isset( self::modes()['auto'] ) ) { return [ __( 'Tip: this can be done automatically when an order is paid — Settings → Backorders.', 'patchwork-order-split' ), $module ? $module->admin_url( 'settings' ) : '' ]; } // No upgrade prompts on order screens (WordPress.org Guideline 11) — Pro is // only mentioned on the plugin's own Licence tab. return [ '', '' ]; } public function badge( $badges, \WC_Order $order ) { if ( self::mode() === 'off' ) return $badges; $units = self::suggestion( $order ); if ( ! $units ) return $badges; $badges[] = [ /* translators: %d: number of units */ 'label' => sprintf( _n( '⚠ %d on backorder', '⚠ %d on backorder', array_sum( $units ), 'patchwork-order-split' ), array_sum( $units ) ), 'url' => $order->get_edit_order_url(), 'title' => __( 'Some items are on backorder. Open the order to split them into their own order so the rest can ship now.', 'patchwork-order-split' ) . ( self::tip()[0] ? ' ' . self::tip()[0] : '' ), 'tone' => 'warning', ]; return $badges; } public function notice(): void { if ( self::mode() === 'off' || ! Settings::current_user_can_split() ) return; $order_id = Admin::current_order_id(); $order = $order_id ? wc_get_order( $order_id ) : null; if ( ! $order ) return; $units = self::suggestion( $order ); if ( ! $units ) return; // Automatic mode handles paid orders by itself. if ( self::mode() === 'auto' && Splitter::is_paid( $order ) ) return; $items = $order->get_items( 'line_item' ); $list = implode( ', ', array_map( function( $id, $qty ) use ( $items ) { return sprintf( '%s × %d', $items[ $id ]->get_name(), $qty ); }, array_keys( $units ), $units ) ); $postage = (float) $order->get_shipping_total() + (float) $order->get_shipping_tax(); [ $tip, $tip_url ] = self::tip(); ?>
\d+)/backorders/dismiss', [ 'methods' => 'POST', 'permission_callback' => [ REST_Controller::class, 'check_permission' ], 'callback' => function( \WP_REST_Request $request ) { $order = wc_get_order( absint( $request['id'] ) ); if ( ! $order ) return new \WP_REST_Response( [ 'success' => false, 'message' => __( 'Order not found.', 'patchwork-order-split' ) ], 404 ); self::mark_done( $order ); $order->add_order_note( __( 'Patchwork Split: backorder suggestion dismissed — shipping together.', 'patchwork-order-split' ), 0, true ); return new \WP_REST_Response( [ 'success' => true ], 200 ); }, ] ); } }