class-order-item-status-manager.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. <?php
  2. /**
  3. * Per-order-item print status — UI, persistence, propagation, completion guard.
  4. *
  5. * See docs/feature-order-item-status-analysis.md for the design rationale.
  6. *
  7. * Coupling rules (one-way, order → items):
  8. * - Order → wc-in-print: items in `pending-print` advance to `in-print`.
  9. * Other item statuses are untouched.
  10. * - Order → wc-completed: gated, not propagating. Blocked when any product
  11. * line item is still `pending-print` or `in-print`.
  12. * - All other order-status transitions: no automatic item change.
  13. * - Item status changed manually: never propagates to the order.
  14. */
  15. defined('ABSPATH') || exit;
  16. class Order_Item_Status_Manager {
  17. const META_KEY = '_print_status';
  18. const STATUS_PENDING = 'pending-print';
  19. const STATUS_IN = 'in-print';
  20. const STATUS_DONE = 'done-print';
  21. const STATUS_SKIP = 'skip-print';
  22. /**
  23. * Statuses that satisfy the completion guard. Anything not in this list
  24. * blocks the order from transitioning to `wc-completed`.
  25. */
  26. const STATUSES_THAT_SATISFY_COMPLETION = array(
  27. self::STATUS_DONE,
  28. self::STATUS_SKIP,
  29. );
  30. /**
  31. * Re-entrancy flag for the completion-guard revert path. The revert calls
  32. * $order->update_status() which fires woocommerce_order_status_changed
  33. * again — without this flag we'd recurse indefinitely on a blocked
  34. * completion attempt.
  35. */
  36. private static $inside_self_revert = false;
  37. public function __construct() {
  38. add_action('woocommerce_admin_order_item_headers', array($this, 'render_column_header'));
  39. add_action('woocommerce_admin_order_item_values', array($this, 'render_column_value'), 10, 3);
  40. add_action('woocommerce_process_shop_order_meta', array($this, 'save_item_statuses_from_post'));
  41. add_action('woocommerce_order_status_changed', array($this, 'on_order_status_changed'), 5, 4);
  42. }
  43. public static function valid_statuses() {
  44. return array(
  45. self::STATUS_PENDING,
  46. self::STATUS_IN,
  47. self::STATUS_DONE,
  48. self::STATUS_SKIP,
  49. );
  50. }
  51. /**
  52. * Translatable labels for the four statuses. Built lazily so the
  53. * translation lookup happens after the text domain is loaded.
  54. */
  55. public static function labels() {
  56. return array(
  57. self::STATUS_PENDING => __('Pending Print', 'studiou-wc-ord-print-statuses'),
  58. self::STATUS_IN => __('In Print', 'studiou-wc-ord-print-statuses'),
  59. self::STATUS_DONE => __('Done Print', 'studiou-wc-ord-print-statuses'),
  60. self::STATUS_SKIP => __('Skip Print', 'studiou-wc-ord-print-statuses'),
  61. );
  62. }
  63. /**
  64. * Return the item's current print status. Missing meta defaults to
  65. * STATUS_PENDING — lazy default avoids a one-time migration over all
  66. * existing orders.
  67. */
  68. public function get_status($item) {
  69. if (!$item instanceof WC_Order_Item_Product) {
  70. return self::STATUS_PENDING;
  71. }
  72. $value = (string) $item->get_meta(self::META_KEY);
  73. if (in_array($value, self::valid_statuses(), true)) {
  74. return $value;
  75. }
  76. return self::STATUS_PENDING;
  77. }
  78. /**
  79. * Persist a new status for a single item. Does NOT call $item->save() —
  80. * the caller is responsible for persistence (so callers that batch
  81. * multiple item updates can save in one round-trip).
  82. *
  83. * @param WC_Order_Item_Product $item
  84. * @param string $status One of the four constants.
  85. * @param bool $audit If true, append an order note describing the transition.
  86. * @return bool true if the meta was updated (or already at the requested value), false if rejected.
  87. */
  88. public function set_status($item, $status, $audit = true) {
  89. if (!$item instanceof WC_Order_Item_Product) {
  90. return false;
  91. }
  92. if (!in_array($status, self::valid_statuses(), true)) {
  93. return false;
  94. }
  95. $current = $this->get_status($item);
  96. if ($current === $status) {
  97. return true; // no-op
  98. }
  99. $item->update_meta_data(self::META_KEY, $status);
  100. if ($audit) {
  101. $order = $item->get_order();
  102. if ($order) {
  103. $labels = self::labels();
  104. $order->add_order_note(sprintf(
  105. /* translators: 1: item ID, 2: product name, 3: old status label, 4: new status label */
  106. __('Item #%1$d (%2$s): %3$s → %4$s.', 'studiou-wc-ord-print-statuses'),
  107. $item->get_id(),
  108. wp_strip_all_tags((string) $item->get_name()),
  109. $labels[$current],
  110. $labels[$status]
  111. ));
  112. }
  113. }
  114. return true;
  115. }
  116. /**
  117. * Inspect every product line item on the order. Returns an array with
  118. * keys 'ready' (bool) and 'blocking' (array of WC_Order_Item_Product
  119. * instances still in pending-print or in-print).
  120. *
  121. * Non-product items (shipping, fees, coupons, taxes, refunds) are
  122. * ignored — they don't carry a print status and never block.
  123. *
  124. * @param WC_Order $order
  125. * @return array{ready:bool, blocking:WC_Order_Item_Product[]}
  126. */
  127. public function is_order_ready_for_completion($order) {
  128. $blocking = array();
  129. foreach ($order->get_items() as $item) {
  130. if (!$item instanceof WC_Order_Item_Product) {
  131. continue;
  132. }
  133. $status = $this->get_status($item);
  134. if (!in_array($status, self::STATUSES_THAT_SATISFY_COMPLETION, true)) {
  135. $blocking[] = $item;
  136. }
  137. }
  138. return array(
  139. 'ready' => empty($blocking),
  140. 'blocking' => $blocking,
  141. );
  142. }
  143. /**
  144. * Advance every product item currently in `pending-print` to `in-print`.
  145. * Used by the propagation listener on order → wc-in-print transitions.
  146. * Items in `done-print` or `skip-print` are untouched.
  147. *
  148. * @param WC_Order $order
  149. * @return int Number of items advanced.
  150. */
  151. public function advance_pending_to_in($order) {
  152. $count = 0;
  153. foreach ($order->get_items() as $item) {
  154. if (!$item instanceof WC_Order_Item_Product) {
  155. continue;
  156. }
  157. if ($this->get_status($item) !== self::STATUS_PENDING) {
  158. continue;
  159. }
  160. // Skip per-item audit notes — the caller adds a single summary
  161. // note ("N items advanced to In Print …") for the whole batch.
  162. $this->set_status($item, self::STATUS_IN, false);
  163. $item->save();
  164. $count++;
  165. }
  166. return $count;
  167. }
  168. /**
  169. * Advance every product item still in `pending-print` or `in-print` to
  170. * `done-print`. Used by Delivered Protocol import before flipping the
  171. * order to `wc-completed` so the completion guard is satisfied.
  172. *
  173. * @param WC_Order $order
  174. * @return int Number of items advanced.
  175. */
  176. public function advance_remaining_to_done($order) {
  177. $count = 0;
  178. foreach ($order->get_items() as $item) {
  179. if (!$item instanceof WC_Order_Item_Product) {
  180. continue;
  181. }
  182. $current = $this->get_status($item);
  183. if (!in_array($current, array(self::STATUS_PENDING, self::STATUS_IN), true)) {
  184. continue;
  185. }
  186. $this->set_status($item, self::STATUS_DONE, false);
  187. $item->save();
  188. $count++;
  189. }
  190. return $count;
  191. }
  192. /* ---------------------------------------------------------------- *
  193. * UI rendering
  194. * ---------------------------------------------------------------- */
  195. /**
  196. * Render the new column header in the order-items table. Fires inside
  197. * `<thead>` for the product items section of the order edit screen.
  198. */
  199. public function render_column_header(/* $order */) {
  200. echo '<th class="item_print_status sortable" data-sort="string-ins">'
  201. . esc_html__('Print Status', 'studiou-wc-ord-print-statuses')
  202. . '</th>';
  203. }
  204. /**
  205. * Render the per-row cell. Fires once per product line item in the
  206. * order-items table. Non-product items are handled by their own
  207. * templates, which don't fire this hook.
  208. */
  209. public function render_column_value($product, $item, $item_id) {
  210. if (!$item instanceof WC_Order_Item_Product) {
  211. echo '<td class="item_print_status">—</td>';
  212. return;
  213. }
  214. $current = $this->get_status($item);
  215. $labels = self::labels();
  216. echo '<td class="item_print_status" data-print-status="' . esc_attr($current) . '">';
  217. echo '<select name="print_status[' . esc_attr((string) $item_id) . ']" class="studiou-print-status-select">';
  218. foreach (self::valid_statuses() as $slug) {
  219. printf(
  220. '<option value="%1$s"%2$s>%3$s</option>',
  221. esc_attr($slug),
  222. selected($slug, $current, false),
  223. esc_html($labels[$slug])
  224. );
  225. }
  226. echo '</select>';
  227. echo '</td>';
  228. }
  229. /* ---------------------------------------------------------------- *
  230. * Save handler
  231. * ---------------------------------------------------------------- */
  232. /**
  233. * Persist any submitted item-status changes from $_POST['print_status'].
  234. * Hooked to `woocommerce_process_shop_order_meta` (fires after WC's own
  235. * item processing on form submit). HPOS- and CPT-compatible.
  236. */
  237. public function save_item_statuses_from_post($order_id) {
  238. if (!current_user_can('edit_shop_order', $order_id) && !current_user_can('edit_shop_orders')) {
  239. return;
  240. }
  241. if (empty($_POST['print_status']) || !is_array($_POST['print_status'])) {
  242. return;
  243. }
  244. $order = wc_get_order($order_id);
  245. if (!$order) {
  246. return;
  247. }
  248. $valid_statuses = self::valid_statuses();
  249. foreach (wp_unslash($_POST['print_status']) as $item_id => $new_status) {
  250. $new_status = sanitize_text_field((string) $new_status);
  251. if (!in_array($new_status, $valid_statuses, true)) {
  252. continue;
  253. }
  254. $item = $order->get_item((int) $item_id);
  255. if (!$item instanceof WC_Order_Item_Product) {
  256. continue;
  257. }
  258. if ($this->set_status($item, $new_status)) {
  259. $item->save();
  260. }
  261. }
  262. }
  263. /* ---------------------------------------------------------------- *
  264. * Combined propagation + completion guard listener
  265. * ---------------------------------------------------------------- */
  266. /**
  267. * Hooked to `woocommerce_order_status_changed` at priority 5.
  268. *
  269. * Two behaviours:
  270. * 1. Propagation: on `→ in-print`, advance pending items to in-print.
  271. * Includes manual admin dropdown changes, bulk actions, and the
  272. * InPrint Protocol import — they all flow through update_status.
  273. * 2. Guard: on `→ completed`, check that every product item is in
  274. * done-print or skip-print. If not, revert the status to $from
  275. * and surface an admin notice.
  276. */
  277. public function on_order_status_changed($order_id, $from, $to, $order) {
  278. if (self::$inside_self_revert) {
  279. return;
  280. }
  281. if ($to === 'in-print') {
  282. $advanced = $this->advance_pending_to_in($order);
  283. if ($advanced > 0) {
  284. $order->add_order_note(sprintf(
  285. /* translators: %d is the number of items advanced. */
  286. _n(
  287. '%d line item advanced to "In Print" by order-status change.',
  288. '%d line items advanced to "In Print" by order-status change.',
  289. $advanced,
  290. 'studiou-wc-ord-print-statuses'
  291. ),
  292. $advanced
  293. ));
  294. }
  295. return;
  296. }
  297. if ($to === 'completed') {
  298. $check = $this->is_order_ready_for_completion($order);
  299. if ($check['ready']) {
  300. return;
  301. }
  302. self::$inside_self_revert = true;
  303. try {
  304. $order->update_status(
  305. $from,
  306. __('Cannot complete: product line items still pending print. Resolve item statuses first.', 'studiou-wc-ord-print-statuses')
  307. );
  308. $order->save();
  309. } finally {
  310. self::$inside_self_revert = false;
  311. }
  312. UtilsLog::message(
  313. sprintf(
  314. /* translators: 1: order ID, 2: number of blocking items. */
  315. __('Order #%1$d completion blocked — %2$d product items not yet "Done Print" or "Skip Print".', 'studiou-wc-ord-print-statuses'),
  316. $order_id,
  317. count($check['blocking'])
  318. ),
  319. 'error'
  320. );
  321. UtilsLog::log('Order #' . $order_id . ' completion blocked: ' . count($check['blocking']) . ' item(s) not ready');
  322. }
  323. }
  324. }