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); } 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; } 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'); } } }