|
@@ -0,0 +1,349 @@
|
|
|
|
|
+<?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');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|