order-done-plan-01.md 26 KB

Implementation Plan — Auto "Done Print" on bulk "Change status to Completed"

File: docs/order-done-plan-01.md Target version: 1.5.1 Status: Draft / not yet implemented Supersedes: docs/order-done-plan-00.md (kept for history)


0. Changes from plan-00 (review folded in)

This revision incorporates the findings from the code-review of plan-00. Summary of what changed:

  1. Per-order failure isolation — each order is processed inside try/catch so one bad order can no longer fatal the request and strand the whole batch (was: an uncaught throwable in the loop aborts everything, including WooCommerce's own completion pass).
  2. Terminal / already-completed skip — the advance loop now skips any order already in Studiou_DB_Manager::TERMINAL_STATUSES (completed/cancelled/refunded/failed). This fixes the silent mutation of already-completed orders (whose items lazily default to pending-print) and resolves the terminal-order question (Q2) toward the plugin's established skip-terminal philosophy. See §6 for the precise resulting behaviour, including one honest residual edge case.
  3. Stronger hook-ordering / outcome test — test §9 #8 now asserts the end state (order completed, no "completion blocked" notice) rather than only log-line ordering, so a future WooCommerce regression in the load-bearing assumption is actually caught.
  4. Two-instance/cache dependency documented — §5 now states explicitly that the advance and the guard run on two different wc_get_order() instances and why that is expected to be coherent (and what would break it).
  5. i18n corrected — the sketch carries /* translators */ comments on every new string, and §8 states the real new-string count.
  6. Double order hydration acknowledged — §6 notes the two wc_get_order() loads per order across the two callbacks.

1. Goal

On the HPOS Orders list (wp-admin/admin.php?page=wc-orders), when the operator runs the built-in WooCommerce bulk action "Change status to Completed" (mark_completed, Czech "Změnit stav na Dokončeno"), the plugin must — before the order status is flipped — advance every still-unresolved product line-item print status to Done Print (done-print) for orders that are actually about to transition.

"Unresolved / unset" means any product item currently in pending-print or in-print. Items already in done-print or skip-print are left alone. This is exactly the semantics of the existing Order_Item_Status_Manager::advance_remaining_to_done() — we reuse it, we do not reimplement it.

Hard scope constraint

This auto-advance is proprietary to this one entry point only:

  • the mark_completed bulk action, and
  • the woocommerce_page_wc-orders (HPOS) screen.

It must not fire for:

  • single-order completion from the order-edit status dropdown,
  • programmatic / REST / cron status changes,
  • the InPrint or Delivered protocol imports (the Delivered import already does its own advance),
  • any other bulk action (Prepare to Printing, In Printing, export, trash, etc.),
  • the legacy CPT orders screen (edit-shop_order) — out of scope; operator uses HPOS.

Everywhere else, the existing completion guard behaviour is preserved: completing an order while items are still pending/in-print is blocked and reverted with an admin notice.


2. Why this is needed (current behaviour)

Today the only coupling on → wc-completed is the completion guard in Order_Item_Status_Manager::on_order_status_changed() (hooked to woocommerce_order_status_changed, priority 5). It does the opposite of what we want here: if any product item is still pending-print/in-print it reverts the order back to its previous status and shows an error notice.

So a bulk "mark completed" over orders whose items are still pending currently fails for those orders — WooCommerce sets them to completed, our guard immediately reverts them, and the operator sees N "completion blocked" notices. The operator's intent in this specific bulk action is the reverse: "I'm done — mark the items done and complete the orders."

The fix is to advance the items before the status change reaches the guard, so the guard sees a ready order and lets the completion stand.


3. How WooCommerce dispatches the bulk action

WooCommerce routes all orders-list bulk actions — including the core mark_* ones — through the WordPress filter:

handle_bulk_actions-woocommerce_page_wc-orders   ( $redirect_to, $action, $ids )

The plugin already proves this: Bulk_Actions_Manager registers its custom actions on this exact filter at priority 10 and they work. WooCommerce core's own handler for mark_* (which loops the selected IDs and calls $order->update_status( 'completed', ... )) is — on the WooCommerce versions in scope — another callback on the same filter, registered at priority 10.

Key lever: a callback registered on the same filter at a lower priority number runs first. So a callback at priority 9 runs before WooCommerce changes any status — the exact window we need to advance items.

(Priority is what orders callbacks, not registration time. Our managers register at plugins_loaded 10; WC registers its list-table handler later in the request during screen setup. Using priority 9 guarantees we still run first regardless of registration order.)

Load-bearing assumption. The whole feature depends on WC core's mark_completed handler being a priority-10 filter callback that priority 9 pre-empts. If a future WC version processes mark_* inline (before applying the filter) the priority-9 window disappears. This is why §9 #8 asserts the actual outcome on the installed WC, and why §7 documents a fallback.


4. Chosen approach (Approach A — pre-flip in the bulk filter)

Add one new callback on handle_bulk_actions-woocommerce_page_wc-orders at priority 9 that:

  1. returns $redirect_to untouched unless $action === 'mark_completed';
  2. bails (returns $redirect_to untouched) if the user lacks edit_shop_orders — let WC's own handler reject the request; we never mutate without the cap;
  3. sanitises the ID list (array_filter(array_map('intval', (array) $ids)));
  4. for each ID, inside a try/catch: wc_get_order($id), skip falsy, skip any order already in a terminal status (see §6), then call advance_remaining_to_done($order) (reused as-is);
  5. adds a per-order summary order note when count > 0 ("N line items advanced to Done Print before bulk completion.");
  6. accumulates totals and emits one UtilsLog::message(..., 'success') summary across the batch (e.g. "Auto-advanced 12 items to Done Print across 4 orders before completion."); if any order threw, emits one warning summarising the failure count;
  7. returns $redirect_to unchanged — it does not short-circuit. WooCommerce's priority-10 handler then runs the actual status change, and because every item on the advanced orders is now done-print/skip-print, the completion guard at priority 5 sees a ready order and lets it through silently.

No change is needed to the completion guard itself. The guard stays as the safety net for every other path — and, by design (§6), for the terminal orders we deliberately do not advance.

Where the code lives — recommendation

Put the new hook + handler in Order_Item_Status_Manager (not Bulk_Actions_Manager):

  • It already owns advance_remaining_to_done(), set_status(), get_status(), the completion guard, and the recursion-guard flag — all the moving parts of this feature are in one class, so the author reasons about the guard/advance interaction in one place.
  • It already hooks WooCommerce actions in its constructor, so adding one add_filter(...) line there is idiomatic.
  • It avoids wiring a new cross-manager dependency. (It does reference Studiou_DB_Manager::TERMINAL_STATUSES — a constant on an always-loaded class — which is acceptable: Studiou_DB_Manager itself already does new Order_Item_Status_Manager(), so the two classes are already mutually aware.)

Constructor addition:

add_filter(
    'handle_bulk_actions-woocommerce_page_wc-orders',
    array($this, 'bulk_complete_advance_items'),
    9,   // before WooCommerce's own mark_completed handler (priority 10)
    3
);

New method (sketch — final code to match house style):

/**
 * 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. Runs at priority 9 (before WC's 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).
 */
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;
}

