get_status(), $blocked, true ) ) { return new \WP_Error( 'pwsplit_status', sprintf( /* translators: %s: order status name */ __( 'Orders with the status "%s" can\'t be split.', 'patchwork-order-split' ), wc_get_order_status_name( $order->get_status() ) ) ); } return null; } /** * An array stored in order meta. (Missing meta comes back as '', and * (array) '' would be [''] — a phantom entry.) */ public static function meta_list( \WC_Data $object, string $key ): array { $value = $object->get_meta( $key ); return is_array( $value ) ? $value : []; } /** * Whether the order counts as paid (money captured), which changes how Unable works. */ public static function is_paid( \WC_Order $order ): bool { return $order->is_paid() || $order->get_date_paid() !== null; } /** * Apply a batch. * * @param \WC_Order $order * @param array $raw_lines Each: [ item_id, action (split|unable), qty, reason, note, extra[] ] * @param array $args [ shipping (keep|move|zero), child_date (Y-m-d), notify_customer (bool), refund_via_gateway (bool) ] * @return array|\WP_Error Result summary, or an error and nothing changed. */ public function split( \WC_Order $order, array $raw_lines, array $args = [] ) { $problem = self::order_problem( $order ); if ( $problem ) return $problem; $lines = $this->normalize_lines( $order, $raw_lines ); if ( is_wp_error( $lines ) ) return $lines; $args = $this->normalize_args( $args ); if ( is_wp_error( $args ) ) return $args; $plan = $this->plan( $order, $lines, $args ); if ( is_wp_error( $plan ) ) return $plan; $check = apply_filters( 'patchwork_split_validate_batch', null, $order, $lines, $args ); if ( is_wp_error( $check ) ) return $check; if ( ! self::lock( $order->get_id() ) ) { return new \WP_Error( 'pwsplit_locked', __( 'This order is already being split. Wait a moment, then reload the page.', 'patchwork-order-split' ) ); } Email_Guard::suspend(); try { $result = $this->run( $order, $lines, $args, $plan ); } catch ( \Exception $e ) { $result = new \WP_Error( 'pwsplit_failed', $e->getMessage() ); } finally { Email_Guard::resume(); self::unlock( $order->get_id() ); } if ( is_wp_error( $result ) ) return $result; $this->notify_customer( $order, $lines, $result, $args ); do_action( 'patchwork_split_completed', $result, $order, $lines, $args ); return $result; } // ─── Validation ─────────────────────────────────────────────────────────── private function normalize_lines( \WC_Order $order, array $raw ) { if ( empty( $raw ) ) { return new \WP_Error( 'pwsplit_no_lines', __( 'Mark at least one item as Split or Unable.', 'patchwork-order-split' ) ); } // Use the order's own item objects (not $order->get_item(), which loads a // fresh copy) so edits are seen when the order recalculates its totals. $order_items = $order->get_items( 'line_item' ); $lines = []; foreach ( $raw as $row ) { if ( ! is_array( $row ) ) { return new \WP_Error( 'pwsplit_bad_line', __( 'Invalid line in request.', 'patchwork-order-split' ) ); } $item_id = absint( $row['item_id'] ?? 0 ); $action = (string) ( $row['action'] ?? '' ); $item = $order_items[ $item_id ] ?? null; if ( ! $item instanceof \WC_Order_Item_Product || (int) $item->get_order_id() !== $order->get_id() ) { return new \WP_Error( 'pwsplit_no_item', sprintf( /* translators: %d: order item ID */ __( 'Item %d is not part of this order. Reload the page and try again.', 'patchwork-order-split' ), $item_id ) ); } if ( ! in_array( $action, [ 'split', 'unable' ], true ) ) { return new \WP_Error( 'pwsplit_bad_action', __( 'Invalid action.', 'patchwork-order-split' ) ); } if ( isset( $lines[ $item_id ] ) ) { return new \WP_Error( 'pwsplit_duplicate', __( 'The same item appears twice in the request.', 'patchwork-order-split' ) ); } if ( $order->get_qty_refunded_for_item( $item_id ) != 0 || $order->get_total_refunded_for_item( $item_id ) > 0 ) { return new \WP_Error( 'pwsplit_item_refunded', sprintf( /* translators: %s: product name */ __( '"%s" has already been (partly) refunded, so it can\'t be split or marked unable.', 'patchwork-order-split' ), $item->get_name() ) ); } $full = (int) $item->get_quantity(); $qty = isset( $row['qty'] ) ? (int) $row['qty'] : $full; if ( $qty < 1 || $qty > $full ) { return new \WP_Error( 'pwsplit_bad_qty', sprintf( /* translators: 1: product name, 2: max quantity */ __( 'Quantity for "%1$s" must be between 1 and %2$d.', 'patchwork-order-split' ), $item->get_name(), $full ) ); } $extra = []; if ( ! empty( $row['extra'] ) && is_array( $row['extra'] ) ) { foreach ( $row['extra'] as $key => $value ) { if ( is_scalar( $value ) ) $extra[ sanitize_key( $key ) ] = sanitize_text_field( (string) $value ); } } $lines[ $item_id ] = [ 'item_id' => $item_id, 'item' => $item, 'action' => $action, 'qty' => $qty, 'full_qty' => $full, 'reason' => sanitize_text_field( (string) ( $row['reason'] ?? '' ) ), 'note' => sanitize_textarea_field( (string) ( $row['note'] ?? '' ) ), 'extra' => $extra, ]; } return $lines; } private function normalize_args( array $args ) { $shipping = (string) ( $args['shipping'] ?? Settings::get_shipping_mode() ); if ( ! in_array( $shipping, [ 'keep', 'move', 'zero' ], true ) ) { return new \WP_Error( 'pwsplit_bad_shipping', __( 'Invalid shipping option.', 'patchwork-order-split' ) ); } $date = null; if ( ! empty( $args['child_date'] ) ) { $raw = (string) $args['child_date']; if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw ) ) { return new \WP_Error( 'pwsplit_bad_date', __( 'Invalid order date.', 'patchwork-order-split' ) ); } try { $date = new \WC_DateTime( $raw . ' ' . current_time( 'H:i:s' ), new \DateTimeZone( wc_timezone_string() ) ); } catch ( \Exception $e ) { return new \WP_Error( 'pwsplit_bad_date', __( 'Invalid order date.', 'patchwork-order-split' ) ); } } return [ 'shipping' => $shipping, 'child_status' => isset( $args['child_status'] ) ? preg_replace( '/^wc-/', '', sanitize_key( (string) $args['child_status'] ) ) : '', // honoured only via the patchwork_split_child_status filter 'child_date' => $date, 'notify_customer' => isset( $args['notify_customer'] ) ? (bool) $args['notify_customer'] : null, // null = use settings 'refund_via_gateway' => ! empty( $args['refund_via_gateway'] ), ]; } /** * Work out everything that will happen, before anything is changed. */ private function plan( \WC_Order $order, array $lines, array $args ) { $paid = self::is_paid( $order ); $has_split = (bool) array_filter( $lines, function( $l ) { return $l['action'] === 'split'; } ); $has_unable = (bool) array_filter( $lines, function( $l ) { return $l['action'] === 'unable'; } ); // Units of each product line still to fulfil after the batch. Refunded // units (including Unable lines on a paid order) don't count. $remaining_units = 0; foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) { $left = (int) $item->get_quantity() + (int) $order->get_qty_refunded_for_item( $item_id ); // refunded qty is negative if ( isset( $lines[ $item_id ] ) ) $left -= $lines[ $item_id ]['qty']; $remaining_units += max( 0, $left ); } $parent_emptied = $remaining_units === 0; // Shipping: where does it go? // - Parent emptied by a split: shipping follows the goods into the child. // - Parent emptied by Unable only: drop it if unpaid; a paid order keeps what // was paid for (refund shipping by hand if needed). // - Otherwise follow the chosen mode. if ( $parent_emptied && $has_split ) { $ship_to_child = 'copy'; $remove_parent_shipping = true; } elseif ( $parent_emptied ) { $ship_to_child = 'none'; $remove_parent_shipping = ! $paid; } elseif ( ! $has_split ) { $ship_to_child = 'none'; $remove_parent_shipping = false; } else { $ship_to_child = [ 'keep' => 'none', 'move' => 'copy', 'zero' => 'zero' ][ $args['shipping'] ]; $remove_parent_shipping = $args['shipping'] === 'move'; } if ( $remove_parent_shipping ) { foreach ( $order->get_items( 'shipping' ) as $ship_id => $ship ) { if ( $order->get_total_refunded_for_item( $ship_id, 'shipping' ) > 0 ) { return new \WP_Error( 'pwsplit_shipping_refunded', __( 'Shipping on this order has been refunded, so it can\'t be moved. Choose "Keep shipping on the original order".', 'patchwork-order-split' ) ); } } } // On a paid order, a fully-refunded parent stays where WooCommerce puts it. // Otherwise an empty parent follows the setting. An authorised-but-not-captured // payment would be voided by cancelling, so that case goes on hold instead. $parent_status = null; if ( $parent_emptied ) { $parent_status = Settings::get_parent_when_empty(); // cancel | on-hold | leave $authorised_only = ! $paid && $order->get_transaction_id() !== ''; if ( $parent_status === 'cancel' && $authorised_only ) $parent_status = 'on-hold'; if ( $parent_status === 'cancel' ) $parent_status = 'cancelled'; if ( $parent_status === 'leave' ) $parent_status = null; } return [ 'paid' => $paid, 'stock_reduced' => (bool) $order->get_data_store()->get_stock_reduced( $order->get_id() ), 'has_split' => $has_split, 'has_unable' => $has_unable, 'parent_emptied' => $parent_emptied, 'ship_to_child' => $ship_to_child, 'remove_parent_shipping' => $remove_parent_shipping, 'parent_status' => $parent_status, 'child_status' => (string) apply_filters( 'patchwork_split_child_status', $order->get_status(), $order, $lines, $args ), ]; } // ─── Execution ──────────────────────────────────────────────────────────── private function run( \WC_Order $order, array $lines, array $args, array $plan ): array { $split_lines = array_filter( $lines, function( $l ) { return $l['action'] === 'split'; } ); $unable_lines = array_filter( $lines, function( $l ) { return $l['action'] === 'unable'; } ); $refund_unable = $plan['paid'] && $unable_lines; $restock = Settings::restock_unable(); // 1. Create the new orders first. If anything fails here, roll them back and // leave the parent untouched. $created = []; try { $child = $split_lines ? $this->create_child( $order, $split_lines, 'split', $args, $plan ) : null; if ( $child ) $created[] = $child; $unable_record = ( $unable_lines && ! $refund_unable ) ? $this->create_child( $order, $unable_lines, 'unable', $args, $plan ) : null; if ( $unable_record ) $created[] = $unable_record; } catch ( \Exception $e ) { foreach ( $created as $created_order ) $created_order->delete( true ); throw $e; } // 2. Paid order: refund the Unable lines (WooCommerce restocks them). $refund = null; if ( $refund_unable ) { $refund = $this->refund_unable( $order, $unable_lines, $args, $restock ); if ( is_wp_error( $refund ) ) { foreach ( $created as $created_order ) $created_order->delete( true ); throw new \Exception( esc_html( $refund->get_error_message() ) ); } $order = wc_get_order( $order->get_id() ); // refunds change the order; reload it $order_items = $order->get_items( 'line_item' ); foreach ( $lines as $id => $line ) $lines[ $id ]['item'] = $order_items[ $id ]; $split_lines = array_intersect_key( $lines, $split_lines ); } // 3. Take the moved / removed quantities off the parent. $stock_notes = []; $remove_from_parent = $refund_unable ? $split_lines : $lines; foreach ( $remove_from_parent as $line ) { $item = $line['item']; $reduced = (int) $item->get_meta( '_reduced_stock' ); $moved = min( $line['qty'], max( 0, $reduced ) ); // stock units that leave with this line if ( $line['action'] === 'unable' && $restock && $moved > 0 ) { $product = $item->get_product(); if ( $product && $product->managing_stock() ) { $new_stock = wc_update_product_stock( $product, $moved, 'increase' ); $stock_notes[] = sprintf( '%s (%s) %d→%d', $item->get_name(), $product->get_sku() ?: '#' . $product->get_id(), $new_stock - $moved, $new_stock ); } } if ( $line['qty'] >= $line['full_qty'] ) { $order->remove_item( $line['item_id'] ); continue; } $part = $this->portion( $item, $line['qty'] ); $taxes = $item->get_taxes(); foreach ( [ 'total', 'subtotal' ] as $key ) { foreach ( (array) ( $taxes[ $key ] ?? [] ) as $rate_id => $amount ) { $taxes[ $key ][ $rate_id ] = (float) $amount - (float) ( $part['taxes'][ $key ][ $rate_id ] ?? 0 ); } } $item->set_quantity( $line['full_qty'] - $line['qty'] ); $item->set_subtotal( (float) $item->get_subtotal() - $part['subtotal'] ); $item->set_total( (float) $item->get_total() - $part['total'] ); $item->set_taxes( $taxes ); if ( $reduced > 0 ) { $item->update_meta_data( '_reduced_stock', $reduced - $moved ); } $item->save(); } if ( $plan['remove_parent_shipping'] ) { foreach ( $order->get_items( 'shipping' ) as $ship_id => $ship ) { $order->remove_item( $ship_id ); } } $order->update_taxes(); $order->calculate_totals( false ); // 4. Notes, relationships and log on the parent. $children = array_filter( [ $child, $unable_record ] ); $existing = self::meta_list( $order, '_pwsplit_children' ); $order->update_meta_data( '_pwsplit_children', array_values( array_unique( array_merge( $existing, array_map( function( $o ) { return $o->get_id(); }, $children ) ) ) ) ); $log = self::meta_list( $order, '_pwsplit_log' ); $log[] = [ 'time' => time(), 'user' => get_current_user_id(), 'child' => $child ? $child->get_id() : 0, 'unable' => $unable_record ? $unable_record->get_id() : 0, 'refund' => $refund ? $refund->get_id() : 0, 'lines' => array_values( array_map( function( $l ) { return [ 'item' => $l['item_id'], 'name' => $l['item'] ? $l['item']->get_name() : '', 'action' => $l['action'], 'qty' => $l['qty'], 'reason' => $l['reason'] ]; }, $lines ) ), ]; $order->update_meta_data( '_pwsplit_log', $log ); $order->add_order_note( $this->parent_note( $lines, $child, $unable_record, $refund, $stock_notes, $plan ), 0, true ); if ( $plan['parent_emptied'] ) { // Nothing left that still holds stock (moved to the child, restocked, or // deliberately not restocked) — stop WooCommerce restocking on cancel. self::set_stock_reduced( $order, false ); } if ( $plan['parent_status'] ) { $order->set_status( $plan['parent_status'], __( 'Patchwork Split: every item has been split out or marked unable.', 'patchwork-order-split' ) ); } $order->save(); return [ 'parent_id' => $order->get_id(), 'parent_status' => $order->get_status(), 'parent_total' => (float) $order->get_total(), 'parent_emptied' => $plan['parent_emptied'], 'child_id' => $child ? $child->get_id() : 0, 'child_total' => $child ? (float) $child->get_total() : 0.0, 'unable_id' => $unable_record ? $unable_record->get_id() : 0, 'refund_id' => $refund ? $refund->get_id() : 0, 'refund_amount' => $refund ? (float) $refund->get_amount() : 0.0, 'payment_copied' => $child ? (bool) $child->get_meta( '_pwsplit_paid_via_parent' ) : false, ]; } /** * Create the split child order (type "split") or the cancelled record of * unfulfilled items on an unpaid order (type "unable"). */ private function create_child( \WC_Order $order, array $lines, string $type, array $args, array $plan ): \WC_Order { $child = new \WC_Order(); $child->set_customer_id( $order->get_customer_id() ); foreach ( [ 'billing', 'shipping' ] as $address_type ) { foreach ( $order->get_address( $address_type ) as $key => $value ) { $setter = "set_{$address_type}_{$key}"; if ( is_callable( [ $child, $setter ] ) ) $child->{$setter}( $value ); } } $child->set_currency( $order->get_currency() ); $child->set_prices_include_tax( $order->get_prices_include_tax() ); $child->set_created_via( 'patchwork-split' ); $child->set_payment_method( $order->get_payment_method() ); $child->set_payment_method_title( $order->get_payment_method_title() ); if ( $args['child_date'] ) $child->set_date_created( $args['child_date'] ); $carries_stock = $type === 'split' && $plan['stock_reduced']; foreach ( $lines as $line ) { $src = $line['item']; $part = $this->portion( $src, $line['qty'] ); $item = new \WC_Order_Item_Product(); $item->set_name( $src->get_name() ); $item->set_product_id( $src->get_product_id() ); $item->set_variation_id( $src->get_variation_id() ); $item->set_tax_class( $src->get_tax_class() ); $item->set_quantity( $line['qty'] ); $item->set_subtotal( $part['subtotal'] ); $item->set_total( $part['total'] ); $item->set_taxes( $part['taxes'] ); foreach ( $src->get_meta_data() as $meta ) { if ( in_array( $meta->key, self::NO_COPY_META, true ) ) continue; $item->add_meta_data( $meta->key, $meta->value, false ); } $item->add_meta_data( '_pwsplit_parent_item_id', $src->get_id(), true ); $item->add_meta_data( '_pwsplit_qty', $line['qty'], true ); if ( $line['reason'] !== '' ) $item->add_meta_data( '_pwsplit_reason', $line['reason'], true ); if ( $line['note'] !== '' ) $item->add_meta_data( '_pwsplit_note', $line['note'], true ); if ( $carries_stock ) { $moved = min( $line['qty'], max( 0, (int) $src->get_meta( '_reduced_stock' ) ) ); if ( $moved > 0 ) $item->add_meta_data( '_reduced_stock', $moved, true ); } $child->add_item( $item ); } if ( $type === 'split' && $plan['ship_to_child'] !== 'none' ) { $zero = $plan['ship_to_child'] === 'zero'; foreach ( $order->get_items( 'shipping' ) as $src ) { $ship = new \WC_Order_Item_Shipping(); $ship->set_method_title( $src->get_method_title() ); $ship->set_method_id( $src->get_method_id() ); $ship->set_instance_id( $src->get_instance_id() ); $ship->set_total( $zero ? 0 : $src->get_total() ); $ship->set_taxes( $zero ? [] : $src->get_taxes() ); foreach ( $src->get_meta_data() as $meta ) { $ship->add_meta_data( $meta->key, $meta->value, false ); } $child->add_item( $ship ); } } if ( $type === 'split' && $plan['paid'] ) { $child->set_transaction_id( $order->get_transaction_id() ); $child->set_date_paid( $order->get_date_paid() ); $child->update_meta_data( '_pwsplit_paid_via_parent', 1 ); } $reasons = array_unique( array_filter( array_column( $lines, 'reason' ) ) ); $child->update_meta_data( '_pwsplit_parent_id', $order->get_id() ); $child->update_meta_data( '_pwsplit_type', $type ); if ( $reasons ) $child->update_meta_data( '_pwsplit_reason', implode( ', ', $reasons ) ); do_action( 'patchwork_split_before_child_save', $child, $order, $lines, $type, $args ); $child->update_taxes(); $child->calculate_totals( false ); // saves; the child has an ID from here on // Stock for these units was already reduced on the parent. Flag the child so // WooCommerce doesn't reduce it again when the status is set below. self::set_stock_reduced( $child, $carries_stock ); $child->add_order_note( $this->child_note( $order, $lines, $type, $plan ), 0, true ); $status = $type === 'unable' ? 'cancelled' : $plan['child_status']; $child->set_status( $status ); $child->save(); if ( $type === 'split' ) { do_action( 'patchwork_split_child_created', $child->get_id(), $order->get_id(), array_keys( $lines ), $args ); } return $child; } /** * Refund Unable lines on a paid order. * * @return \WC_Order_Refund|\WP_Error */ private function refund_unable( \WC_Order $order, array $lines, array $args, bool $restock ) { $line_items = []; $amount = 0.0; $names = []; foreach ( $lines as $line ) { $part = $this->portion( $line['item'], $line['qty'] ); $line_items[ $line['item_id'] ] = [ 'qty' => $line['qty'], 'refund_total' => $part['total'], 'refund_tax' => $part['taxes']['total'], ]; $amount += $part['total'] + array_sum( array_map( 'floatval', $part['taxes']['total'] ) ); $names[] = $line['item']->get_name() . ( $line['reason'] !== '' ? ' (' . $line['reason'] . ')' : '' ); } return wc_create_refund( [ 'order_id' => $order->get_id(), 'amount' => wc_format_decimal( $amount, wc_get_price_decimals() ), /* translators: %s: list of items */ 'reason' => sprintf( __( 'Unable to fulfil: %s', 'patchwork-order-split' ), implode( ', ', $names ) ), 'line_items' => $line_items, 'refund_payment' => $args['refund_via_gateway'], 'restock_items' => $restock, ] ); } // ─── Helpers ────────────────────────────────────────────────────────────── /** * Amounts for $qty units of a line, prorated from the original values. */ private function portion( \WC_Order_Item_Product $item, int $qty ): array { $full = (int) $item->get_quantity(); $taxes = $item->get_taxes(); if ( $qty >= $full ) { return [ 'subtotal' => (float) $item->get_subtotal(), 'total' => (float) $item->get_total(), 'taxes' => [ 'total' => (array) ( $taxes['total'] ?? [] ), 'subtotal' => (array) ( $taxes['subtotal'] ?? [] ) ], ]; } $ratio = $qty / $full; $precision = wc_get_rounding_precision(); $out = [ 'total' => [], 'subtotal' => [] ]; foreach ( [ 'total', 'subtotal' ] as $key ) { foreach ( (array) ( $taxes[ $key ] ?? [] ) as $rate_id => $amount ) { $out[ $key ][ $rate_id ] = $amount === '' ? '' : round( (float) $amount * $ratio, $precision ); } } return [ 'subtotal' => round( (float) $item->get_subtotal() * $ratio, $precision ), 'total' => round( (float) $item->get_total() * $ratio, $precision ), 'taxes' => $out, ]; } private function parent_note( array $lines, ?\WC_Order $child, ?\WC_Order $unable, $refund, array $stock_notes, array $plan ): string { $out = [ __( 'Patchwork Split', 'patchwork-order-split' ) ]; foreach ( $lines as $line ) { $name = $line['item'] ? $line['item']->get_name() : '#' . $line['item_id']; $out[] = sprintf( '• %s × %d — %s%s', $name, $line['qty'], $line['action'] === 'split' ? __( 'split', 'patchwork-order-split' ) : __( 'unable', 'patchwork-order-split' ), $line['reason'] !== '' ? ' (' . $line['reason'] . ')' : '' ); } /* translators: %d: order number */ if ( $child ) $out[] = sprintf( __( 'New order: #%d', 'patchwork-order-split' ), $child->get_id() ); /* translators: %d: order number */ if ( $unable ) $out[] = sprintf( __( 'Unable items recorded in cancelled order #%d', 'patchwork-order-split' ), $unable->get_id() ); if ( $refund ) { /* translators: %s: refund amount */ $out[] = sprintf( __( 'Refund created for unable items: %s', 'patchwork-order-split' ), wp_strip_all_tags( wc_price( $refund->get_amount() ) ) ) . ( $refund->get_refunded_payment() ? '' : ' ' . __( '(recorded only — refund the customer through your payment provider)', 'patchwork-order-split' ) ); } /* translators: %s: list of products and stock changes */ if ( $stock_notes ) $out[] = sprintf( __( 'Stock restored: %s', 'patchwork-order-split' ), implode( ', ', $stock_notes ) ); if ( $plan['remove_parent_shipping'] && $child ) $out[] = __( 'Shipping moved to the new order.', 'patchwork-order-split' ); return implode( "\n", $out ); } private function child_note( \WC_Order $order, array $lines, string $type, array $plan ): string { $out = [ $type === 'unable' /* translators: %d: order number */ ? sprintf( __( 'Items that could not be fulfilled from order #%d.', 'patchwork-order-split' ), $order->get_id() ) /* translators: %d: order number */ : sprintf( __( 'Split from order #%d.', 'patchwork-order-split' ), $order->get_id() ) ]; foreach ( $lines as $line ) { if ( $line['reason'] !== '' || $line['note'] !== '' ) { $out[] = sprintf( '• %s: %s', $line['item']->get_name(), trim( $line['reason'] . ( $line['note'] !== '' ? ' — ' . $line['note'] : '' ) ) ); } } if ( $type === 'split' && $plan['paid'] ) { $out[] = __( 'Paid as part of the original order. Make any refunds from the original order.', 'patchwork-order-split' ); } return implode( "\n", $out ); } /** * One customer note on the original order (the number the customer knows). */ private function notify_customer( \WC_Order $order, array $lines, array $result, array $args ): void { $order = wc_get_order( $order->get_id() ); if ( ! $order ) return; $notify_split = $args['notify_customer'] ?? Settings::email_on_split(); $notify_unable = $args['notify_customer'] ?? Settings::email_on_unable(); $parts = []; $split = array_filter( $lines, function( $l ) { return $l['action'] === 'split'; } ); $unable = array_filter( $lines, function( $l ) { return $l['action'] === 'unable'; } ); if ( $notify_split && $split && $result['child_id'] ) { $parts[] = sprintf( /* translators: 1: new order number, 2: list of items */ __( 'Some items from your order will be sent separately as order #%1$d: %2$s.', 'patchwork-order-split' ), $result['child_id'], $this->item_list( $split ) ); } if ( $notify_unable && $unable ) { $parts[] = sprintf( /* translators: %s: list of items */ __( 'Unfortunately we are unable to supply: %s.', 'patchwork-order-split' ), $this->item_list( $unable, true ) ) . ( $result['refund_id'] ? ' ' . __( 'These items have been refunded.', 'patchwork-order-split' ) : '' ); } if ( $parts ) { $order->add_order_note( implode( "\n\n", $parts ), 1, true ); } } private function item_list( array $lines, bool $with_reason = false ): string { return implode( ', ', array_map( function( $l ) use ( $with_reason ) { return sprintf( '%s × %d', $l['item']->get_name(), $l['qty'] ) . ( $with_reason && $l['reason'] !== '' ? ' (' . $l['reason'] . ')' : '' ); }, $lines ) ); } /** * Set the order's "stock reduced" flag on the object itself, so a later save * of this object can't overwrite it with a stale value (HPOS). */ private static function set_stock_reduced( \WC_Order $order, bool $value ): void { if ( is_callable( [ $order, 'set_order_stock_reduced' ] ) ) { $order->set_order_stock_reduced( $value ); $order->save(); } else { $order->get_data_store()->set_stock_reduced( $order, $value ); } } /** Per-order lock, shared with other order operations (e.g. merge). */ public static function lock( int $order_id ): bool { $key = 'patchwork_split_lock_' . $order_id; if ( add_option( $key, time(), '', false ) ) return true; $since = (int) get_option( $key ); if ( $since && time() - $since > 120 ) { // stale lock from a crashed request delete_option( $key ); return add_option( $key, time(), '', false ); } return false; } public static function unlock( int $order_id ): void { delete_option( 'patchwork_split_lock_' . $order_id ); } }