update_status() which fires woocommerce_order_status_changed * again — without this flag we'd recurse indefinitely on a blocked * completion attempt. */ private static $inside_self_revert = false; public function __construct() { add_action('woocommerce_admin_order_item_headers', array($this, 'render_column_header')); add_action('woocommerce_admin_order_item_values', array($this, 'render_column_value'), 10, 3); add_action('woocommerce_process_shop_order_meta', array($this, 'save_item_statuses_from_post')); add_action('woocommerce_order_status_changed', array($this, 'on_order_status_changed'), 5, 4); // Proprietary pre-advance for the HPOS Orders list "Change status to // Completed" bulk action. Priority 9 runs before WooCommerce's own // priority-10 mark_completed handler on the same filter. add_filter('handle_bulk_actions-woocommerce_page_wc-orders', array($this, 'bulk_complete_advance_items'), 9, 3); } public static function valid_statuses() { return array( self::STATUS_PENDING, self::STATUS_IN, self::STATUS_DONE, self::STATUS_SKIP, ); } /** * Translatable labels for the four statuses. Built lazily so the * translation lookup happens after the text domain is loaded. */ public static function labels() { return array( self::STATUS_PENDING => __('Pending Print', 'studiou-wc-ord-print-statuses'), self::STATUS_IN => __('In Print', 'studiou-wc-ord-print-statuses'), self::STATUS_DONE => __('Done Print', 'studiou-wc-ord-print-statuses'), self::STATUS_SKIP => __('Skip Print', 'studiou-wc-ord-print-statuses'), ); } /** * Return the item's current print status. Missing meta defaults to * STATUS_PENDING — lazy default avoids a one-time migration over all * existing orders. */ public function get_status($item) { if (!$item instanceof WC_Order_Item_Product) { return self::STATUS_PENDING; } $value = (string) $item->get_meta(self::META_KEY); if (in_array($value, self::valid_statuses(), true)) { return $value; } return self::STATUS_PENDING; } /** * Persist a new status for a single item. Does NOT call $item->save() — * the caller is responsible for persistence (so callers that batch * multiple item updates can save in one round-trip). * * @param WC_Order_Item_Product $item * @param string $status One of the four constants. * @param bool $audit If true, append an order note describing the transition. * @return bool true if the meta was updated (or already at the requested value), false if rejected. */ public function set_status($item, $status, $audit = true) { if (!$item instanceof WC_Order_Item_Product) { return false; } if (!in_array($status, self::valid_statuses(), true)) { return false; } $current = $this->get_status($item); if ($current === $status) { return true; // no-op } $item->update_meta_data(self::META_KEY, $status); if ($audit) { $order = $item->get_order(); if ($order) { $labels = self::labels(); $order->add_order_note(sprintf( /* translators: 1: item ID, 2: product name, 3: old status label, 4: new status label */ __('Item #%1$d (%2$s): %3$s → %4$s.', 'studiou-wc-ord-print-statuses'), $item->get_id(), wp_strip_all_tags((string) $item->get_name()), $labels[$current], $labels[$status] )); } } return true; } /** * Inspect every product line item on the order. Returns an array with * keys 'ready' (bool) and 'blocking' (array of WC_Order_Item_Product * instances still in pending-print or in-print). * * Non-product items (shipping, fees, coupons, taxes, refunds) are * ignored — they don't carry a print status and never block. * * @param WC_Order $order * @return array{ready:bool, blocking:WC_Order_Item_Product[]} */ public function is_order_ready_for_completion($order) { $blocking = array(); foreach ($order->get_items() as $item) { if (!$item instanceof WC_Order_Item_Product) { continue; } $status = $this->get_status($item); if (!in_array($status, self::STATUSES_THAT_SATISFY_COMPLETION, true)) { $blocking[] = $item; } } return array( 'ready' => empty($blocking), 'blocking' => $blocking, ); } /** * Advance every product item currently in `pending-print` to `in-print`. * Used by the propagation listener on order → wc-in-print transitions. * Items in `done-print` or `skip-print` are untouched. * * @param WC_Order $order * @return int Number of items advanced. */ public function advance_pending_to_in($order) { $count = 0; foreach ($order->get_items() as $item) { if (!$item instanceof WC_Order_Item_Product) { continue; } if ($this->get_status($item) !== self::STATUS_PENDING) { continue; } // Skip per-item audit notes — the caller adds a single summary // note ("N items advanced to In Print …") for the whole batch. $this->set_status($item, self::STATUS_IN, false); $item->save(); $count++; } return $count; } /** * Advance every product item still in `pending-print` or `in-print` to * `done-print`. Used by Delivered Protocol import before flipping the * order to `wc-completed` so the completion guard is satisfied. * * @param WC_Order $order * @return int Number of items advanced. */ public function advance_remaining_to_done($order) { $count = 0; foreach ($order->get_items() as $item) { if (!$item instanceof WC_Order_Item_Product) { continue; } $current = $this->get_status($item); if (!in_array($current, array(self::STATUS_PENDING, self::STATUS_IN), true)) { continue; } $this->set_status($item, self::STATUS_DONE, false); $item->save(); $count++; } return $count; } /* ---------------------------------------------------------------- * * UI rendering * ---------------------------------------------------------------- */ /** * Render the new column header in the order-items table. Fires inside * `` for the product items section of the order edit screen. */ public function render_column_header(/* $order */) { echo '' . esc_html__('Print Status', 'studiou-wc-ord-print-statuses') . ''; } /** * Render the per-row cell. Fires once per product line item in the * order-items table. Non-product items are handled by their own * templates, which don't fire this hook. */ public function render_column_value($product, $item, $item_id) { if (!$item instanceof WC_Order_Item_Product) { echo '—'; return; } $current = $this->get_status($item); $labels = self::labels(); echo ''; echo ''; echo ''; } /* ---------------------------------------------------------------- * * Save handler * ---------------------------------------------------------------- */ /** * Persist any submitted item-status changes from $_POST['print_status']. * Hooked to `woocommerce_process_shop_order_meta` (fires after WC's own * item processing on form submit). HPOS- and CPT-compatible. */ public function save_item_statuses_from_post($order_id) { if (!current_user_can('edit_shop_order', $order_id) && !current_user_can('edit_shop_orders')) { return; } if (empty($_POST['print_status']) || !is_array($_POST['print_status'])) { return; } $order = wc_get_order($order_id); if (!$order) { return; } $valid_statuses = self::valid_statuses(); foreach (wp_unslash($_POST['print_status']) as $item_id => $new_status) { $new_status = sanitize_text_field((string) $new_status); if (!in_array($new_status, $valid_statuses, true)) { continue; } $item = $order->get_item((int) $item_id); if (!$item instanceof WC_Order_Item_Product) { continue; } if ($this->set_status($item, $new_status)) { $item->save(); } } } /* ---------------------------------------------------------------- * * Combined propagation + completion guard listener * ---------------------------------------------------------------- */ /** * Hooked to `woocommerce_order_status_changed` at priority 5. * * Two behaviours: * 1. Propagation: on `→ in-print`, advance pending items to in-print. * Includes manual admin dropdown changes, bulk actions, and the * InPrint Protocol import — they all flow through update_status. * 2. Guard: on `→ completed`, check that every product item is in * done-print or skip-print. If not, revert the status to $from * and surface an admin notice. */ public function on_order_status_changed($order_id, $from, $to, $order) { if (self::$inside_self_revert) { return; } if ($to === 'in-print') { $advanced = $this->advance_pending_to_in($order); if ($advanced > 0) { $order->add_order_note(sprintf( /* translators: %d is the number of items advanced. */ _n( '%d line item advanced to "In Print" by order-status change.', '%d line items advanced to "In Print" by order-status change.', $advanced, 'studiou-wc-ord-print-statuses' ), $advanced )); } return; } if ($to === 'completed') { $check = $this->is_order_ready_for_completion($order); if ($check['ready']) { return; } // §7 fallback (belt-and-suspenders for the priority-9 pre-advance in // bulk_complete_advance_items()). If this completion is the HPOS // Orders-list "Change status to Completed" bulk action and the order // was NOT previously terminal, advance the remaining items here — // priority-independent and operating on the SAME $order instance WC // just completed, so it survives a future WC change to mark_* // handling and any two-instance object-cache skew. Terminal source // statuses (cancelled/refunded/failed) are still reverted below, // matching the bulk handler's terminal-skip — see the class-level // coupling notes and docs/order-done-plan-01.md §6/§7. if ($this->is_bulk_complete_request() && !in_array($from, Studiou_DB_Manager::TERMINAL_STATUSES, true)) { $advanced = $this->advance_remaining_to_done($order); if ($advanced > 0) { $order->add_order_note(sprintf( /* translators: %d is the number of line items advanced. */ _n( '%d line item advanced to "Done Print" before bulk completion.', '%d line items advanced to "Done Print" before bulk completion.', $advanced, 'studiou-wc-ord-print-statuses' ), $advanced )); } UtilsLog::log(sprintf( 'Order #%d completed via bulk action; guard-fallback advanced %d item(s) to done-print (priority-9 pre-advance did not run first).', $order_id, $advanced )); return; // allow completion to stand } self::$inside_self_revert = true; try { $order->update_status( $from, __('Cannot complete: product line items still pending print. Resolve item statuses first.', 'studiou-wc-ord-print-statuses') ); $order->save(); } finally { self::$inside_self_revert = false; } UtilsLog::message( sprintf( /* translators: 1: order ID, 2: number of blocking items. */ __('Order #%1$d completion blocked — %2$d product items not yet "Done Print" or "Skip Print".', 'studiou-wc-ord-print-statuses'), $order_id, count($check['blocking']) ), 'error' ); UtilsLog::log('Order #' . $order_id . ' completion blocked: ' . count($check['blocking']) . ' item(s) not ready'); } } /** * True when the current request is the HPOS Orders-list "Change status to * Completed" bulk action (`page=wc-orders` + the resolved bulk action is * `mark_completed`). Mirrors WP_List_Table::current_action() — the active * action is `action` unless it is the "-1" placeholder, in which case it is * `action2` (the bottom-of-table dropdown). * * Scopes the §7 guard-fallback to exactly the same entry point as * bulk_complete_advance_items(): a single-order edit-screen save posts * `action=edit` (not `mark_completed`), and REST/cron/WP-CLI are not * `is_admin()`, so none of them trip this. */ private function is_bulk_complete_request() { if (!is_admin()) { return false; } if (!isset($_REQUEST['page']) || $_REQUEST['page'] !== 'wc-orders') { return false; } $action = ''; if (isset($_REQUEST['action']) && $_REQUEST['action'] !== '-1') { $action = sanitize_text_field(wp_unslash($_REQUEST['action'])); } elseif (isset($_REQUEST['action2']) && $_REQUEST['action2'] !== '-1') { $action = sanitize_text_field(wp_unslash($_REQUEST['action2'])); } return $action === 'mark_completed'; } /* ---------------------------------------------------------------- * * Bulk "Change status to Completed" pre-advance (HPOS Orders list) * ---------------------------------------------------------------- */ /** * Proprietary behaviour for the HPOS Orders list "Change status to Completed" * bulk action ONLY: advance every still-pending/in-print product item to * done-print BEFORE WooCommerce flips the order, so the completion guard is * satisfied instead of reverting. Hooked to * `handle_bulk_actions-woocommerce_page_wc-orders` at priority 9 (before WC's * own priority-10 mark_completed handler). Returns the redirect unchanged so * WC still performs the status change. * * Orders already in a terminal status (completed/cancelled/refunded/failed) are * deliberately NOT advanced — see the class-level coupling notes and * docs/order-done-plan-01.md §6: * - already-completed: WC's flip is a no-op, so advancing would silently * mutate items (and add a misleading note) with no transition; * - cancelled/refunded/failed with unresolved items: leaving them unadvanced * lets the existing completion guard revert WC's flip, protecting them from * being completed by an over-broad selection — matching the protocol-import * skip-terminal philosophy. * * Each order is processed inside try/catch: a single failing order must not * abort the whole batch (which would also stop WC's priority-10 handler from * completing ANY order and leave earlier orders with advanced-but-uncompleted * items). * * @param string $redirect_to * @param string $action * @param int[] $ids * @return string The (unchanged) redirect URL. */ public function bulk_complete_advance_items($redirect_to, $action, $ids) { if ($action !== 'mark_completed') { return $redirect_to; } if (!current_user_can('edit_shop_orders')) { return $redirect_to; // WC's handler enforces caps; we don't mutate without it. } $ids = array_filter(array_map('intval', (array) $ids)); if (empty($ids)) { return $redirect_to; } $total_items = 0; $total_orders = 0; $failures = 0; foreach ($ids as $id) { try { $order = wc_get_order($id); if (!$order) { continue; } // Do not touch terminal/already-completed orders (see docblock). if ($order->has_status(Studiou_DB_Manager::TERMINAL_STATUSES)) { continue; } $advanced = $this->advance_remaining_to_done($order); if ($advanced > 0) { $order->add_order_note(sprintf( /* translators: %d is the number of line items advanced. */ _n( '%d line item advanced to "Done Print" before bulk completion.', '%d line items advanced to "Done Print" before bulk completion.', $advanced, 'studiou-wc-ord-print-statuses' ), $advanced )); $total_items += $advanced; $total_orders++; } } catch (\Throwable $e) { $failures++; UtilsLog::log(sprintf( 'bulk_complete_advance_items: order #%d failed to pre-advance: %s', $id, $e->getMessage() )); } } if ($total_items > 0) { UtilsLog::message( sprintf( /* translators: 1: item count, 2: order count */ __('Auto-advanced %1$d line item(s) to "Done Print" across %2$d order(s) before completion.', 'studiou-wc-ord-print-statuses'), $total_items, $total_orders ), 'success' ); UtilsLog::log(sprintf('bulk mark_completed: advanced %d items across %d orders', $total_items, $total_orders)); } if ($failures > 0) { UtilsLog::message( sprintf( /* translators: %d is the number of orders that errored during pre-advance. */ _n( '%d order could not be pre-advanced and may not complete — check the debug log.', '%d orders could not be pre-advanced and may not complete — check the debug log.', $failures, 'studiou-wc-ord-print-statuses' ), $failures ), 'warning' ); } return $redirect_to; } }