| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546 |
- <?php
- /**
- * Per-order-item print status — UI, persistence, propagation, completion guard.
- *
- * See docs/feature-order-item-status-analysis.md for the design rationale.
- *
- * Coupling rules (one-way, order → items):
- * - Order → wc-in-print: items in `pending-print` advance to `in-print`.
- * Other item statuses are untouched.
- * - Order → wc-completed: gated, not propagating. Blocked when any product
- * line item is still `pending-print` or `in-print`.
- * - All other order-status transitions: no automatic item change.
- * - Item status changed manually: never propagates to the order.
- *
- * One scoped exception to the gate above — the HPOS Orders list "Change status
- * to Completed" bulk action ONLY: a priority-9 pre-advance moves every still
- * pending/in-print item on each *non-terminal* selected order to `done-print`
- * BEFORE WooCommerce flips the status, so the completion guard then passes
- * instead of reverting. Terminal/already-completed orders are skipped. This does
- * NOT apply to single-order, programmatic, or import completions. See
- * bulk_complete_advance_items() and docs/order-done-plan-01.md.
- *
- * Belt-and-suspenders: the completion guard itself carries a fallback for the
- * same bulk entry point (is_bulk_complete_request()). If the priority-9
- * pre-advance ever fails to run before WooCommerce's status flip, the guard
- * advances the remaining items on the order it just completed instead of
- * reverting — priority-independent, and on the same WC_Order instance. Terminal
- * source statuses are still reverted, preserving the skip above.
- */
- defined('ABSPATH') || exit;
- class Order_Item_Status_Manager {
- const META_KEY = '_print_status';
- const STATUS_PENDING = 'pending-print';
- const STATUS_IN = 'in-print';
- const STATUS_DONE = 'done-print';
- const STATUS_SKIP = 'skip-print';
- /**
- * Statuses that satisfy the completion guard. Anything not in this list
- * blocks the order from transitioning to `wc-completed`.
- */
- const STATUSES_THAT_SATISFY_COMPLETION = array(
- self::STATUS_DONE,
- self::STATUS_SKIP,
- );
- /**
- * Re-entrancy flag for the completion-guard revert path. The revert calls
- * $order->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
- * `<thead>` for the product items section of the order edit screen.
- */
- public function render_column_header(/* $order */) {
- echo '<th class="item_print_status sortable" data-sort="string-ins">'
- . esc_html__('Print Status', 'studiou-wc-ord-print-statuses')
- . '</th>';
- }
- /**
- * 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 '<td class="item_print_status">—</td>';
- return;
- }
- $current = $this->get_status($item);
- $labels = self::labels();
- echo '<td class="item_print_status" data-print-status="' . esc_attr($current) . '">';
- echo '<select name="print_status[' . esc_attr((string) $item_id) . ']" class="studiou-print-status-select">';
- foreach (self::valid_statuses() as $slug) {
- printf(
- '<option value="%1$s"%2$s>%3$s</option>',
- esc_attr($slug),
- selected($slug, $current, false),
- esc_html($labels[$slug])
- );
- }
- echo '</select>';
- echo '</td>';
- }
- /* ---------------------------------------------------------------- *
- * 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;
- }
- }
|