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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  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. * One scoped exception to the gate above — the HPOS Orders list "Change status
  16. * to Completed" bulk action ONLY: a priority-9 pre-advance moves every still
  17. * pending/in-print item on each *non-terminal* selected order to `done-print`
  18. * BEFORE WooCommerce flips the status, so the completion guard then passes
  19. * instead of reverting. Terminal/already-completed orders are skipped. This does
  20. * NOT apply to single-order, programmatic, or import completions. See
  21. * bulk_complete_advance_items() and docs/order-done-plan-01.md.
  22. *
  23. * Belt-and-suspenders: the completion guard itself carries a fallback for the
  24. * same bulk entry point (is_bulk_complete_request()). If the priority-9
  25. * pre-advance ever fails to run before WooCommerce's status flip, the guard
  26. * advances the remaining items on the order it just completed instead of
  27. * reverting — priority-independent, and on the same WC_Order instance. Terminal
  28. * source statuses are still reverted, preserving the skip above.
  29. */
  30. defined('ABSPATH') || exit;
  31. class Order_Item_Status_Manager {
  32. const META_KEY = '_print_status';
  33. const STATUS_PENDING = 'pending-print';
  34. const STATUS_IN = 'in-print';
  35. const STATUS_DONE = 'done-print';
  36. const STATUS_SKIP = 'skip-print';
  37. /**
  38. * Statuses that satisfy the completion guard. Anything not in this list
  39. * blocks the order from transitioning to `wc-completed`.
  40. */
  41. const STATUSES_THAT_SATISFY_COMPLETION = array(
  42. self::STATUS_DONE,
  43. self::STATUS_SKIP,
  44. );
  45. /**
  46. * Re-entrancy flag for the completion-guard revert path. The revert calls
  47. * $order->update_status() which fires woocommerce_order_status_changed
  48. * again — without this flag we'd recurse indefinitely on a blocked
  49. * completion attempt.
  50. */
  51. private static $inside_self_revert = false;
  52. public function __construct() {
  53. add_action('woocommerce_admin_order_item_headers', array($this, 'render_column_header'));
  54. add_action('woocommerce_admin_order_item_values', array($this, 'render_column_value'), 10, 3);
  55. add_action('woocommerce_process_shop_order_meta', array($this, 'save_item_statuses_from_post'));
  56. add_action('woocommerce_order_status_changed', array($this, 'on_order_status_changed'), 5, 4);
  57. // Proprietary pre-advance for the HPOS Orders list "Change status to
  58. // Completed" bulk action. Priority 9 runs before WooCommerce's own
  59. // priority-10 mark_completed handler on the same filter.
  60. add_filter('handle_bulk_actions-woocommerce_page_wc-orders', array($this, 'bulk_complete_advance_items'), 9, 3);
  61. }
  62. public static function valid_statuses() {
  63. return array(
  64. self::STATUS_PENDING,
  65. self::STATUS_IN,
  66. self::STATUS_DONE,
  67. self::STATUS_SKIP,
  68. );
  69. }
  70. /**
  71. * Translatable labels for the four statuses. Built lazily so the
  72. * translation lookup happens after the text domain is loaded.
  73. */
  74. public static function labels() {
  75. return array(
  76. self::STATUS_PENDING => __('Pending Print', 'studiou-wc-ord-print-statuses'),
  77. self::STATUS_IN => __('In Print', 'studiou-wc-ord-print-statuses'),
  78. self::STATUS_DONE => __('Done Print', 'studiou-wc-ord-print-statuses'),
  79. self::STATUS_SKIP => __('Skip Print', 'studiou-wc-ord-print-statuses'),
  80. );
  81. }
  82. /**
  83. * Return the item's current print status. Missing meta defaults to
  84. * STATUS_PENDING — lazy default avoids a one-time migration over all
  85. * existing orders.
  86. */
  87. public function get_status($item) {
  88. if (!$item instanceof WC_Order_Item_Product) {
  89. return self::STATUS_PENDING;
  90. }
  91. $value = (string) $item->get_meta(self::META_KEY);
  92. if (in_array($value, self::valid_statuses(), true)) {
  93. return $value;
  94. }
  95. return self::STATUS_PENDING;
  96. }
  97. /**
  98. * Persist a new status for a single item. Does NOT call $item->save() —
  99. * the caller is responsible for persistence (so callers that batch
  100. * multiple item updates can save in one round-trip).
  101. *
  102. * @param WC_Order_Item_Product $item
  103. * @param string $status One of the four constants.
  104. * @param bool $audit If true, append an order note describing the transition.
  105. * @return bool true if the meta was updated (or already at the requested value), false if rejected.
  106. */
  107. public function set_status($item, $status, $audit = true) {
  108. if (!$item instanceof WC_Order_Item_Product) {
  109. return false;
  110. }
  111. if (!in_array($status, self::valid_statuses(), true)) {
  112. return false;
  113. }
  114. $current = $this->get_status($item);
  115. if ($current === $status) {
  116. return true; // no-op
  117. }
  118. $item->update_meta_data(self::META_KEY, $status);
  119. if ($audit) {
  120. $order = $item->get_order();
  121. if ($order) {
  122. $labels = self::labels();
  123. $order->add_order_note(sprintf(
  124. /* translators: 1: item ID, 2: product name, 3: old status label, 4: new status label */
  125. __('Item #%1$d (%2$s): %3$s → %4$s.', 'studiou-wc-ord-print-statuses'),
  126. $item->get_id(),
  127. wp_strip_all_tags((string) $item->get_name()),
  128. $labels[$current],
  129. $labels[$status]
  130. ));
  131. }
  132. }
  133. return true;
  134. }
  135. /**
  136. * Inspect every product line item on the order. Returns an array with
  137. * keys 'ready' (bool) and 'blocking' (array of WC_Order_Item_Product
  138. * instances still in pending-print or in-print).
  139. *
  140. * Non-product items (shipping, fees, coupons, taxes, refunds) are
  141. * ignored — they don't carry a print status and never block.
  142. *
  143. * @param WC_Order $order
  144. * @return array{ready:bool, blocking:WC_Order_Item_Product[]}
  145. */
  146. public function is_order_ready_for_completion($order) {
  147. $blocking = array();
  148. foreach ($order->get_items() as $item) {
  149. if (!$item instanceof WC_Order_Item_Product) {
  150. continue;
  151. }
  152. $status = $this->get_status($item);
  153. if (!in_array($status, self::STATUSES_THAT_SATISFY_COMPLETION, true)) {
  154. $blocking[] = $item;
  155. }
  156. }
  157. return array(
  158. 'ready' => empty($blocking),
  159. 'blocking' => $blocking,
  160. );
  161. }
  162. /**
  163. * Advance every product item currently in `pending-print` to `in-print`.
  164. * Used by the propagation listener on order → wc-in-print transitions.
  165. * Items in `done-print` or `skip-print` are untouched.
  166. *
  167. * @param WC_Order $order
  168. * @return int Number of items advanced.
  169. */
  170. public function advance_pending_to_in($order) {
  171. $count = 0;
  172. foreach ($order->get_items() as $item) {
  173. if (!$item instanceof WC_Order_Item_Product) {
  174. continue;
  175. }
  176. if ($this->get_status($item) !== self::STATUS_PENDING) {
  177. continue;
  178. }
  179. // Skip per-item audit notes — the caller adds a single summary
  180. // note ("N items advanced to In Print …") for the whole batch.
  181. $this->set_status($item, self::STATUS_IN, false);
  182. $item->save();
  183. $count++;
  184. }
  185. return $count;
  186. }
  187. /**
  188. * Advance every product item still in `pending-print` or `in-print` to
  189. * `done-print`. Used by Delivered Protocol import before flipping the
  190. * order to `wc-completed` so the completion guard is satisfied.
  191. *
  192. * @param WC_Order $order
  193. * @return int Number of items advanced.
  194. */
  195. public function advance_remaining_to_done($order) {
  196. $count = 0;
  197. foreach ($order->get_items() as $item) {
  198. if (!$item instanceof WC_Order_Item_Product) {
  199. continue;
  200. }
  201. $current = $this->get_status($item);
  202. if (!in_array($current, array(self::STATUS_PENDING, self::STATUS_IN), true)) {
  203. continue;
  204. }
  205. $this->set_status($item, self::STATUS_DONE, false);
  206. $item->save();
  207. $count++;
  208. }
  209. return $count;
  210. }
  211. /* ---------------------------------------------------------------- *
  212. * UI rendering
  213. * ---------------------------------------------------------------- */
  214. /**
  215. * Render the new column header in the order-items table. Fires inside
  216. * `<thead>` for the product items section of the order edit screen.
  217. */
  218. public function render_column_header(/* $order */) {
  219. echo '<th class="item_print_status sortable" data-sort="string-ins">'
  220. . esc_html__('Print Status', 'studiou-wc-ord-print-statuses')
  221. . '</th>';
  222. }
  223. /**
  224. * Render the per-row cell. Fires once per product line item in the
  225. * order-items table. Non-product items are handled by their own
  226. * templates, which don't fire this hook.
  227. */
  228. public function render_column_value($product, $item, $item_id) {
  229. if (!$item instanceof WC_Order_Item_Product) {
  230. echo '<td class="item_print_status">—</td>';
  231. return;
  232. }
  233. $current = $this->get_status($item);
  234. $labels = self::labels();
  235. echo '<td class="item_print_status" data-print-status="' . esc_attr($current) . '">';
  236. echo '<select name="print_status[' . esc_attr((string) $item_id) . ']" class="studiou-print-status-select">';
  237. foreach (self::valid_statuses() as $slug) {
  238. printf(
  239. '<option value="%1$s"%2$s>%3$s</option>',
  240. esc_attr($slug),
  241. selected($slug, $current, false),
  242. esc_html($labels[$slug])
  243. );
  244. }
  245. echo '</select>';
  246. echo '</td>';
  247. }
  248. /* ---------------------------------------------------------------- *
  249. * Save handler
  250. * ---------------------------------------------------------------- */
  251. /**
  252. * Persist any submitted item-status changes from $_POST['print_status'].
  253. * Hooked to `woocommerce_process_shop_order_meta` (fires after WC's own
  254. * item processing on form submit). HPOS- and CPT-compatible.
  255. */
  256. public function save_item_statuses_from_post($order_id) {
  257. if (!current_user_can('edit_shop_order', $order_id) && !current_user_can('edit_shop_orders')) {
  258. return;
  259. }
  260. if (empty($_POST['print_status']) || !is_array($_POST['print_status'])) {
  261. return;
  262. }
  263. $order = wc_get_order($order_id);
  264. if (!$order) {
  265. return;
  266. }
  267. $valid_statuses = self::valid_statuses();
  268. foreach (wp_unslash($_POST['print_status']) as $item_id => $new_status) {
  269. $new_status = sanitize_text_field((string) $new_status);
  270. if (!in_array($new_status, $valid_statuses, true)) {
  271. continue;
  272. }
  273. $item = $order->get_item((int) $item_id);
  274. if (!$item instanceof WC_Order_Item_Product) {
  275. continue;
  276. }
  277. if ($this->set_status($item, $new_status)) {
  278. $item->save();
  279. }
  280. }
  281. }
  282. /* ---------------------------------------------------------------- *
  283. * Combined propagation + completion guard listener
  284. * ---------------------------------------------------------------- */
  285. /**
  286. * Hooked to `woocommerce_order_status_changed` at priority 5.
  287. *
  288. * Two behaviours:
  289. * 1. Propagation: on `→ in-print`, advance pending items to in-print.
  290. * Includes manual admin dropdown changes, bulk actions, and the
  291. * InPrint Protocol import — they all flow through update_status.
  292. * 2. Guard: on `→ completed`, check that every product item is in
  293. * done-print or skip-print. If not, revert the status to $from
  294. * and surface an admin notice.
  295. */
  296. public function on_order_status_changed($order_id, $from, $to, $order) {
  297. if (self::$inside_self_revert) {
  298. return;
  299. }
  300. if ($to === 'in-print') {
  301. $advanced = $this->advance_pending_to_in($order);
  302. if ($advanced > 0) {
  303. $order->add_order_note(sprintf(
  304. /* translators: %d is the number of items advanced. */
  305. _n(
  306. '%d line item advanced to "In Print" by order-status change.',
  307. '%d line items advanced to "In Print" by order-status change.',
  308. $advanced,
  309. 'studiou-wc-ord-print-statuses'
  310. ),
  311. $advanced
  312. ));
  313. }
  314. return;
  315. }
  316. if ($to === 'completed') {
  317. $check = $this->is_order_ready_for_completion($order);
  318. if ($check['ready']) {
  319. return;
  320. }
  321. // §7 fallback (belt-and-suspenders for the priority-9 pre-advance in
  322. // bulk_complete_advance_items()). If this completion is the HPOS
  323. // Orders-list "Change status to Completed" bulk action and the order
  324. // was NOT previously terminal, advance the remaining items here —
  325. // priority-independent and operating on the SAME $order instance WC
  326. // just completed, so it survives a future WC change to mark_*
  327. // handling and any two-instance object-cache skew. Terminal source
  328. // statuses (cancelled/refunded/failed) are still reverted below,
  329. // matching the bulk handler's terminal-skip — see the class-level
  330. // coupling notes and docs/order-done-plan-01.md §6/§7.
  331. if ($this->is_bulk_complete_request()
  332. && !in_array($from, Studiou_DB_Manager::TERMINAL_STATUSES, true)) {
  333. $advanced = $this->advance_remaining_to_done($order);
  334. if ($advanced > 0) {
  335. $order->add_order_note(sprintf(
  336. /* translators: %d is the number of line items advanced. */
  337. _n(
  338. '%d line item advanced to "Done Print" before bulk completion.',
  339. '%d line items advanced to "Done Print" before bulk completion.',
  340. $advanced,
  341. 'studiou-wc-ord-print-statuses'
  342. ),
  343. $advanced
  344. ));
  345. }
  346. UtilsLog::log(sprintf(
  347. 'Order #%d completed via bulk action; guard-fallback advanced %d item(s) to done-print (priority-9 pre-advance did not run first).',
  348. $order_id,
  349. $advanced
  350. ));
  351. return; // allow completion to stand
  352. }
  353. self::$inside_self_revert = true;
  354. try {
  355. $order->update_status(
  356. $from,
  357. __('Cannot complete: product line items still pending print. Resolve item statuses first.', 'studiou-wc-ord-print-statuses')
  358. );
  359. $order->save();
  360. } finally {
  361. self::$inside_self_revert = false;
  362. }
  363. UtilsLog::message(
  364. sprintf(
  365. /* translators: 1: order ID, 2: number of blocking items. */
  366. __('Order #%1$d completion blocked — %2$d product items not yet "Done Print" or "Skip Print".', 'studiou-wc-ord-print-statuses'),
  367. $order_id,
  368. count($check['blocking'])
  369. ),
  370. 'error'
  371. );
  372. UtilsLog::log('Order #' . $order_id . ' completion blocked: ' . count($check['blocking']) . ' item(s) not ready');
  373. }
  374. }
  375. /**
  376. * True when the current request is the HPOS Orders-list "Change status to
  377. * Completed" bulk action (`page=wc-orders` + the resolved bulk action is
  378. * `mark_completed`). Mirrors WP_List_Table::current_action() — the active
  379. * action is `action` unless it is the "-1" placeholder, in which case it is
  380. * `action2` (the bottom-of-table dropdown).
  381. *
  382. * Scopes the §7 guard-fallback to exactly the same entry point as
  383. * bulk_complete_advance_items(): a single-order edit-screen save posts
  384. * `action=edit` (not `mark_completed`), and REST/cron/WP-CLI are not
  385. * `is_admin()`, so none of them trip this.
  386. */
  387. private function is_bulk_complete_request() {
  388. if (!is_admin()) {
  389. return false;
  390. }
  391. if (!isset($_REQUEST['page']) || $_REQUEST['page'] !== 'wc-orders') {
  392. return false;
  393. }
  394. $action = '';
  395. if (isset($_REQUEST['action']) && $_REQUEST['action'] !== '-1') {
  396. $action = sanitize_text_field(wp_unslash($_REQUEST['action']));
  397. } elseif (isset($_REQUEST['action2']) && $_REQUEST['action2'] !== '-1') {
  398. $action = sanitize_text_field(wp_unslash($_REQUEST['action2']));
  399. }
  400. return $action === 'mark_completed';
  401. }
  402. /* ---------------------------------------------------------------- *
  403. * Bulk "Change status to Completed" pre-advance (HPOS Orders list)
  404. * ---------------------------------------------------------------- */
  405. /**
  406. * Proprietary behaviour for the HPOS Orders list "Change status to Completed"
  407. * bulk action ONLY: advance every still-pending/in-print product item to
  408. * done-print BEFORE WooCommerce flips the order, so the completion guard is
  409. * satisfied instead of reverting. Hooked to
  410. * `handle_bulk_actions-woocommerce_page_wc-orders` at priority 9 (before WC's
  411. * own priority-10 mark_completed handler). Returns the redirect unchanged so
  412. * WC still performs the status change.
  413. *
  414. * Orders already in a terminal status (completed/cancelled/refunded/failed) are
  415. * deliberately NOT advanced — see the class-level coupling notes and
  416. * docs/order-done-plan-01.md §6:
  417. * - already-completed: WC's flip is a no-op, so advancing would silently
  418. * mutate items (and add a misleading note) with no transition;
  419. * - cancelled/refunded/failed with unresolved items: leaving them unadvanced
  420. * lets the existing completion guard revert WC's flip, protecting them from
  421. * being completed by an over-broad selection — matching the protocol-import
  422. * skip-terminal philosophy.
  423. *
  424. * Each order is processed inside try/catch: a single failing order must not
  425. * abort the whole batch (which would also stop WC's priority-10 handler from
  426. * completing ANY order and leave earlier orders with advanced-but-uncompleted
  427. * items).
  428. *
  429. * @param string $redirect_to
  430. * @param string $action
  431. * @param int[] $ids
  432. * @return string The (unchanged) redirect URL.
  433. */
  434. public function bulk_complete_advance_items($redirect_to, $action, $ids) {
  435. if ($action !== 'mark_completed') {
  436. return $redirect_to;
  437. }
  438. if (!current_user_can('edit_shop_orders')) {
  439. return $redirect_to; // WC's handler enforces caps; we don't mutate without it.
  440. }
  441. $ids = array_filter(array_map('intval', (array) $ids));
  442. if (empty($ids)) {
  443. return $redirect_to;
  444. }
  445. $total_items = 0;
  446. $total_orders = 0;
  447. $failures = 0;
  448. foreach ($ids as $id) {
  449. try {
  450. $order = wc_get_order($id);
  451. if (!$order) {
  452. continue;
  453. }
  454. // Do not touch terminal/already-completed orders (see docblock).
  455. if ($order->has_status(Studiou_DB_Manager::TERMINAL_STATUSES)) {
  456. continue;
  457. }
  458. $advanced = $this->advance_remaining_to_done($order);
  459. if ($advanced > 0) {
  460. $order->add_order_note(sprintf(
  461. /* translators: %d is the number of line items advanced. */
  462. _n(
  463. '%d line item advanced to "Done Print" before bulk completion.',
  464. '%d line items advanced to "Done Print" before bulk completion.',
  465. $advanced,
  466. 'studiou-wc-ord-print-statuses'
  467. ),
  468. $advanced
  469. ));
  470. $total_items += $advanced;
  471. $total_orders++;
  472. }
  473. } catch (\Throwable $e) {
  474. $failures++;
  475. UtilsLog::log(sprintf(
  476. 'bulk_complete_advance_items: order #%d failed to pre-advance: %s',
  477. $id,
  478. $e->getMessage()
  479. ));
  480. }
  481. }
  482. if ($total_items > 0) {
  483. UtilsLog::message(
  484. sprintf(
  485. /* translators: 1: item count, 2: order count */
  486. __('Auto-advanced %1$d line item(s) to "Done Print" across %2$d order(s) before completion.', 'studiou-wc-ord-print-statuses'),
  487. $total_items,
  488. $total_orders
  489. ),
  490. 'success'
  491. );
  492. UtilsLog::log(sprintf('bulk mark_completed: advanced %d items across %d orders', $total_items, $total_orders));
  493. }
  494. if ($failures > 0) {
  495. UtilsLog::message(
  496. sprintf(
  497. /* translators: %d is the number of orders that errored during pre-advance. */
  498. _n(
  499. '%d order could not be pre-advanced and may not complete — check the debug log.',
  500. '%d orders could not be pre-advanced and may not complete — check the debug log.',
  501. $failures,
  502. 'studiou-wc-ord-print-statuses'
  503. ),
  504. $failures
  505. ),
  506. 'warning'
  507. );
  508. }
  509. return $redirect_to;
  510. }
  511. }