| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- <?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.
- */
- 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);
- }
- 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;
- }
- 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');
- }
- }
- }
|