(\Throwable is available on PHP 7.2+, the plugin's minimum.)

Alternative placement (if preferred)

If the team would rather keep all bulk-action hooks in Bulk_Actions_Manager, the identical logic can live there instead, with the Order_Item_Status_Manager instance injected via the constructor. The main plugin (initialize_managers()) currently builds Bulk_Actions_Manager (line ~89) before Order_Item_Status_Manager (line ~92), so this would also require reordering those two instantiations and passing the item-status manager into new Bulk_Actions_Manager($itemStatus). Functionally equivalent; more wiring. Recommendation stays with Order_Item_Status_Manager.


5. Interaction with the existing completion guard

Confirm during implementation:

  • The guard runs at woocommerce_order_status_changed priority 5; it fires after WC's bulk handler calls update_status('completed'), i.e. after our priority-9 pre-advance.
  • Sequence for one non-terminal order in the bulk action:
    1. our priority-9 filter advances items → done-print and saves each one ($item->save());
    2. WC's priority-10 filter calls $order->update_status('completed');
    3. that fires woocommerce_order_status_changed → guard (priority 5) → is_order_ready_for_completion() now returns ready → guard returns without reverting.
  • The $inside_self_revert recursion flag is not involved (no revert happens), so no conflict.

Two WC_Order instances — coherency note (review finding)

Unlike the Delivered-import path — which advances items and completes on the same $order object — Approach A inherently involves two different wc_get_order($id) instances: ours at priority 9 (which advances + saves the item meta) and WooCommerce's own at priority 10 (which calls update_status and whose object the guard inspects).

This is expected to be coherent because WC_Order_Item::save() writes _print_status to woocommerce_order_itemmeta and invalidates that item's meta entry in the WP object cache, so WC's fresh load at priority 10 reads the just-saved done-print value. The risk to watch: a persistent object cache / order cache layer that returns a stale item-meta view to the second instance would make the guard still see pending-print and revert — re-producing exactly the "completion blocked" notices this feature removes. The §9 #8 test asserts the real outcome precisely to exercise this on the live stack rather than trusting the invalidation in theory.

(If a future change ever makes this unreliable, the simplest hardening is to advance and complete in a single pass ourselves — but that is the §7 fallback's territory, not Approach A.)


6. Edge cases & decisions

  • Terminal-status orders selected (completed / cancelled / refunded / failed): We skip the advance for any order already in Studiou_DB_Manager::TERMINAL_STATUSES. Resulting behaviour:
    • already completed (incl. legacy orders created before 1.5.0, whose items lazily default to pending-print): we do not advance them. WC's update_status('completed') is a no-op (no transition), so nothing else happens either. This fixes the silent item-mutation + misleading note that an unconditional advance would cause on non-transitioning orders.
    • cancelled/refunded/failed with unresolved items: we do not advance them; WC core still calls update_status('completed'), but the existing completion guard then reverts the order to its prior status and shows its standard "completion blocked" notice. Net effect: the order stays terminal — protected from being completed by an over-broad selection, consistent with how the protocol imports skip TERMINAL_STATUSES.
    • Honest residual edge: a cancelled/refunded/failed order whose product items are already all done-print/skip-print will pass the guard and will be completed by WC core. The guard only blocks on unresolved items, never on the order's terminal status, and Approach A cannot remove IDs from WC's own pass. This matches existing 1.5.0 guard behaviour (it is not a regression introduced here) but is documented so nobody is surprised. If full terminal protection is required, it needs the §7 fallback or an ID-filtering approach, not Approach A. Tracked as Q2.
  • Empty selection: handled (returns early).
  • Per-order failure isolation: each order's processing is wrapped in try/catch. A single order that throws (e.g. a DB error during $item->save()) is logged and counted as a failure; the loop continues so the rest of the batch is still pre-advanced and WC's priority-10 pass still runs. Without this, an uncaught throwable would propagate out of the filter, fatal the request, leave already-processed orders with advanced-but-uncompleted items, and complete nothing.
  • Capability: mirror existing pattern (edit_shop_orders); bail without mutating if absent rather than wp_die() — WC's handler already enforces caps and we don't want to change the failure UX of the core action.
  • Non-product items (shipping/fee/coupon): advance_remaining_to_done() already skips anything that isn't a WC_Order_Item_Product. No change.
  • Performance: two cost notes, both acceptable and not optimised now:
    1. advance_remaining_to_done() saves each changed item individually ($item->save()), consistent with the existing import path — N item-saves for N pending items.
    2. Each selected order is hydrated twice per request: once by our priority-9 callback and once by WC's priority-10 callback. For very large selections this doubles order-object hydration. Acceptable; flagged for awareness.
  • Idempotency: re-running the bulk action over the same (non-terminal) orders is safe (already-done items are skipped, count 0, no note).

7. Fallback approach (only if Approach A proves unreliable)

If a future WooCommerce version stops routing mark_completed through handle_bulk_actions-woocommerce_page_wc-orders, or our priority-9 callback cannot be made to run first, or the two-instance cache coherency in §5 proves unreliable, fall back to context-aware guard relaxation:

  • In on_order_status_changed(), when $to === 'completed', detect the bulk-complete request context — is_admin() and the request carries the wc-orders bulk action, e.g. ($_REQUEST['page'] ?? '') === 'wc-orders' and the resolved bulk action ($_REQUEST['action'] / action2) is mark_completed.
  • In that context only, instead of reverting, call advance_remaining_to_done($order) and allow completion. Because this path runs inside the guard on the same $order object WC is completing, it also sidesteps the two-instance concern in §5.
  • Everywhere else, keep the current block-and-revert.

This is more coupled (inspects superglobals inside a domain listener) and is documented here only as a contingency. Verify Approach A on staging first (see §9); prefer it.


8. Files to change

  • includes/class-order-item-status-manager.php
    • add the add_filter('handle_bulk_actions-woocommerce_page_wc-orders', ..., 9, 3) in the constructor;
    • add bulk_complete_advance_items() method (with the terminal-skip guard and per-order try/catch).
  • studiou-wc-ord-print-statuses.php
    • bump Version: header and STUDIOU_WC_OPS_VERSION1.5.1.
  • README.md
    • extend §6 "Per-order-item print status" coupling notes with the new bulk-complete-on-wc-orders behaviour (be explicit that it is screen- and action-scoped, does NOT change single-order or programmatic completion, and skips terminal/completed orders per §6 of this plan);
    • add a ## 1.5.1 changelog entry.
  • CLAUDE.md
    • add a "Version 1.5.1" entry under Recent Changes;
    • extend the Order_Item_Status_Manager architecture bullet with the new bulk-complete pre-advance hook (priority 9 on handle_bulk_actions-woocommerce_page_wc-orders, scoped to mark_completed, terminal-skipping, per-order try/catch);
    • add a one-line pointer to this plan in the Documentation list: - [docs/order-done-plan-01.md](docs/order-done-plan-01.md) — Plan for auto "Done Print" on the HPOS bulk "Change status to Completed" action (1.5.1).
  • (No JS/CSS change → no asset cache-bust concern. Version bump still required by repo release convention.)

New translatable strings (correcting plan-00's undercount — three new source formats, two of them singular/plural pairs):

  1. per-order note — _n('%d line item advanced to "Done Print" before bulk completion.', …);
  2. batch success notice — __('Auto-advanced %1$d line item(s) to "Done Print" across %2$d order(s) before completion.', …);
  3. failure warning notice — _n('%d order could not be pre-advanced and may not complete — check the debug log.', …).

Add their English sources and refresh cs_CZ (docs/translations.txt, languages/*.po/*.mo) per the existing process. Each _n()/__() call carries a /* translators */ comment in the sketch — keep them in the final code (matches the existing _n() at on_order_status_changed()).


9. Test plan

Manual, on staging with HPOS enabled and WP_DEBUG/WP_DEBUG_LOG on:

  1. Happy path: create an order with several product items in mixed statuses (pending-print, in-print, one skip-print). On wp-admin/admin.php?page=wc-orders, select it, run Change status to Completed. Expect: order becomes completed; the pending/in items are now done-print; the skip-print item is untouched; one order note "N line items advanced to Done Print before bulk completion."; one success admin notice; no "completion blocked" notice.
  2. Multi-order batch: select several orders; verify per-order notes and a single batch summary notice with correct totals.
  3. Already-ready order: items all done-print/skip-print → completes with no advance note (count 0).
  4. Already-completed / legacy order (Finding 2): select an order already in completed whose items are still pending-print (e.g. completed before 1.5.0, lazy default). Expect: no item mutation, no advance note, no new transition — the order is left exactly as it was.
  5. Terminal-status order (Finding 4 / Q2): select a refunded (or cancelled/failed) order with pending items. Expect: it is not advanced and is not completed — WC's flip is reverted by the existing guard and the order stays refunded, with the standard "completion blocked" notice. (Also note the documented residual edge in §6: a terminal order whose items are already all resolved will be completed.)
  6. Scope — single order edit screen: open one order with pending items, set status to Completed via the order dropdown + Update. Expect the old behaviour: blocked + reverted + error notice (auto-advance must NOT apply here).
  7. Scope — other bulk actions: run "Set Status to In Printing" / "Prepare to Printing Export" — unchanged; no done-print advance.
  8. Scope — programmatic: trigger a completion via WP-CLI/REST (or a quick snippet) — guard still blocks pending orders; no auto-advance.
  9. Capability: as a role without edit_shop_orders, confirm no item mutation occurs.
  10. Hook-order + outcome verification (the load-bearing assumption, Findings 3 & 5): on the installed WC version, run the happy path (#1) and assert the end state: the order row shows Completed, the items are done-print, and no "completion blocked" notice appears. As supporting evidence, with WP_DEBUG_LOG log the item statuses at the top of bulk_complete_advance_items() and inside the completion guard and confirm the advance log line precedes the guard line for the same order in one request. (The end-state assertion — not just log ordering — is what proves priority 9 actually wins and that the guard's order instance observed the saved meta. Re-check after any WC upgrade.)

10. Open questions

  • Q1 (placement): keep the handler in Order_Item_Status_Manager (recommended) or move to Bulk_Actions_Manager with dependency injection (requires reordering manager instantiation)? Default: Order_Item_Status_Manager.
  • Q2 (terminal orders) — resolved toward skip: this plan now skips advancing terminal/completed orders (§6). For cancelled/refunded/failed with unresolved items the existing guard reverts WC's completion, so they stay terminal; the one residual edge (terminal order with already-resolved items still completes) is documented and would need the §7 fallback / ID-filtering to close. If the team instead wants the original "follow core, complete everything" behaviour, remove the TERMINAL_STATUSES skip — but that re-introduces the silent refunded→completed + item-advance risk the review flagged.
  • Q3 (legacy CPT screen): confirm the legacy edit-shop_order screen is genuinely out of scope (operator uses HPOS only). If it must be covered too, mirror the hook on handle_bulk_actions-edit-shop_order.