1
0

6 Commity e8a0088455 ... 29d11884f7

Autor SHA1 Správa Dátum
  Dalibor Votruba 29d11884f7 Release v1.5.1 — auto "Done Print" on bulk "Change status to Completed" 4 týždňov pred
  Dalibor Votruba 9a945f2738 add implementation plans 4 týždňov pred
  Dalibor Votruba d6f2d7ccbf Release v1.5.0 — per-order-item print status feature 2 mesiacov pred
  Dalibor Votruba 746676ccb0 Add implementation analysis for per-order-item print status (v1.5.0) 2 mesiacov pred
  Dalibor Votruba 54a62e7c7d Release v1.4.5 — resolve in-scope findings from revise-1.4.4 2 mesiacov pred
  Dalibor Votruba ae7e82fc06 Release v1.4.4 — resolve every in-scope finding from revise-1.4.3 2 mesiacov pred

+ 63 - 1
studiou-wc-ord-print-statuses/CLAUDE.md

@@ -8,7 +8,7 @@ This is a WordPress plugin that extends WooCommerce functionality to manage prin
 
 **Plugin Details:**
 - Text Domain: `studiou-wc-ord-print-statuses`
-- Current Version: 1.4.3
+- Current Version: 1.5.1
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
 - Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
 
@@ -24,11 +24,60 @@ User-facing and historical documentation lives at the project root and under `do
 - [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0. All in-scope findings are resolved in 1.4.1; the doc carries per-item status annotations.
 - [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Fresh-eye analytical review of v1.4.1. All in-scope findings are resolved in 1.4.2; per-item status annotations inside.
 - [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) — Fresh-eye analytical review of v1.4.2. All in-scope findings are resolved in 1.4.3; per-item status annotations inside.
+- [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md) — Fresh-eye analytical review of v1.4.3. All in-scope findings are resolved in 1.4.4; per-item status annotations inside.
+- [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md) — Fresh-eye analytical review of v1.4.4. Mediums and lows are addressed in 1.4.5; per-item status annotations inside.
+- [`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 (implemented in 1.5.1). Supersedes [`docs/order-done-plan-00.md`](docs/order-done-plan-00.md), kept for history.
 
 When adding new documentation files, place them in `docs/` and add a one-line pointer here.
 
 ## Recent Changes
 
+### Version 1.5.1 (2026-06-15)
+
+**New feature:** auto "Done Print" on the HPOS Orders-list **Change status to Completed** bulk action. See `README.md` §"Per-order-item print status" and `docs/order-done-plan-01.md`.
+
+- **New method:** `Order_Item_Status_Manager::bulk_complete_advance_items()`, hooked to `handle_bulk_actions-woocommerce_page_wc-orders` at **priority 9** (before WooCommerce's own priority-10 `mark_completed` handler on the same filter). For each selected non-terminal order it runs `advance_remaining_to_done()` — advancing every `pending-print`/`in-print` product item to `done-print` — *before* WC flips the status, so the completion guard passes instead of reverting.
+- **Scope:** fires only for the `mark_completed` action on the HPOS Orders screen. Single-order completion (order-edit dropdown), programmatic/REST/cron changes, the protocol imports, and every other bulk action are unchanged — the standard completion guard still applies. The legacy CPT `edit-shop_order` screen is intentionally not hooked.
+- **Terminal-skip:** orders already in `Studiou_DB_Manager::TERMINAL_STATUSES` (`completed`/`cancelled`/`refunded`/`failed`) are not advanced — already-completed orders are left untouched (no silent item mutation), and terminal orders with unresolved items stay blocked by the guard. Residual edge (unchanged from 1.5.0): a terminal order whose items are already all resolved is still completed by WC core, since the guard never blocks on terminal status.
+- **Resilience:** each order is processed in a `try/catch`; a failing order is logged and skipped so the batch (and WC's own priority-10 completion pass) is not aborted. A per-order activity note records the advance; one batch success notice summarises totals; a warning notice reports any per-order failures.
+- **Guard fallback (belt-and-suspenders):** `on_order_status_changed()` carries a `→ completed` fallback for the same bulk entry point, gated by `is_bulk_complete_request()` (`page=wc-orders` + resolved bulk action `mark_completed`). If the priority-9 pre-advance ever fails to run before WC's status flip, the guard advances remaining items on the same `$order` instance instead of reverting — priority-independent, and closes the two-instance object-cache concern. Orders whose *previous* status (`$from`) was terminal are still reverted, preserving the terminal-skip. No new translatable strings (reuses the bulk advance note).
+
+### Version 1.5.0 (2026-05-12)
+
+**New feature:** per-order-item print status. See `README.md` §"Per-order-item print status" and `docs/feature-order-item-status-analysis.md` for the full design rationale.
+
+- **New manager:** `Order_Item_Status_Manager` (`includes/class-order-item-status-manager.php`). Renders the per-item Print Status column on the order edit screen, persists POST changes, and runs the combined propagation + completion guard on `woocommerce_order_status_changed` at priority 5.
+- **Storage:** `_print_status` meta key on each `WC_Order_Item_Product`, in `{$wpdb->prefix}woocommerce_order_itemmeta`. Lazy default = `pending-print` (no migration needed).
+- **Coupling:** order → `wc-in-print` propagates `pending-print` items to `in-print`; order → `wc-completed` is gated by every product item being in `done-print` or `skip-print`. All other transitions leave items alone.
+- **`Studiou_DB_Manager::import_delivered_protocol()`** now calls `advance_remaining_to_done()` before the order-status flip so the completion guard is satisfied. Surfaces the total items advanced in the import summary.
+- **`Studiou_DB_Manager::get_orders_for_printing()`** adds `prod_print_status` column via `LEFT JOIN` on `woocommerce_order_itemmeta` filtered to `_print_status`, with `COALESCE(..., 'pending-print')` for the lazy default.
+- **CSV export** now includes the new column. Print-shop tooling that consumes the CSV needs to either consume or ignore `prod_print_status`.
+- **Bulk action behaviour change:** "Set Status to In Printing" now propagates items via the listener (no explicit code in Bulk_Actions_Manager). "Set Status to Prepare to Printing" remains order-only.
+
+### Version 1.4.5 (2026-05-12)
+
+Defensive-hardening release resolving the findings in `docs/revise-1.4.4.md`. No critical or high findings; this round closes mediums and lows.
+
+- **`normalise_csv_date()` fast path** now runs `checkdate()` + hour/minute/second bounds before accepting the regex match. Bogus inputs like `"2026-13-45T25:99"` no longer slip through to be stored verbatim.
+- **`emit_import_summary()`** adds an "info: no actionable rows" notice when both `$updated` and `$errors` are zero. Closes the silent-import path when every CSV row had an empty `order_no` after dedup.
+- **`csv_escape()`** trims leading whitespace before the first-char check. `" =CMD"`-style bypasses are defused.
+- **`parse_csv()`** rejects CSVs with normalised header collisions (e.g., `"Order No"` and `"order_no"` both → `order_no`). Avoids silent `array_combine` overwrite.
+- **`bulk_set_print_status` note dispatch** lifted to an explicit `array(slug => note)` map. Future-contributor trap closed.
+
+Deferred (low-impact): main plugin instance lifecycle, `woocommerce_missing_notice` mid-request edge case, theoretical MySQL collation mismatch on the slug join (now mentioned in README "Known limitations").
+
+### Version 1.4.4 (2026-05-12)
+
+Bug-fix release. Notable code changes resolving findings in `docs/revise-1.4.3.md`:
+
+- **`Studiou_DB_Manager::normalise_csv_date()`** rewritten — drops `strtotime` entirely. Fast path: regex reformat for ISO-like inputs (no timezone shift). Fallback: `DateTimeImmutable($raw, wp_timezone())->setTimezone(wp_timezone())`. Eliminates the WP-UTC-default + wp_date timezone double-handling that caused a Prague-shifted 14:30 → 16:30 drift in 1.4.2/1.4.3. **Zero `strtotime` calls in `includes/` now** — the recurring bug pattern is structurally eliminated.
+- **HPOS table pre-check.** `get_orders_for_printing()` now bails with an admin notice when `{$wpdb->prefix}wc_orders` is missing (HPOS disabled), parallel to the existing check on `wc_order_product_lookup`.
+- **Bulk-action order notes.** `bulk_set_print_status` passes "Marked Prepare to Printing by bulk action." / "Marked In Printing by bulk action." as the `update_status` note. Audit trail now consistent across bulk and import paths.
+- **Column rendering perf.** `Custom_Columns_Manager` uses the WC_Order object directly under HPOS (no `wc_get_order` call) and memoises by ID per request under legacy CPT. Saves ~150 lookups per 50-orders page.
+- **Import summary suppression.** `Import_Manager::emit_import_summary()` skips the "%d orders updated successfully" notice when no rows changed.
+- **`call_user_func` → direct callable** in `run_import`.
+- **`before_woocommerce_init` callback** converted from anonymous to named (`studiou_wc_ord_print_statuses_declare_hpos_compat`) so it can be `remove_action`-ed.
+
 ### Version 1.4.3 (2026-05-12)
 
 Bug-fix release resolving the fresh-eye findings in `docs/revise-1.4.2.md`. Notable code changes:
@@ -170,6 +219,19 @@ The plugin uses a manager-based architecture where the main plugin class (`Studi
    - Contains complex SQL for export query joining orders, products, variations, categories, and images
    - Handles CSV parsing and protocol imports
 
+8. **Order_Item_Status_Manager** (`includes/class-order-item-status-manager.php`)
+   - New in 1.5.0. Owns the per-line-item print status (`_print_status` meta on `WC_Order_Item_Product`).
+   - Four status constants: `STATUS_PENDING` / `STATUS_IN` / `STATUS_DONE` / `STATUS_SKIP`.
+   - Renders the "Print Status" column on the order edit screen items table.
+   - Persists POST changes via `woocommerce_process_shop_order_meta`.
+   - Combined propagation + completion guard listener on `woocommerce_order_status_changed` (priority 5, with static recursion-guard flag).
+     - Order → `wc-in-print`: `pending-print` items advance to `in-print`.
+     - Order → `wc-completed`: gated; blocked if any product item is still `pending-print` or `in-print`.
+     - All other order transitions: no automatic item change.
+   - Exposes `advance_pending_to_in()` and `advance_remaining_to_done()` for protocol imports.
+   - `bulk_complete_advance_items()` (new in 1.5.1) — hooked to `handle_bulk_actions-woocommerce_page_wc-orders` at **priority 9**, before WooCommerce's own priority-10 `mark_completed` handler. Scoped to that one bulk action on the HPOS Orders screen: for each selected non-terminal order it pre-advances remaining items to `done-print` so the completion guard passes. Terminal/already-completed orders are skipped; each order runs in a `try/catch` so one failure doesn't abort the batch.
+   - `is_bulk_complete_request()` (new in 1.5.1) — detects the HPOS `mark_completed` bulk-action request (mirrors `WP_List_Table::current_action()`). Used by the completion guard's belt-and-suspenders fallback: if the priority-9 pre-advance fails to win the ordering race, the guard advances remaining items on the same `$order` it just completed instead of reverting (still reverting orders whose `$from` was terminal).
+
 ### Key Database Operations
 
 The export query in `Studiou_DB_Manager::get_orders_for_printing()` performs a complex join across:

+ 93 - 3
studiou-wc-ord-print-statuses/README.md

@@ -3,7 +3,7 @@
 WordPress plugin that extends WooCommerce with a dedicated workflow for managing print orders sent to third-party print providers. It adds custom order statuses, tracking fields, CSV export, protocol-based CSV imports, and search/filter capabilities tailored to a print fulfillment pipeline.
 
 - **Plugin slug / text domain:** `studiou-wc-ord-print-statuses`
-- **Current version:** 1.4.3
+- **Current version:** 1.5.1
 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
 - **License:** GPL v2 or later
 
@@ -74,7 +74,39 @@ Three columns are appended after **Total**:
 
 The standard WooCommerce **Search orders** input also matches the `external_ref_ord_no` meta value — no separate input is required. Works under both legacy CPT storage and HPOS (`woocommerce_shop_order_search_fields` and `woocommerce_order_table_search_query_meta_keys` filters).
 
-### 6. Protocol imports
+### 6. Per-order-item print status (1.5.0+)
+
+Each product line item on an order carries its own print status independently of the order's status:
+
+| Status         | Meaning                                                   |
+|----------------|------------------------------------------------------------|
+| `pending-print` | Initial. Waiting for the print provider.                  |
+| `in-print`      | Currently being printed.                                  |
+| `done-print`    | Printed.                                                   |
+| `skip-print`    | Skipped — won't be printed (operator decision, exclusion). |
+
+The status is editable per item from the **Items** table on the order edit screen via a `<select>` in the new **Print Status** column. Non-product line items (shipping, fees, coupons, refunds) don't carry a print status and show `—`.
+
+**Coupling rules between order status and item statuses** (one-way, order → items):
+
+| Order transition         | Effect on product items                                                                       |
+|---------------------------|-----------------------------------------------------------------------------------------------|
+| → `wc-in-print`           | `pending-print` items advance to `in-print`. Other statuses untouched.                        |
+| → `wc-completed`          | **Gated, not propagating.** Blocked if any product item is still `pending-print` or `in-print`. |
+| All other order transitions | No automatic item change.                                                                   |
+
+Item-level status changes never propagate back to the order. The operator drives the order status independently — the only constraint is the completion guard.
+
+**Bulk "Change status to Completed" (HPOS Orders list only, 1.5.1+):** when you run WooCommerce's own **Change status to Completed** bulk action on the Orders screen (`wp-admin/admin.php?page=wc-orders`), the plugin first advances every still-`pending-print`/`in-print` item on each selected order to `done-print`, *then* lets WooCommerce complete the order — so the completion guard is satisfied instead of blocking. This is scoped strictly to that one bulk action on that one screen: single-order completion from the order-edit dropdown, programmatic/REST changes, and every other bulk action keep the standard guard behaviour. Orders already in a terminal status (`completed`/`cancelled`/`refunded`/`failed`) are **not** advanced — already-completed orders are left untouched, and terminal orders with unresolved items are still blocked by the guard.
+
+Protocol imports interact with this feature:
+
+- **InPrint Protocol** sets the order to `wc-in-print`; items propagate automatically via the rule above (no special code in the importer).
+- **Delivered Protocol** advances every still-`pending-print`/`in-print` item to `done-print` *before* flipping the order to `wc-completed`, so the completion guard is satisfied. The summary notice tells the operator how many items were auto-advanced.
+
+The CSV export adds a `prod_print_status` column reflecting each line item's current status.
+
+### 7. Protocol imports
 
 A **WooCommerce → Import Protocols** submenu exposes two CSV upload forms:
 
@@ -96,9 +128,11 @@ Generated file: `prepare_to_printing_export.csv` (UTF-8 with BOM — opens corre
 Columns (in order):
 
 ```
-order_no, prod_cat, prod_name, prod_var, prod_var_type, prod_img_url, qty, email
+order_no, prod_cat, prod_name, prod_var, prod_var_type, prod_img_url, qty, prod_print_status, email
 ```
 
+`prod_print_status` (added in 1.5.0) carries the per-line-item print status — one of `pending-print`, `in-print`, `done-print`, or `skip-print`.
+
 - `prod_var_type` is sourced from the variation's `attribute_pa_format` postmeta, resolved against the `wp_terms` table.
 - `prod_cat` is a comma-separated list (deduplicated) when a product belongs to multiple `product_cat` terms — the row itself is not duplicated.
 - The file is generated **before** any status change. If the underlying SQL returns no rows the file is not produced and statuses are still advanced (per spec), with an admin notice surfacing the situation.
@@ -196,6 +230,8 @@ Reference documents live under [`docs/`](docs/):
 - [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0 (all in-scope findings addressed in 1.4.1).
 - [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Analytical review of v1.4.1 (all in-scope findings addressed in 1.4.2).
 - [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) — Fresh-eye analytical review of v1.4.2 (all in-scope findings addressed in 1.4.3).
+- [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md) — Fresh-eye analytical review of v1.4.3 (all in-scope findings addressed in 1.4.4).
+- [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md) — Fresh-eye analytical review of v1.4.4 (mediums + lows addressed in 1.4.5; carried-over deferrals documented).
 
 Project-internal guidance for AI-assisted development is documented in [`CLAUDE.md`](CLAUDE.md).
 
@@ -214,9 +250,63 @@ Project-internal guidance for AI-assisted development is documented in [`CLAUDE.
 - **MySQL `group_concat_max_len`** is raised to 64 KB via `SET SESSION` per export — connection-scoped, harmless but worth knowing if you maintain custom queries on the same connection.
 - **Excel CS-locale CSV separator** — the export uses `,` as the field separator. Czech Excel defaults to `;` as the list separator and may render the file as one column. Workaround: open with the Text Import wizard or set Windows regional list-separator to `,`. Other consumers (LibreOffice Calc, Numbers, programmatic CSV parsers) handle the comma natively.
 - **Trashed/draft products in historical orders** still appear in the export. Order line items reference product IDs; the export joins by ID without filtering on `post_status`. The intended behaviour: show what was sold, even if the product has since been removed from the catalogue.
+- **MySQL collation mismatch on the variation-attribute slug join** (`terms.slug = postmeta.meta_value`) is theoretically possible on databases that have been migrated across MySQL versions with mixed per-table collations. On standard `utf8mb4_unicode_ci` installs the join works fine. If `ERROR 1267 (Illegal mix of collations)` ever surfaces on the bulk export, the fix is to add an explicit `COLLATE` clause to the join condition.
 
 ## Changelog
 
+### 1.5.1 — 2026-06-15
+
+**New feature: auto "Done Print" on the bulk "Change status to Completed" action.** Scoped to WooCommerce's own *Change status to Completed* bulk action on the HPOS Orders list (`wp-admin/admin.php?page=wc-orders`) only.
+
+- **Added:** `Order_Item_Status_Manager::bulk_complete_advance_items()`, hooked to `handle_bulk_actions-woocommerce_page_wc-orders` at priority 9 (before WooCommerce's own priority-10 `mark_completed` handler). For each selected non-terminal order it advances every still-`pending-print`/`in-print` product item to `done-print` *before* the status flip, so the completion guard passes instead of reverting.
+- **Scope:** fires only for the `mark_completed` bulk action on the HPOS Orders screen. Single-order completion from the order-edit dropdown, programmatic/REST/cron changes, the protocol imports, and all other bulk actions are unchanged — the standard completion guard still applies.
+- **Terminal orders skipped:** orders already in `completed`/`cancelled`/`refunded`/`failed` are not advanced. Already-completed orders are left untouched (no silent item mutation); terminal orders with unresolved items remain blocked by the guard. Residual edge: a terminal order whose items are *already* all resolved is still completed by WooCommerce core (the guard never blocks on terminal status) — unchanged from 1.5.0.
+- **Resilience:** each order is processed inside `try/catch`, so one failing order is logged and skipped without aborting the batch or WooCommerce's own completion pass. A per-order activity note records the advance; a single batch admin notice summarises totals, and a warning notice reports any failures.
+- **Added (belt-and-suspenders):** a fallback inside the completion guard itself. If the priority-9 pre-advance ever fails to run before WooCommerce's status flip (e.g. a future WC version changes how it processes `mark_*`), the guard detects the same bulk-complete request context and advances the remaining items on the order it just completed — instead of reverting. It is priority-independent and runs on the same order object, and still reverts orders whose *previous* status was terminal, so the terminal-skip behaviour is preserved. Reuses the existing advance note (no new strings).
+- **Doc:** implementation plan at [`docs/order-done-plan-01.md`](docs/order-done-plan-01.md) (§7 documents the fallback).
+
+### 1.5.0 — 2026-05-12
+
+**New feature: per-order-item print status.** Each product line item now carries its own print status independent of the order's status, with explicit coupling rules between the two.
+
+- **Added:** four item-level statuses — `pending-print` (initial), `in-print`, `done-print`, `skip-print`. Stored as `_print_status` meta on each `WC_Order_Item_Product`. Missing meta defaults to `pending-print` (lazy — no migration needed for existing orders).
+- **Added:** "Print Status" column in the order edit screen's Items table. Per-row `<select>` lets the operator change item status manually. Non-product items show `—`.
+- **Added:** Order → item propagation on `wc-in-print` transitions. The order being flipped to "In Printing" (via manual admin change, bulk action, or InPrint Protocol import) advances all `pending-print` items to `in-print`. `done-print` and `skip-print` items are untouched.
+- **Added:** Completion guard on `wc-completed` transitions. The order cannot complete while any product line item is still `pending-print` or `in-print`. Blocked transitions revert with an admin notice listing the count of unready items. Existing already-completed orders are unaffected.
+- **Added:** Order activity notes for every item-status change ("Item #5 (Product A): Pending Print → Done Print.") and for batch auto-advances ("3 line items advanced to In Print by order-status change.").
+- **Changed:** `Studiou_DB_Manager::import_delivered_protocol()` now calls `advance_remaining_to_done()` before flipping the order to `wc-completed`. Without this the completion guard would block every Delivered Protocol import. The import summary surfaces the total items advanced.
+- **Changed:** Export CSV adds `prod_print_status` column. Existing print-shop tooling that imports the CSV needs to add (or ignore) this column.
+- **Changed:** Bulk action "Set Status to In Printing" now also propagates items (via the new listener — no Bulk_Actions_Manager code change). Bulk action "Set Status to Prepare to Printing" remains order-only.
+- **Doc:** new manager class added to the manager-pattern in CLAUDE.md; planning doc at [`docs/feature-order-item-status-analysis.md`](docs/feature-order-item-status-analysis.md).
+
+> **Known limitations carried into 1.5.0:** the new column renders on the classic order edit screen only — WC 10.x's block-based order edit screen (still feature-flagged) is not supported. Tracked as a phase-2 follow-up.
+
+### 1.4.5 — 2026-05-12
+
+Defensive-hardening release addressing the medium and low findings in [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md):
+
+- **Fixed:** `Studiou_DB_Manager::normalise_csv_date()` fast path now validates date and time ranges with `checkdate()` + hour/minute/second bounds. Structurally-valid but semantically-bogus inputs like `"2026-13-45T25:99"` fall through to the `DateTimeImmutable` fallback, which throws → caught → empty string returned. Mirrors the validation pattern already in `Order_Fields_Manager::datetime_local_to_mysql`.
+- **Fixed:** `Import_Manager::emit_import_summary()` now surfaces an info notice ("The CSV file contained no actionable rows (empty order_no column).") when both `$updated` and `$errors` are zero — closes the silent-import case where every CSV row had an empty `order_no` column.
+- **Fixed:** `Studiou_DB_Manager::csv_escape()` now `ltrim`s before checking the first character. Cells like `" =CMD"` — which some spreadsheet apps strip and evaluate — are no longer slipping past the formula-injection defence.
+- **Fixed:** `Studiou_DB_Manager::parse_csv()` rejects CSVs whose normalised headers collide (e.g., two columns both ending up as `order_no` after whitespace/case normalisation). Previously `array_combine` silently overwrote, losing one column's data. Now logs and returns empty so the downstream import bails with the existing "could not be parsed" notice.
+- **Changed:** `bulk_set_print_status` order-note selection lifted from a binary ternary to an explicit `array(slug => note)` map. Adding a third status in the future no longer risks inheriting the wrong note via the else branch.
+- **Doc:** [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md) updated with per-item status annotations. README "Known limitations" gains an entry for the theoretical MySQL collation mismatch on the slug join (§3.4 of the review).
+
+> **Deferred** (low-impact, carried over): main plugin instance lifecycle (architectural, no functional impact), `woocommerce_missing_notice` mid-request re-fire (edge case), collation mismatch (theoretical, no production occurrence).
+
+### 1.4.4 — 2026-05-12
+
+Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md):
+
+- **Fixed (high):** `Studiou_DB_Manager::normalise_csv_date()` was shifting timezone-less CSV dates by the WP timezone offset. WordPress sets the PHP timezone to UTC very early, so `strtotime("2026-05-12 14:30")` returned a UTC timestamp; `wp_date()` then formatted it in WP-local, producing a Prague-shifted "2026-05-12 16:30:00". Now uses pure-regex reformat for ISO-like inputs (no shift) and `DateTimeImmutable(..., wp_timezone())->setTimezone(wp_timezone())` for the fallback path (handles explicit-timezone inputs correctly). **No remaining `strtotime` calls in `includes/`** — the recurring timezone-bug pattern is now structurally eliminated.
+- **Fixed:** `Studiou_DB_Manager::get_orders_for_printing()` now pre-checks the HPOS `wc_orders` table parallel to the existing `wc_order_product_lookup` check. If HPOS is disabled the export bails with a clear admin notice ("Export requires WooCommerce HPOS … Enable High-Performance Order Storage …") instead of failing with a cryptic SQL error.
+- **Fixed:** `bulk_set_print_status` now passes an explanatory note to `update_status` — "Marked Prepare to Printing by bulk action." / "Marked In Printing by bulk action." — matching the audit-trail pattern the import paths use.
+- **Changed:** `Custom_Columns_Manager` uses the `WC_Order` object directly when WC hands one to the column-content callback (HPOS path); under legacy CPT it memoises by ID per request so the same order isn't re-resolved across the three custom columns. Avoids 100–150 `wc_get_order` calls per 50-orders-per-page list.
+- **Changed:** `Import_Manager::emit_import_summary()` suppresses the "0 orders updated successfully" notice when no rows changed — the per-error notice stands on its own. Matches the `notify_moved_count` pattern.
+- **Changed:** `call_user_func($cfg['handler'], $csv_data)` → direct callable invocation. Tighter and slightly faster.
+- **Changed:** The `before_woocommerce_init` HPOS-compat declaration is now a named function (`studiou_wc_ord_print_statuses_declare_hpos_compat`). Can be `remove_action`-ed from outside.
+- **Doc:** [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md) updated with per-item status annotations.
+
 ### 1.4.3 — 2026-05-12
 
 Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md):

+ 397 - 0
studiou-wc-ord-print-statuses/docs/feature-order-item-status-analysis.md

@@ -0,0 +1,397 @@
+# Feature Analysis — Per-Order-Item Print Status
+
+**Date:** 2026-05-12 (revised after dropping the "independent statuses" requirement)
+**Target plugin version after implementation:** 1.5.0 (minor bump — new feature, additive, non-breaking).
+**Status:** Analysis / planning document. No code has been written yet.
+
+---
+
+## 1. Requirements as received
+
+> 1. Add new status for order item called `order-item-status`:
+>    - `pending-print` — order item is waiting for printing process — *initial*
+>    - `in-print` — order item is currently processing for print
+>    - `done-print` — order item was printed
+>    - `skip-print` — order item is skipped by some custom / system reason
+> 2. Add to order detail to order item possibility (combo box) to change order-item-status manually.
+> 3. When Order status is going to change to `done`, all order items must be `!= pending-print` and `!= in-print`.
+
+> *(Previous requirement #3 — "Order status and order-item-status are independent" — was withdrawn by the stakeholder; this analysis assumes order ↔ item coupling is allowed and beneficial. See §3 for the proposed coupling rules.)*
+
+---
+
+## 2. Logical revisions
+
+### 2.1 "When order status is going to change to `done`" — clarify what "done" means
+
+WooCommerce has no `wc-done` status. The plugin already adds `wc-to-print` and `wc-in-print`. The natural interpretation is the WC built-in `wc-completed`.
+
+**Proposed revision:** *"When the order status is going to change to `wc-completed`"*.
+
+### 2.2 What about non-product line items?
+
+Orders contain product items but also fee items, shipping items, coupon items, tax items, and refund items. None of these are printed.
+
+**Proposed revision:** the completion guard inspects only **product line items** (`WC_Order_Item_Product`). Other item types are not assigned a print status and never block completion.
+
+### 2.3 "Initial" value of `pending-print` — when is it set?
+
+The spec implies new items default to `pending-print`. Two strategies:
+
+- **Lazy (recommended):** the per-item meta is not written when the order is created. The `get_status()` helper returns `'pending-print'` when the meta is missing. Migration of existing orders is unnecessary.
+- **Eager:** write `pending-print` to every new item via `woocommerce_new_order_item`. Existing orders need a one-time backfill migration.
+
+**Proposed revision:** lazy default. Missing meta = `pending-print`. This avoids touching every existing order at activation time.
+
+---
+
+## 3. Coupling rules between order status and item statuses
+
+With the "independence" constraint dropped, the design space opens up. The cleanest model is **one-way coupling, order → items**: changing an order status can propagate to items; changing an item status never propagates back to the order. Within that, we need to decide *which* order-status transitions propagate and how.
+
+The proposed rules:
+
+| Order status transition | Effect on product items |
+|--------------------------|-------------------------|
+| → `wc-to-print` (Prepare to Printing) | No automatic change. Items typically already `pending-print` (lazy default). |
+| → `wc-in-print` (In Printing) | **Items in `pending-print` advance to `in-print`.** `done-print` / `skip-print` items are untouched. |
+| → `wc-completed` | **Gated, not propagating** — the transition is *blocked* if any product item is still `pending-print` or `in-print`. Operator (or protocol import) must advance items first. |
+| → `wc-cancelled` / `wc-refunded` / `wc-failed` | No automatic change. Items stay where they are (historical record preserved). |
+| → `wc-processing` / `wc-pending` / `wc-on-hold` | No automatic change. |
+| Item status changed manually | Never propagates to order. The operator drives the order status separately. |
+
+Two distinct kinds of coupling are at play:
+
+- **Propagation** (`→ in-print`): the order transition *drives* item state. The listener that handles `woocommerce_order_status_changed` runs the propagation.
+- **Guard** (`→ completed`): the order transition *requires* items to already be in the right state. If not, the transition is reverted. The same listener implements the guard.
+
+Both behaviours live in a single hook on `woocommerce_order_status_changed`. The protocol imports become natural consumers of these rules:
+
+- **InPrint Protocol** calls `$order->update_status('in-print', ...)`. The propagation rule advances pending items to `in-print` automatically. No special-case code in the protocol importer.
+- **Delivered Protocol** calls `$order->update_status('completed', ...)`. The guard would block this if items are still `pending-print` or `in-print`. The protocol importer must therefore call `advance_remaining_to_done($order)` *before* the status flip.
+
+---
+
+## 4. Open questions for stakeholder decision
+
+Before implementation begins, the following decisions should be confirmed:
+
+1. **Propagation on `→ in-print`** (§3): confirm "advance `pending-print` items to `in-print`" is the desired behaviour for manual order-status changes from the admin dropdown, bulk actions, *and* the InPrint Protocol import. Alternative: leave items untouched, require explicit per-item operations.
+2. **Audit trail:** should item-status changes write an entry to the order's activity feed? Recommendation: yes, with a brief note like *"Item #5 (Product Name): Pending Print → Done Print."*
+3. **`skip-print` reason:** is there a free-text "reason" field, or just the status? Recommendation: status only in v1.5.0; reason field as a future enhancement.
+4. **Bulk per-item operations across orders:** is there a need? Recommendation: not in v1.5.0 — single-order manual control plus protocol/propagation auto-advance covers the workflow.
+5. **CSV-export filter:** should "Prepare to Printing Export" filter to only `pending-print` items, or include everything? Recommendation: include everything, add `prod_print_status` column. Filtering can happen downstream.
+6. **Backwards-compatible behaviour for existing completed orders:** the completion guard applies only to *new* transitions to `wc-completed`. Already-completed orders are unaffected even if their items are `pending-print` (lazy default). Confirm this is acceptable.
+
+---
+
+## 5. Proposed final specification
+
+After applying §2's revisions and §3's coupling rules:
+
+1. Each order item (specifically each `WC_Order_Item_Product`) has a print status with one of four values, persisted to per-item meta key `_print_status`. Missing meta defaults to `pending-print`.
+2. The order detail screen (classic order edit) adds a **"Print Status"** column to the line-items table with a `<select>` per product row. Saving the order persists changes.
+3. **Order → item propagation:**
+   - Transition into `wc-in-print` advances every `pending-print` item to `in-print`. `done-print` and `skip-print` items are untouched.
+   - All other order-status transitions leave item statuses unchanged.
+4. **Completion guard:** transition into `wc-completed` is blocked if any product item is `pending-print` or `in-print`. Blocked transition reverts to the previous status with an admin notice listing the blocking items.
+5. **Protocol imports:**
+   - **InPrint Protocol** sets order to `wc-in-print`; items auto-propagate per rule (3a). No explicit per-item code in the importer.
+   - **Delivered Protocol** explicitly advances every `pending-print` / `in-print` item to `done-print` before setting the order to `wc-completed`. The guard is satisfied; the operator gets one summary notice.
+6. **Item-status manual changes** never propagate to the order. Operator drives the order status independently.
+7. The CSV export from "Prepare to Printing Export" gains a `prod_print_status` column reflecting each item's current status.
+8. Item-status changes (manual or propagated) generate an order note: *"Item #ID (Product name): Old Status → New Status."*
+
+---
+
+## 6. Data model
+
+### 6.1 Storage
+
+- **Meta key:** `_print_status` (underscore prefix per WC convention for internal meta).
+- **Storage layer:** `WC_Order_Item::update_meta_data()` / `get_meta()`.
+- **Underlying table:** `{$wpdb->prefix}woocommerce_order_itemmeta`. This table exists under both HPOS and legacy CPT — WC uses the same item-meta storage in both modes.
+- **No database migration required** (lazy default).
+
+### 6.2 Constants
+
+```php
+class Order_Item_Status_Manager {
+    const STATUS_PENDING = 'pending-print';
+    const STATUS_IN      = 'in-print';
+    const STATUS_DONE    = 'done-print';
+    const STATUS_SKIP    = 'skip-print';
+
+    const STATUSES_THAT_SATISFY_COMPLETION = array(
+        self::STATUS_DONE,
+        self::STATUS_SKIP,
+    );
+
+    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'),
+        );
+    }
+}
+```
+
+### 6.3 API surface
+
+A new `Order_Item_Status_Manager` class exposes:
+
+| Method | Purpose |
+|--------|---------|
+| `get_status(WC_Order_Item_Product $item): string` | Returns the item's print status; defaults to `STATUS_PENDING` when the meta is missing. |
+| `set_status(WC_Order_Item_Product $item, string $status, bool $audit = true): bool` | Validates the status, writes meta, optionally appends an order note. |
+| `valid_statuses(): array` | Returns the four constants. |
+| `is_order_ready_for_completion(WC_Order $order): array{ready:bool, blocking:WC_Order_Item_Product[]}` | Used by the completion guard and admin UI. |
+| `advance_pending_to_in(WC_Order $order): int` | Used by the propagation listener on `→ in-print`. Returns count advanced. |
+| `advance_remaining_to_done(WC_Order $order): int` | Used by `Delivered Protocol` import before completing. Returns count advanced. |
+
+---
+
+## 7. Files & hooks
+
+### 7.1 New file: `includes/class-order-item-status-manager.php`
+
+Hooks registered in constructor:
+
+| Hook | Purpose |
+|------|---------|
+| `woocommerce_admin_order_item_headers` | Inject the "Print Status" column header. |
+| `woocommerce_admin_order_item_values` | Render the `<select>` per line item. |
+| `woocommerce_process_shop_order_meta` | Read `$_POST['print_status'][$item_id]`, persist via `set_status()`. Runs after WC's own item processing. |
+| `woocommerce_order_status_changed` (priority 5) | Combined propagation + completion guard (see §8). |
+
+### 7.2 Modified: `includes/class-db-manager.php`
+
+- `import_inprint_protocol()`: no functional change — propagation happens automatically via the listener when `$order->update_status('in-print', ...)` fires. Optionally surface the advanced count in the admin notice: *"3 line items advanced to In Print."*
+- `import_delivered_protocol()`: **before** calling `$order->update_status('completed', ...)`, call `Order_Item_Status_Manager::advance_remaining_to_done($order)` so the guard is satisfied. Surface the advanced count in the import-summary notice.
+- `get_orders_for_printing()`: extend the SELECT to surface item-level print status. Join `{$wpdb->prefix}woocommerce_order_items` (the item row) and `{$wpdb->prefix}woocommerce_order_itemmeta` (the `_print_status` meta) on `order_item_id`. Add `prod_print_status` to the CSV output. Note: the existing query groups by `(order, product, variation)` — multiple line items in one group would need a deterministic pick (e.g., `MIN()` again, or join via `order_products.order_item_id` if the lookup table exposes it).
+
+### 7.3 Modified: `studiou-wc-ord-print-statuses.php`
+
+Add the new manager to `load_dependencies()` and `initialize_managers()`, identical to the existing pattern. Bump `STUDIOU_WC_OPS_VERSION` to `1.5.0`.
+
+### 7.4 Modified: `includes/class-bulk-actions-manager.php`
+
+No code change. Bulk actions `"Set Status to Prepare to Printing"` and `"Set Status to In Printing"` already call `$order->update_status(...)` — the propagation listener handles items automatically when the transition is to `in-print`. The "Prepare to Printing" path doesn't propagate (per §3).
+
+### 7.5 Modified: `README.md`, `CLAUDE.md`, `docs/translations.txt`, `languages/studiou-wc-ord-print-statuses-cs_CZ.po`
+
+- README: new "Order item statuses" feature section + 1.5.0 changelog entry + coupling rules table from §3.
+- CLAUDE.md: extend the manager-pattern list with the new manager + Recent Changes entry.
+- Translations: add the four status labels, "Print Status" column header, guard error message, audit-note format, and the protocol auto-advance summary strings.
+
+---
+
+## 8. Combined propagation + completion guard
+
+A single listener on `woocommerce_order_status_changed` handles both behaviours. Pseudocode:
+
+```php
+public function on_order_status_changed($order_id, $from, $to, $order) {
+    // Re-entrancy guard: a guard-revert below fires this hook again. Skip
+    // the recursive invocation.
+    if (self::$inside_self_revert) {
+        return;
+    }
+
+    if ($to === 'in-print') {
+        $advanced = $this->advance_pending_to_in($order);
+        if ($advanced > 0) {
+            UtilsLog::log("Order #{$order_id}: advanced {$advanced} item(s) to in-print on order → in-print");
+        }
+        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')
+            );
+        } finally {
+            self::$inside_self_revert = false;
+        }
+        UtilsLog::message(
+            sprintf(
+                __('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'
+        );
+    }
+}
+```
+
+**Caveats:**
+
+1. `woocommerce_order_status_changed` fires *after* the change is committed. Reverting via `update_status` is post-hoc — the order shows the reverted state on the next page load. Slightly disorienting but workable, and the admin notice explains it.
+2. The static `$inside_self_revert` flag prevents infinite recursion when the revert itself fires the hook.
+3. For cleaner UX on the admin form path, a complementary interception in `woocommerce_admin_process_order_object` can validate **before** save. The post-hoc revert still covers programmatic / API / protocol paths. Belt-and-braces.
+
+---
+
+## 9. UI mockup (text)
+
+In the order edit screen's Items meta box, the table grows by one column:
+
+```
+| Item                  | Cost   | Qty | Total  | Print Status         |
+|-----------------------|--------|-----|--------|----------------------|
+| Product A (variation) | 100.00 |  2  | 200.00 | [Pending Print  ▼]   |
+| Product B (variation) |  50.00 |  1  |  50.00 | [Done Print     ▼]   |
+| Shipping              |      — |  —  |  10.00 | —                    |
+| Coupon: SUMMER10      | -20.00 |  —  | -20.00 | —                    |
+```
+
+Non-product items show `—` and aren't editable.
+
+Optional v1.5.0 sweetener: a one-line summary above the table — *"Print readiness: 3 of 4 items ready (1 pending)."*
+
+---
+
+## 10. Edge cases & open behaviour decisions
+
+| Scenario | Proposed behaviour |
+|----------|---------------------|
+| Order has 0 product items | Completion allowed (vacuously satisfies the guard). |
+| Order has only refunds / shipping / fees | Completion allowed. |
+| Existing `wc-completed` order with items in `pending-print` | No effect — the guard only fires on the *transition* into `completed`, not on stored state. |
+| Manual order status flip Pending → Completed (skipping in-print) | Guard fires. Items are still `pending-print` → blocked. Operator must advance items or use protocol imports. |
+| Order in `wc-in-print` reverted to `wc-processing` then back to `wc-in-print` | First revert: no item change (per §3). Second forward: pending items advance again (no-op since they're already `in-print`). Done items stay done. |
+| Item is removed from order after being set to `done-print` | The meta is deleted along with the item. No orphan record. |
+| Item status changed *after* order is `completed` | Allowed. Order doesn't revert. |
+| Bulk action "Set Status to In Printing" on 100 orders | Each order's pending items propagate to `in-print` via the listener. |
+| Multiple `wc-in-print` ↔ `wc-to-print` ping-pong | Each → `in-print` advances any remaining `pending-print` items. Items previously moved stay in `in-print`. |
+
+---
+
+## 11. Test plan
+
+### 11.1 Unit-style scenarios
+
+1. **Default value:** new order item has no `_print_status` meta; `get_status()` returns `'pending-print'`.
+2. **Set status:** `set_status($item, 'in-print')` writes the meta and (with `$audit=true`) appends an order note.
+3. **Invalid status:** `set_status($item, 'foo')` rejects and returns `false` without writing.
+4. **Completion guard ready:** all items in `done-print` or `skip-print` — `is_order_ready_for_completion()` returns `{ready:true}`.
+5. **Completion guard blocked:** at least one item in `pending-print` or `in-print` — returns `{ready:false, blocking:[…]}`.
+6. **Non-product items ignored:** an order with one product item (`done-print`) and one shipping item — guard returns ready.
+7. **`advance_pending_to_in`:** order with items `[pending, in, done, skip]` → result has items `[in, in, done, skip]`. Count = 1.
+8. **`advance_remaining_to_done`:** order with items `[pending, in, done, skip]` → result `[done, done, done, skip]`. Count = 2.
+
+### 11.2 Integration
+
+1. **Admin UI manual change:** open an order with three items, change one to `done-print`, click Update, reload → meta persisted, audit note added.
+2. **Propagation:** change order status to `wc-in-print` via the admin dropdown → all `pending-print` items in the order are now `in-print`. Order note from `update_status` plus per-item audit notes.
+3. **Completion guard via admin:** with one item still `pending-print`, change order status to `wc-completed`, click Update → order reverts to previous status (visible after reload), admin notice shows the block reason.
+4. **Completion guard via programmatic call:** `wp wc shop_order update <id> --status=completed` on an order with `pending-print` items → status reverts.
+5. **InPrint Protocol import:** prepare a CSV with one `order_no`, run the import → order is in `in-print`, all the order's `pending-print` items advanced to `in-print` via the listener, `done-print` and `skip-print` items untouched. Import notice mentions advanced count.
+6. **Delivered Protocol import:** prepare a CSV with one `order_no`, run the import → items advance to `done-print` first, then order flips to `completed`. Guard is satisfied. Import notice mentions advanced count.
+7. **CSV export:** "Prepare to Printing Export" bulk action — output contains a `prod_print_status` column with per-item values.
+
+### 11.3 Regression
+
+1. The "Set Status to Prepare to Printing" bulk action still works without touching items (no propagation on `→ to-print`).
+2. The "Set Status to In Printing" bulk action now also propagates items (new behaviour — expected).
+3. Existing completed orders are unaffected by the new guard.
+4. The Print Information panel on the order edit screen still renders.
+5. The custom orders-list columns still render.
+6. The order search by external reference still works.
+
+---
+
+## 12. Rollout
+
+### 12.1 Versioning
+
+- New plugin version: **1.5.0** (minor — feature addition, no breaking changes).
+- `STUDIOU_WC_OPS_VERSION` constant updated accordingly.
+
+### 12.2 Activation behaviour
+
+- No database migration. Lazy defaults handle existing items.
+- No new tables. The `woocommerce_order_itemmeta` table is already created by WC.
+
+### 12.3 Deactivation behaviour
+
+- Same policy as today: meta keys are not cleaned up. Reactivation preserves all data.
+
+### 12.4 Internationalisation
+
+~12 new translatable strings:
+
+- 4 status labels: `Pending Print`, `In Print`, `Done Print`, `Skip Print`.
+- 1 column header: `Print Status`.
+- 1 placeholder for non-product items: `—` (literal, not translated).
+- 1 completion-guard order note: `Cannot complete: product line items still pending print. Resolve item statuses first.`
+- 1 admin notice: `Order #%1$d completion blocked — %2$d product items not yet "Done Print" or "Skip Print".`
+- 1 audit-note format: `Item #%1$d (%2$s): %3$s → %4$s.`
+- 2 protocol-auto-advance order notes: `%d items advanced to In Print by order-status change.`, `%d items advanced to Done Print by Delivered Protocol import.`
+- 1 readiness summary (optional UI sweetener): `Print readiness: %1$d of %2$d items ready (%3$d pending).`
+
+### 12.5 Documentation
+
+- README: new "Order item statuses" feature section + coupling rules table + 1.5.0 changelog entry + Known Limitations updated (block-based screen still unsupported).
+- CLAUDE.md: new manager added to the manager-pattern list + Recent Changes entry.
+- New review doc (post-release): `docs/revise-1.5.0.md` once the feature lands.
+- `docs/feature-order-item-status-analysis.md` (this document) → archive or supersede after implementation.
+
+### 12.6 Phasing (suggested)
+
+- **Phase 1 (v1.5.0):** core feature — data model, admin UI, propagation listener, completion guard, Delivered Protocol explicit advance, CSV export column. Classic order edit screen only.
+- **Phase 2 (v1.5.x or later):** AJAX live status changes (no full page reload), bulk-change items across orders, optional `skip-print` reason field, block-based order edit screen support.
+
+---
+
+## 13. Effort estimate
+
+Rough sizing for Phase 1:
+
+| Area | Effort |
+|------|--------|
+| `Order_Item_Status_Manager` class (data model + admin UI + combined propagation/guard listener) | ~6–10 hours |
+| `Studiou_DB_Manager` integration (Delivered Protocol advance, CSV column with new join) | ~2–3 hours |
+| Bootstrap + manager wiring + version bump | ~30 minutes |
+| README / CLAUDE.md / translations / .po | ~2 hours |
+| Test pass (manual scenarios in §11) | ~3–4 hours |
+| **Total** | **~14–20 hours** |
+
+Phase 2 items add ~6–10 hours each depending on scope.
+
+---
+
+## 14. Risk register
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|------------|--------|------------|
+| Listener recursion (propagation → save → propagation, or guard-revert → revert → revert) | Medium | High | Static `$inside_self_revert` flag; propagation only fires on `→ in-print` not on every save. |
+| Existing orders break on first edit because meta is missing | Low | Low | Lazy default handles this — `get_status` returns `pending-print` for missing meta. |
+| Operator surprise: clicking "Set Status to In Printing" silently advances items | Low | Low | Documented in README; per-item audit notes show what happened. |
+| Block-based order edit screen doesn't render the new column | Medium | Low | Documented as a known limitation since 1.4.1; carried forward. |
+| `_print_status` meta key collides with another plugin | Very low | Medium | If collision found, fall back to a namespaced key (`_studiou_print_status`). |
+| Delivered Protocol "advance to done" advances items the operator intended to leave in `skip-print` | None | — | `skip-print` already satisfies the completion guard; the advance helper only touches `pending-print` / `in-print`. |
+
+---
+
+## 15. Recommendation
+
+Implement Phase 1 as described above, subject to stakeholder confirmation of the §4 open questions — particularly:
+
+- Open question 1 (propagation on `→ in-print`). The proposed design assumes "yes, advance pending items". This is the workflow-consistent choice, but it does change the behaviour of the existing **bulk action "Set Status to In Printing"** (previously order-only, now also touches items). If the stakeholder prefers the bulk action to remain strictly order-only, the propagation rule can be limited to specific paths (e.g., only the InPrint Protocol import) — but that re-introduces some of the awkwardness the old §2.5 grappled with.
+
+The shape of the design with requirement #3 dropped is materially cleaner than the original analysis: a single listener implements both behaviours, the protocol-import code paths are minimally changed, and the rules can be summarised in one table (§3).
+
+---
+
+*End of analysis.*

+ 332 - 0
studiou-wc-ord-print-statuses/docs/order-done-plan-00.md

@@ -0,0 +1,332 @@
+# Implementation Plan — Auto "Done Print" on bulk "Change status to Completed"
+
+**File:** `docs/order-done-plan-00.md`
+**Target version:** 1.5.1
+**Status:** Draft / not yet implemented
+
+---
+
+## 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`).
+
+"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 just
+another callback on the same filter, also 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.)
+
+---
+
+## 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: `wc_get_order($id)`, skip falsy, 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.");
+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 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.
+
+### 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.
+
+Constructor addition:
+
+```php
+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):
+
+```php
+/**
+ * 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.
+ */
+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 will reject; we don't mutate.
+    }
+    $ids = array_filter(array_map('intval', (array) $ids));
+    if (empty($ids)) {
+        return $redirect_to;
+    }
+
+    $total_items  = 0;
+    $total_orders = 0;
+    foreach ($ids as $id) {
+        $order = wc_get_order($id);
+        if (!$order) {
+            continue;
+        }
+        $advanced = $this->advance_remaining_to_done($order);
+        if ($advanced > 0) {
+            $order->add_order_note(sprintf(
+                _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++;
+        }
+    }
+
+    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));
+    }
+
+    return $redirect_to;
+}
+```
+
+### 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 would then instantiate
+`Order_Item_Status_Manager` first and pass it 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 order in the bulk action:
+  1. our priority-9 filter advances items → `done-print` and saves them;
+  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.
+- Orders already `completed` before the action: `update_status('completed')` is a no-op (no
+  `status_changed`), so the guard never fires — harmless. Our advance still runs and is also
+  effectively a no-op if items are already resolved.
+
+---
+
+## 6. Edge cases & decisions
+
+- **Terminal-status orders selected (cancelled / refunded / failed):** WooCommerce core's
+  `mark_completed` will move them to `completed` regardless (it has no terminal guard for
+  bulk mark-complete). Our pre-advance will advance their items too. This matches the
+  operator's explicit selection + explicit "complete" choice. **Decision: do not special-case
+  terminal statuses here** — note it as expected behaviour. (Contrast: the protocol *imports*
+  deliberately skip `Studiou_DB_Manager::TERMINAL_STATUSES`, because there the order set is
+  data-driven, not an explicit operator click.) If the team wants to protect these, add a
+  `TERMINAL_STATUSES` skip in the loop — flagged as Open Question Q2.
+- **Empty selection:** handled (returns early).
+- **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:** `advance_remaining_to_done()` saves each changed item individually
+  (`$item->save()`), consistent with the existing import path. For very large bulk sets this
+  is N item-saves; acceptable and matches current code. Not optimising now.
+- **Idempotency:** re-running the bulk action over the same 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, 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.
+- 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.
+- `studiou-wc-ord-print-statuses.php`
+  - bump `Version:` header and `STUDIOU_WC_OPS_VERSION` → `1.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
+    and does NOT change single-order or programmatic completion);
+  - 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`).
+- (No JS/CSS change → no asset cache-bust concern. Version bump still required by repo
+  release convention.)
+
+Add a one-line pointer to this plan in CLAUDE.md's **Documentation** list:
+`- [`docs/order-done-plan-00.md`](docs/order-done-plan-00.md) — Plan for auto "Done Print" on the HPOS bulk "Change status to Completed" action (1.5.1).`
+
+No new translation strings beyond the two new notice/note formats — add their English
+sources and refresh `cs_CZ` (`docs/translations.txt`, `.po`/`.mo`) per the existing process.
+
+---
+
+## 9. Test plan
+
+Manual, on staging with HPOS enabled and `WP_DEBUG` 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. **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).
+5. **Scope — other bulk actions:** run "Set Status to In Printing" / "Prepare to Printing
+   Export" — unchanged; no done-print advance.
+6. **Scope — programmatic:** trigger a completion via WP-CLI/REST (or a quick snippet) — guard
+   still blocks pending orders; no auto-advance.
+7. **Capability:** as a role without `edit_shop_orders`, confirm no item mutation occurs.
+8. **Hook-order verification (the load-bearing assumption):** with `WP_DEBUG_LOG`, log the
+   item statuses at the top of `bulk_complete_advance_items()` and inside the completion
+   guard; confirm the advance log line precedes the guard line for the same order in one
+   request. This proves priority 9 runs before WC's status flip on the installed WC version.
+
+---
+
+## 10. Open questions
+
+- **Q1 (placement):** keep the handler in `Order_Item_Status_Manager` (recommended) or move
+  to `Bulk_Actions_Manager` with dependency injection? Default: `Order_Item_Status_Manager`.
+- **Q2 (terminal orders):** should cancelled/refunded/failed orders selected in the bulk
+  action be skipped (not advanced, not completed) like the protocol imports do, or follow
+  WooCommerce core and complete them? Default: follow core (no skip), since the operator
+  explicitly chose them.
+- **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`.

+ 492 - 0
studiou-wc-ord-print-statuses/docs/order-done-plan-01.md

@@ -0,0 +1,492 @@
+# 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:
+
+```php
+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):
+
+```php
+/**
+ * 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_VERSION` → `1.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`.

+ 267 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.3.md

@@ -0,0 +1,267 @@
+# Plugin Revision — v1.4.3 (Fresh-Eye Analytical Review)
+
+> **Status as of 1.4.4 (2026-05-12):** every in-scope finding below has been resolved in plugin version **1.4.4**. Each finding carries an inline status marker — ✅ resolved, ⏸ deliberately deferred. See the README changelog for the corresponding code changes.
+
+**Scope:** Independent read-only analytical pass over the v1.4.3 codebase. Re-read every PHP file from scratch and walked through user scenarios with deliberate scepticism toward fixes I made in the 1.4.2 / 1.4.3 cycles. The intent of this pass was specifically to look for *regressions introduced by recent fixes* rather than longer-standing issues.
+
+**Reviewer date:** 2026-05-12
+**Target environment:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled), PHP 7.2+.
+**Files reviewed:** all PHP under `includes/`, the bootstrap `studiou-wc-ord-print-statuses.php`, the markdown docs, and the refreshed `.po` translation source.
+**Verification limitations:** Static review only. No live PHP/WP/WC available.
+
+Severity reflects user-visible or correctness impact.
+
+---
+
+## 1. Critical
+
+*(None.)*
+
+---
+
+## 2. High — newly discovered, introduced by recent fix-ups
+
+### 2.1 `Studiou_DB_Manager::normalise_csv_date()` shifts timezone-less CSV dates by the WP timezone offset — ✅ resolved in 1.4.4
+
+Resolution: `strtotime` removed entirely. Fast path uses pure-regex reformat for ISO-like inputs (no timezone shift). Fallback uses `DateTimeImmutable($raw, wp_timezone())->setTimezone(wp_timezone())` — interprets timezone-less input as WP local, honours explicit timezones, throws on garbage. The structural fix recommended in the review's §9 (zero `strtotime` calls in `includes/`) is in effect.
+
+`class-db-manager.php:426-437`:
+
+```php
+private static function normalise_csv_date($raw) {
+    $raw = sanitize_text_field((string) $raw);
+    if ($raw === '') {
+        return '';
+    }
+    $timestamp = strtotime($raw);
+    if (!$timestamp) {
+        UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
+        return '';
+    }
+    return wp_date('Y-m-d H:i:s', $timestamp);
+}
+```
+
+This was changed from `date(...)` to `wp_date(...)` in 1.4.2 with the stated intent of "matching `current_time('mysql')` elsewhere". **The change introduced a timezone shift bug.** Trace:
+
+WordPress calls `date_default_timezone_set('UTC')` very early in its boot. So `strtotime($raw)` for a timezone-less input string interprets it as UTC. Then `wp_date($fmt, $timestamp)` treats its `$timestamp` argument as a UTC value and formats it in the WP site timezone.
+
+Concrete walkthrough with WP site timezone `Europe/Prague` (UTC+2 in summer):
+
+1. CSV `externalorderdate` cell: `"2026-05-12 14:30"` — the print shop wrote this intending Prague-local 14:30.
+2. `strtotime("2026-05-12 14:30")` in WP's UTC-default PHP context → timestamp T (representing 14:30 UTC).
+3. `wp_date('Y-m-d H:i:s', T)` → formatted in Prague timezone → `"2026-05-12 16:30:00"`.
+4. Stored to meta. Operator opening the order edit screen sees **16:30**. CSV said 14:30. **2-hour drift.**
+
+The same direction-of-drift bug as 1.4.0 §2.2 / 1.4.1 §2.1, just in a different function. The previous fix-pass made `Order_Fields_Manager::mysql_to_datetime_local` and `datetime_local_to_mysql` use pure string reformatting (no `strtotime`/`date`/`wp_date`) precisely to avoid this. `Custom_Columns_Manager::format_meta_date` was rebuilt with `DateTimeImmutable(..., wp_timezone())` for the same reason. **`normalise_csv_date` was missed.**
+
+**Suggested fix** — fast-path the common "no explicit timezone" pattern with pure string reformat, fall back to `DateTimeImmutable` with `wp_timezone()` for everything else:
+
+```php
+private static function normalise_csv_date($raw) {
+    $raw = sanitize_text_field((string) $raw);
+    if ($raw === '') {
+        return '';
+    }
+    // Fast path: ISO-ish date with no explicit timezone — treat as WP local
+    // time, no shift.
+    if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
+        $second = isset($m[6]) ? $m[6] : '00';
+        return $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . $second;
+    }
+    // Fallback: parse in WP timezone (no shift on naive input), convert
+    // to WP timezone (handles explicit-TZ input by shifting to local).
+    try {
+        $dt = (new DateTimeImmutable($raw, wp_timezone()))->setTimezone(wp_timezone());
+        return $dt->format('Y-m-d H:i:s');
+    } catch (Exception $e) {
+        UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
+        return '';
+    }
+}
+```
+
+This restores the documented invariant: imported `external_ref_ord_date` reads back at the value the print shop sent.
+
+The drift was hidden in 1.4.2 because `Custom_Columns_Manager::format_meta_date` had its own *opposite* drift bug — the two cancelled out in the columns view. Now that the columns view is correct (1.4.2), the import-side drift is the visible asymmetry.
+
+---
+
+## 3. Medium — pre-existing or longstanding
+
+### 3.1 Plugin assumes HPOS without enforcing it — ✅ resolved in 1.4.4
+
+Resolution: `get_orders_for_printing()` now runs `table_exists("{$wpdb->prefix}wc_orders")` parallel to the existing `wc_order_product_lookup` check. If HPOS is disabled, the export bails with an admin notice ("Export requires WooCommerce HPOS … Enable High-Performance Order Storage …") instead of failing with a cryptic SQL error.
+
+`class-db-manager.php:84` references `{$wpdb->prefix}wc_orders` (the HPOS custom table). The bootstrap declares HPOS compatibility:
+
+```php
+FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
+```
+
+The third argument is `$compatible=true`, meaning "this plugin works correctly with HPOS". It does *not* mean "this plugin requires HPOS". If a site has HPOS turned off (legacy CPT storage only), the `wc_orders` table either doesn't exist or is unsynced. The export query then either:
+
+- Errors with "Table 'wp_wc_orders' doesn't exist" — caught by the `$results === false` branch, logged but user-visible as "no exportable items".
+- Returns rows from an incomplete or stale lookup — the export is incorrect but emits no warning.
+
+The plugin has a precedent for surfacing this kind of dependency: `table_exists($wpdb->prefix . 'wc_order_product_lookup')` already runs at the top of the same method (1.4.2 §3.7). A parallel check for `wc_orders` would close the gap, or the plugin could refuse to load entirely under legacy CPT and surface an admin notice à la `woocommerce_missing_notice`.
+
+The Search Manager has fallback (`woocommerce_shop_order_search_fields` for legacy CPT alongside the HPOS filter), but the export path doesn't. Asymmetric.
+
+### 3.2 `bulk_set_print_status` doesn't pass a note to `update_status`, while the import handlers do — ✅ resolved in 1.4.4
+
+Resolution: `bulk_set_print_status` now passes "Marked Prepare to Printing by bulk action." / "Marked In Printing by bulk action." to `update_status`. The audit trail in the order activity feed is now consistent across bulk-action and import paths.
+
+`class-db-manager.php:227`:
+
+```php
+$order->update_status($status_slug);
+```
+
+…versus `class-db-manager.php:280-283`:
+
+```php
+$order->update_status(
+    'in-print',
+    __('Marked In Printing by InPrint Protocol import.', 'studiou-wc-ord-print-statuses')
+);
+```
+
+Bulk-action-triggered transitions get WC's default "Status changed from X to Y" note. Import-triggered transitions get the explanatory `"Marked … by … import."` note. **Asymmetric audit trail.** The 1.4.3 release added explanatory notes to the import paths; the bulk paths were missed.
+
+Pass an explanatory note from `bulk_set_print_status` too — e.g., `"Marked … by bulk action."`.
+
+### 3.3 `normalise_csv_date` fallback (after fix) — `strtotime()` is permissive — ✅ resolved in 1.4.4
+
+Resolution: subsumed by §2.1's fix. `DateTimeImmutable` throws on inputs `strtotime` would silently misinterpret (`'foo'`, `'1'` etc.), so the catch-Exception branch logs and returns empty rather than persisting a meaningless timestamp.
+
+Independent of §2.1, `strtotime` returns a valid timestamp for many ambiguous inputs:
+
+- `strtotime('1')` → current year, January 1st.
+- `strtotime('next thursday')` → next Thursday.
+- `strtotime('foo bar')` → false (the only one that errors).
+
+If a print shop's CSV has a malformed date that happens to parse, the import silently stores a meaningless date. The current `if (!$timestamp)` guard only catches the `false` return value, not the "parsed but absurd" cases.
+
+The fix-suggestion in §2.1 — going through `DateTimeImmutable($raw, wp_timezone())` — is stricter; it throws on unparseable input rather than guessing.
+
+---
+
+## 4. Low — style and cosmetic
+
+### 4.1 Re-entrancy of `$order->save()` inside `handle_status_transitions` — ⏸ left as-is (forward-looking comment kept in code)
+
+The behaviour is correct under WC 10.7.0's caching semantics; the docblock in `class-order-status-manager.php:83-93` already explains the contract. Refactoring would require a more invasive design change to avoid the listener saving entirely.
+
+`class-order-status-manager.php:107-109`. The listener fires from within `woocommerce_order_status_changed`, which is itself inside `WC_Order::update_status()`. The listener calls `$order->save()` to persist the meta change. The parent path (bulk action, import handler, or manual edit) then calls its own `$order->save()` afterwards.
+
+Today this works because WC's data store returns the same object instance from `wc_get_order($id)` while the request is hot — both saves operate on the same object, the second save is a no-op or just re-saves the same state. If WC ever changes the cache semantics (e.g., to return fresh objects after a save), the parent and listener would operate on different instances and the meta change could be clobbered by the parent's save.
+
+Brittle but not broken. Worth a forward-looking comment.
+
+### 4.2 `bulk_set_print_status` and the listener both stamp the same meta key — ⏸ left as-is (intentional, documented)
+
+Acknowledged in `class-order-status-manager.php:71-77`'s docblock as intentional. The redundancy costs one extra `save()` per bulk-changed order, but the behaviour is correct. The 1.4.0 review §2.3 and the 1.4.1 review covered this.
+
+### 4.3 `Custom_Columns_Manager` calls `wc_get_order` per cell — ✅ resolved in 1.4.4
+
+Resolution: under HPOS the column-content callback receives the `WC_Order` object directly — `wc_get_order` is no longer called. Under legacy CPT a per-request `$order_cache` indexed by ID memoises the lookup so the three custom columns share one resolved instance per row.
+
+`class-custom-columns-manager.php:31`. On a 50-orders-per-page list with three custom columns, that's 150 calls per page load. WC's own object cache mitigates the cost, but a per-page-load static cache keyed by ID inside the manager would be tighter.
+
+### 4.4 `emit_import_summary` always shows "%d order(s) updated successfully" even when the count is 0 — ✅ resolved in 1.4.4
+
+Resolution: gated on `$updated > 0`, matching the pattern `Bulk_Actions_Manager::notify_moved_count` introduced in 1.4.3.
+
+`class-import-manager.php:175-186`. If 100% of rows errored, the operator sees "0 orders updated successfully" + the more useful error notice. The `notify_moved_count` helper in `Bulk_Actions_Manager` (1.4.3) already does the suppression-on-zero pattern; the import summary could adopt it.
+
+### 4.5 `add_action('before_woocommerce_init', function () {...})` uses an anonymous callback — ✅ resolved in 1.4.4
+
+Resolution: converted to a named function `studiou_wc_ord_print_statuses_declare_hpos_compat`. Other plugins / test harnesses can now `remove_action('before_woocommerce_init', 'studiou_wc_ord_print_statuses_declare_hpos_compat')` if they need to override.
+
+`studiou-wc-ord-print-statuses.php:26-30`. Anonymous functions cannot be unhooked from outside the file. If another plugin or test setup ever wants to disable the HPOS compatibility declaration, they can't. Cosmetic — converting to a named function is one line.
+
+### 4.6 `Studiou_DB_Manager::csv_escape` would prefix legitimate negative quantities with `'` — ⏸ left as-is
+
+Acknowledged trade-off — OWASP's recommendation to defuse formula injection is stronger than the cosmetic concern of a leading single quote on negative numbers (which Excel hides anyway). If negative quantities ever become a real workflow signal, csv_escape can be adjusted to whitelist numeric leading-minus.
+
+`class-db-manager.php:164-176`. A `qty` of `"-1"` (which shouldn't occur but might in refund-handling scenarios) starts with `-`, so `csv_escape` adds a leading single quote: `"'-1"`. Excel/LibreOffice display strips the quote so the user sees `-1` — no UX impact — but the underlying CSV bytes look unusual to programmatic consumers. The OWASP recommendation is consistent with this trade-off (defending against formula injection is more important than clean negative-number display), but worth noting.
+
+### 4.7 `call_user_func($cfg['handler'], $csv_data)` in `Import_Manager::run_import` — ✅ resolved in 1.4.4
+
+Resolution: replaced with `$handler = $cfg['handler']; $result = $handler($csv_data);` — direct callable invocation. Slightly faster, less verbose.
+
+`class-import-manager.php:129`. PHP 7+ supports invoking array callables directly: `$cfg['handler']($csv_data)`. The `call_user_func` form is slightly slower and slightly more verbose. Style only.
+
+### 4.8 `add_custom_shop_order_column_content`'s `external_order_number` cell relies on `$value !== ''` rather than `$value !== '' && $value !== '—'` — ⏸ left as-is (vanishingly rare edge case)
+
+If anyone ever stores the literal string `"—"` to `external_ref_ord_no` (operator typing it in for an unknown number), the column displays the literal `"—"`. Confusing but a vanishingly rare edge case.
+
+### 4.9 `parse_csv` doesn't handle `\r`-only line endings — ⏸ left as-is (legacy macOS, extremely rare)
+
+`class-db-manager.php:351-384`. PHP's `auto_detect_line_endings` was deprecated in 8.1 and removed in 9.0. macOS-Classic CSVs (`\r`-only) wouldn't tokenise correctly. Extremely unlikely in practice — modern macOS uses `\n`, Excel-Mac uses `\r\n`. Not worth fixing.
+
+### 4.10 `set_orders_to_prepare_printing` and `set_orders_to_in_printing` are thin wrappers — ⏸ left as-is (stable public API surface)
+
+`class-db-manager.php:197-209` — two two-line methods that delegate to `bulk_set_print_status`. The public API would be cleaner as a single `bulk_set_status($order_ids, $slug)` that the bulk action handler calls with the right slug. Refactor-only; behaviour unchanged.
+
+---
+
+## 5. Verification gaps (carried over from earlier reviews)
+
+1. **HPOS search filter name** `woocommerce_order_table_search_query_meta_keys` against WC 10.7.0 source.
+2. **`woocommerce_process_shop_order_meta`** behaviour under the WC 10.7.0 block-based order edit screen.
+3. **`object cache` scenarios for `UtilsLog::message`** (Redis/Memcached).
+
+---
+
+## 6. Status of prior reviews
+
+| Review | Status of in-scope findings | Carried-over deferrals |
+|--------|------------------------------|-------------------------|
+| `revise-1.3.2.md` | All resolved in 1.4.0 | 3 (`Network:` header, uninstall, cosmetic README). |
+| `revise-1.4.0.md` | All resolved in 1.4.1 | 1 (HPOS filter verification). |
+| `revise-1.4.1.md` | All resolved in 1.4.2 | 2 (custom-order-number plugins, register_post_status). |
+| `revise-1.4.2.md` | All resolved in 1.4.3 | 1 (Excel CS-locale CSV separator). |
+
+**One newly introduced regression** identified in this pass: §2.1 above, the side-effect of the 1.4.2 `date()` → `wp_date()` change in `normalise_csv_date`. Worth noting as a process observation: the same class of bug has now appeared in three separate functions across three releases (1.4.0 `Order_Fields_Manager`, 1.4.1 `Custom_Columns_Manager`, 1.4.2 `normalise_csv_date`). A future fix should audit *every* call to `strtotime` + `date_i18n`/`wp_date`/`date` in the codebase for the same pattern.
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by user-visible severity. Status as of **1.4.4**:
+
+1. **§2.1 `normalise_csv_date` shifts timezone-less CSV dates by the WP offset.** ✅ resolved.
+2. **§3.1 No hard guard against HPOS-disabled installs.** ✅ resolved.
+3. **§3.2 `bulk_set_print_status` doesn't pass an explanatory note.** ✅ resolved.
+4. **§3.3 `normalise_csv_date` fallback accepts permissive `strtotime` interpretations.** ✅ resolved (subsumed by §2.1).
+5. **§4.1 Listener `save()` re-entrancy.** ⏸ left as-is (docblock kept).
+6. **§4.3 Per-cell `wc_get_order` in column rendering.** ✅ resolved.
+7. **§4.4 Zero-count import "success" notice.** ✅ resolved.
+
+---
+
+## 8. Out of scope
+
+- Live behaviour testing against a running WP 6.9.4 / WC 10.7.0 install.
+- Performance profiling of the export query on large order volumes.
+- Behavioural verification on the WC 10.x block-based order edit screen.
+- Audit of the refreshed `.po` against the running plugin (the file was rewritten in this cycle; cross-verifying every `msgstr` against the source `msgid` is out of scope for this review).
+- Multisite, uninstall, custom-order-number plugin integration (carried-over deferrals).
+
+---
+
+## 9. Process observation
+
+The two prior fresh-eye reviews (`revise-1.4.1`, `revise-1.4.2`) each surfaced a single new high-severity bug that had survived earlier passes. This review continues the pattern with §2.1 — and the cause is a regression *I introduced* in the 1.4.2 fix for a different timezone bug. The same code shape (`strtotime` + format-in-WP-timezone) appears in three different functions across three releases, fixed each time only in the function specifically called out by the review.
+
+The reliable fix for this class of bug is to **drop `strtotime` for the "stored as WP-local MySQL datetime ↔ display in WP-local" pipeline entirely**. Either pure string reformatting (as `Order_Fields_Manager` now does) or `DateTimeImmutable(..., wp_timezone())` (as `Custom_Columns_Manager` now does) sidesteps the WP UTC-default trap. A grep for `strtotime` across `includes/` would catch any future regression.
+
+Today the only remaining `strtotime` call is in `normalise_csv_date`. After §2.1 is fixed, there should be none.
+
+> **Confirmation in 1.4.4:** a grep for `strtotime` across `includes/` returns zero hits in executable code. Only the docblock comments in `class-custom-columns-manager.php`, `class-db-manager.php`, and `class-order-fields-manager.php` mention it — explaining why each function avoids the pattern.
+
+---
+
+*End of review.*

+ 260 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.4.md

@@ -0,0 +1,260 @@
+# Plugin Revision — v1.4.4 (Fresh-Eye Analytical Review)
+
+> **Status as of 1.4.5 (2026-05-12):** all four mediums and items §4.1, §4.2 are resolved in plugin version **1.4.5**. §4.3 and §4.4 are documented and deferred (architectural / edge case). §3.4 is documented in README "Known limitations" without a code change. Each finding carries an inline ✅ resolved / ⏸ deferred status marker.
+
+**Scope:** Independent read-only analytical pass over the v1.4.4 codebase. Read with deliberate scepticism toward the changes introduced in 1.4.4 itself, looking for regressions and gaps the previous reviews missed.
+
+**Reviewer date:** 2026-05-12
+**Target environment:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled), PHP 7.2+.
+**Files reviewed:** all PHP under `includes/`, the bootstrap, the markdown docs, and the `.po` translation source.
+**Verification limitations:** Static review only. No live PHP/WP/WC available.
+
+Severity reflects user-visible or correctness impact.
+
+> **Headline:** no critical findings in this round. The plugin is in a steady state. Four medium-severity items and four low-severity items remain — most are defensive improvements rather than active defects. Two of the mediums (§3.1, §3.3) are *gaps* in fixes I introduced in 1.4.4 itself.
+
+---
+
+## 1. Critical
+
+*(None.)*
+
+---
+
+## 2. High
+
+*(None.)*
+
+---
+
+## 3. Medium — gaps and asymmetries
+
+### 3.1 `normalise_csv_date` fast-path lacks range validation — asymmetric with `datetime_local_to_mysql` — ✅ resolved in 1.4.5
+
+Resolution: the fast path now runs `checkdate($month, $day, $year)` plus explicit hour ≤ 23 / minute ≤ 59 / second ≤ 59 bounds. Inputs that match the regex structurally but fall outside the valid ranges (e.g., `"2026-13-45T25:99"`) drop through to the `DateTimeImmutable` fallback, which throws and routes to the catch → empty string + logged.
+
+`class-db-manager.php:459-462`:
+
+```php
+if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
+    $second = isset($m[6]) ? $m[6] : '00';
+    return $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . $second;
+}
+```
+
+The regex matches structurally — `"2026-13-45T25:99"` passes — but doesn't validate that the date is real or that the time components are in range. The fast-path returns `"2026-13-45 25:99:00"`, which is then stored to postmeta as-is.
+
+Compare with `Order_Fields_Manager::datetime_local_to_mysql` (`class-order-fields-manager.php:118-131`), which I extended in 1.4.3 to add `checkdate()` plus explicit hour/minute/second bounds. **The same defence belongs on the import side**, because:
+
+- A CSV from the print shop's system could realistically include malformed dates (data-entry typo, locale mismatch, broken upstream tool).
+- The bogus stored value round-trips through display: `Order_Fields_Manager::mysql_to_datetime_local` matches the regex but produces nonsense (`2026-13-45T25:99`), which the browser renders as an empty input. The operator can't tell anything happened until they save the order — at which point `datetime_local_to_mysql` finally rejects it.
+
+**Suggested fix** — apply the same validation in the fast path:
+
+```php
+if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
+    list(, $y, $mo, $d, $h, $mi) = $m;
+    $s = isset($m[6]) ? $m[6] : '00';
+    if (!checkdate((int) $mo, (int) $d, (int) $y) || (int) $h > 23 || (int) $mi > 59 || (int) $s > 59) {
+        // Fall through to the DateTimeImmutable path, which will throw on
+        // unparseable values and reach our catch.
+        return self::normalise_csv_date_fallback($raw);
+    }
+    return $y . '-' . $mo . '-' . $d . ' ' . $h . ':' . $mi . ':' . $s;
+}
+```
+
+…or simpler: drop the fast path entirely and always use `DateTimeImmutable($raw, wp_timezone())`. The fast path was an optimisation; the fallback handles the common case correctly too.
+
+### 3.2 `emit_import_summary` is silent when 0 successes and 0 errors — ✅ resolved in 1.4.5
+
+Resolution: added an `elseif (empty($errors))` branch that emits an info notice ("The CSV file contained no actionable rows (empty order_no column).") so the operator gets feedback even when dedup-by-order_no filters out every row.
+
+`class-import-manager.php:172-209`. The 1.4.4 change gated the "%d orders updated successfully" notice on `$updated > 0`. That's correct *when* there are errors to surface, but if both counters are zero the operator sees nothing.
+
+How can both be zero? `run_import` already bails earlier when `$csv_data` is empty (line 121). But `dedupe_by_order_no` (line 413) further filters out rows whose `order_no` is empty after `trim()`. A CSV with header + N rows where every row has an empty `order_no` column produces `count($csv_data) === N` but yields zero rows after dedup. No order_no → no `wc_get_order` lookup → no error entry → zero on both sides.
+
+The operator gets no feedback that the import "succeeded" (it ran without error) but did nothing.
+
+**Suggested fix** — add an else branch:
+
+```php
+} elseif (empty($errors)) {
+    UtilsLog::message(
+        __('The CSV file contained no actionable rows (empty order_no column).', 'studiou-wc-ord-print-statuses'),
+        'info'
+    );
+}
+```
+
+### 3.3 `csv_escape` checks only the literal first character — leading-whitespace bypass — ✅ resolved in 1.4.5
+
+Resolution: `csv_escape` now calls `ltrim` and checks the first non-whitespace character. Cells like `" =CMD"` are now prefixed with `'`.
+
+`class-db-manager.php:174-186`:
+
+```php
+$first = $value[0];
+if (in_array($first, array('=', '+', '-', '@', "\t", "\r"), true)) {
+    return "'" . $value;
+}
+```
+
+Cells beginning with a space followed by a danger character (e.g., `" =CMD"`) slip past — `$value[0]` is the space. Some spreadsheet apps trim leading whitespace before evaluating, others don't; behaviour varies by version and config.
+
+**Vector likelihood:** low in this codebase — the danger-prone fields are `prod_name`, `prod_var`, `prod_cat`, `email`. Product / category names are admin-controlled. `email` is customer-controlled but WC's email validation rejects strings starting with space-equals.
+
+Still, the OWASP recommendation is to check the value after `ltrim`, not just `$value[0]`. One-line fix:
+
+```php
+$trimmed = ltrim($value);
+if ($trimmed !== '' && in_array($trimmed[0], array('=', '+', '-', '@', "\t", "\r"), true)) {
+    return "'" . $value;
+}
+```
+
+### 3.4 Possible collation mismatch on `terms.slug = postmeta.meta_value` — ⏸ documented in README "Known limitations"
+
+Resolution path: the issue is theoretical and hasn't been observed in production. Adding an explicit `COLLATE` clause to the join condition risks breaking installs whose actual collation differs from whatever we'd hardcode. The pragmatic choice is to document the failure mode (which manifests as `ERROR 1267 (Illegal mix of collations)` from the bulk export) so future operators can add the fix if it surfaces. README "Known limitations" now mentions this.
+
+`class-db-manager.php:104-105`:
+
+```sql
+LEFT JOIN `{$wpdb->terms}` `product_variations_name`
+    ON `product_variations_name`.`slug` = `product_variations_attr`.`meta_value`
+```
+
+`terms.slug` is `VARCHAR(200)`; `postmeta.meta_value` is `LONGTEXT`. Both columns *should* use `utf8mb4_unicode_ci` on a default WP install, but installs that have been around long enough to have been migrated across MySQL versions (or had per-table collation changes applied) can end up with mismatched collations.
+
+When MySQL encounters a join condition between columns with incompatible collations, it can either:
+- Throw `ERROR 1267 (HY000): Illegal mix of collations` and abort the query, or
+- Implicitly coerce via temporary string conversion, which can silently change case sensitivity / accent handling for that join.
+
+The export query has no `COLLATE` clause, so it inherits the DB-default collation. Most production sites won't be affected — Studiou's install presumably wasn't, given that earlier 1.3.2 / 1.4.0 / 1.4.1 reviews didn't surface this. Worth noting as an unbounded edge case rather than a confirmed bug.
+
+**Mitigation** (only if it becomes a problem): add `COLLATE utf8mb4_unicode_ci` to the JOIN condition.
+
+---
+
+## 4. Low
+
+### 4.1 `bulk_set_print_status` note selection is a binary ternary — implicit else — ✅ resolved in 1.4.5
+
+Resolution: lifted to an explicit `array('to-print' => …, 'in-print' => …)` map with `isset` fallback to empty string. Future contributors adding a third slug get a no-op note rather than the wrong note.
+
+`class-db-manager.php:229-231`:
+
+```php
+$note = ($status_slug === 'to-print')
+    ? __('Marked Prepare to Printing by bulk action.', 'studiou-wc-ord-print-statuses')
+    : __('Marked In Printing by bulk action.', 'studiou-wc-ord-print-statuses');
+```
+
+The function is only ever called with `'to-print'` or `'in-print'`, so the else branch is correct today. If a future contributor extends the helper to a third status, the else-implicit selection of "In Printing" would silently produce a misleading note. Defensive option: a small dispatch map.
+
+### 4.2 `parse_csv` doesn't detect duplicate headers — ✅ resolved in 1.4.5
+
+Resolution: after `array_map(normalise_header, $headers)`, `parse_csv` compares `count($headers)` against `count(array_unique($headers))` and bails (with an `UtilsLog::log` entry naming the headers) on mismatch. The downstream import then emits the existing "could not be parsed" notice.
+
+`class-db-manager.php:391-397`:
+
+```php
+$headers = array_map(array(__CLASS__, 'normalise_header'), $headers);
+while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
+    if (count($data) !== count($headers)) {
+        continue;
+    }
+    $csv_data[] = array_combine($headers, $data);
+}
+```
+
+If the CSV has two columns sharing a normalised name (e.g., `Order No` and `order_no` both normalising to `order_no`), `array_combine` silently overwrites — the second column's data wins for every row, the first column's data is lost. The user gets no warning.
+
+Realistic? Unlikely with a well-formed protocol CSV. Defensive — add `count($headers) === count(array_unique($headers))` check and abort with a clear notice if not.
+
+### 4.3 Main plugin instance is short-lived; managers persist via the WP filter array — ⏸ left as-is (architectural)
+
+Resolution path: refactoring would require either making the main instance a singleton (with `getInstance()`) or moving manager initialisation to a static method. Both are larger refactors than the symptom warrants — there is no functional impact, only a documentation concern. Worth a CLAUDE.md note if the lifecycle pattern surprises a future contributor.
+
+`studiou-wc-ord-print-statuses.php:47-50`:
+
+```php
+public static function initPlugin() {
+    $class = __CLASS__;
+    new $class;
+}
+```
+
+The new instance has no global / static reference. After `__construct()` calls `init()` and `init()` returns, the local `$class` variable goes out of scope and PHP's reference counter for the main instance hits zero. The instance is GC'd.
+
+The managers stored in `$this->order_status_manager` etc. would normally be GC'd along with the main instance — *except* each manager's constructor registers WP hooks with `array($this, '...')`, which adds references to the manager instances inside the global `$wp_filter` array. Those references keep the managers alive for the rest of the request.
+
+So the private `$order_status_manager` / `$order_fields_manager` / etc. fields on `Studiou_WC_Ord_Print_Statuses` are effectively *decorative* — they only matter during the few microseconds of `initialize_managers()`. After `init()` returns they're inaccessible.
+
+**Implications:** none in normal flow. But code that tries to look up "the plugin's order status manager" via `Studiou_WC_Ord_Print_Statuses` (e.g., a third-party hook integrating with this plugin) will find no instance. The pattern is unusual and worth documenting.
+
+### 4.4 `woocommerce_missing_notice` registered conditionally but never unregistered — ⏸ left as-is (edge case)
+
+`studiou-wc-ord-print-statuses.php:54-60`. If WC is not yet active when our `init()` runs, the notice hook is registered. If WC becomes active later in the same request (extremely unlikely — WC would have to be activated mid-request by another plugin's hook), the notice would still display once on the next admin page load. Edge case.
+
+---
+
+## 5. Verification gaps (carried over from earlier reviews)
+
+1. **HPOS search filter name** `woocommerce_order_table_search_query_meta_keys` against WC 10.7.0 source. The legacy filter `woocommerce_shop_order_search_fields` may not fire under HPOS at all — making it not a fallback. Smoke test by adding a temporary `error_log` inside the callback and observing whether the HPOS path actually fires.
+2. **`woocommerce_process_shop_order_meta`** behaviour under the WC 10.7.0 block-based order edit screen.
+3. **Object-cache scenarios for `UtilsLog::message`** (Redis/Memcached).
+
+---
+
+## 6. Status of prior reviews
+
+| Review | Resolved | Carried-over deferrals |
+|--------|----------|-------------------------|
+| `revise-1.3.2.md` | 1.4.0 | 3 (`Network:` header, uninstall, README phrasing). |
+| `revise-1.4.0.md` | 1.4.1 | 1 (HPOS filter verification). |
+| `revise-1.4.1.md` | 1.4.2 | 2 (custom-order-number plugins, register_post_status). |
+| `revise-1.4.2.md` | 1.4.3 | 1 (Excel CS-locale CSV separator). |
+| `revise-1.4.3.md` | 1.4.4 | (listener save() re-entrancy, bulk/listener double-stamp — both intentional, documented; csv_escape on negatives — OWASP trade-off; "—" literal column, `\r`-only line endings, `set_orders_to_*` API surface — vanishingly rare). |
+
+**No new regressions introduced by 1.4.4** in the strict sense. §3.1 and §3.3 are *under-applied defences* — patterns that should have extended to the import path / been stricter in csv_escape — rather than active bugs.
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by user-visible severity. Status as of **1.4.5**:
+
+1. **§3.1 `normalise_csv_date` fast-path missing range validation.** ✅ resolved.
+2. **§3.2 Silent import when all rows have empty `order_no`.** ✅ resolved.
+3. **§3.3 `csv_escape` leading-whitespace bypass.** ✅ resolved.
+4. **§3.4 Collation mismatch on slug join.** ⏸ documented in README "Known limitations".
+5. **§4.1 Binary note selection in `bulk_set_print_status`.** ✅ resolved.
+6. **§4.2 Duplicate-header detection in `parse_csv`.** ✅ resolved.
+7. **§4.3 Main plugin instance lifecycle.** ⏸ left as-is (architectural, no functional impact).
+8. **§4.4 `woocommerce_missing_notice` re-fire.** ⏸ left as-is (edge case).
+
+---
+
+## 8. Out of scope
+
+- Live behaviour testing against a running WP 6.9.4 / WC 10.7.0 install.
+- Performance profiling under realistic order volumes.
+- Behavioural verification on the WC 10.x block-based order edit screen.
+- Multisite, uninstall, custom-order-number plugin integration (carried-over deferrals).
+- Verifying every `msgstr` in the refreshed `.po` against the running plugin.
+
+---
+
+## 9. Process observation
+
+The "single new HIGH per fresh-eye pass" pattern from 1.4.0 → 1.4.3 has tailed off. This pass surfaces only mediums and lows. Either:
+
+- The plugin has genuinely reached a stable state (likely), or
+- I'm developing scope blindness on this codebase after seven reviews and four fix-up releases (possible — fresh-eye reviews work best with rotating reviewers).
+
+If a second reviewer is available, this is a good moment for an outside pass. Otherwise, the diminishing-returns curve suggests the next iteration of fixes should focus on the carried-over verification gaps (live testing against WC 10.7.0) rather than another static review cycle.
+
+---
+
+*End of review.*

+ 46 - 1
studiou-wc-ord-print-statuses/docs/translations.txt

@@ -1,5 +1,5 @@
 Translations: all user-facing strings (English → Czech).
-Last reviewed: v1.4.3 (2026-05-12).
+Last reviewed: v1.5.1 (2026-06-15).
 
 Note: After editing the .po file, regenerate the .mo binary with one of:
   wp i18n make-mo languages/
@@ -74,3 +74,48 @@ Only .csv files are accepted.
 # New in 1.4.3 — order notes passed to update_status from import paths
 Marked In Printing by InPrint Protocol import.                                              Označeno jako "V Tisku" importem protokolu V Tisku.
 Marked completed by Delivered Protocol import.                                              Označeno jako "Dokončeno" importem protokolu Tisk Dodán.
+
+# New in 1.4.4 — order notes passed to update_status from bulk-action paths
+Marked Prepare to Printing by bulk action.                                                  Označeno jako "Příprava Tisku" hromadnou akcí.
+Marked In Printing by bulk action.                                                          Označeno jako "V Tisku" hromadnou akcí.
+
+# New in 1.4.4 — HPOS table-missing admin notice
+Export requires WooCommerce HPOS (custom order tables). Enable High-Performance Order Storage in WooCommerce → Settings → Advanced → Features, or contact support.  Export vyžaduje WooCommerce HPOS (vlastní tabulky objednávek). Aktivujte High-Performance Order Storage v WooCommerce → Nastavení → Pokročilé → Funkce, nebo kontaktujte podporu.
+
+# New in 1.4.5 — import-summary info notice when dedup filters out every row
+The CSV file contained no actionable rows (empty order_no column).                          CSV soubor neobsahoval žádné použitelné řádky (sloupec order_no je prázdný).
+
+# New in 1.5.0 — per-order-item print status feature
+# Status labels
+Pending Print                                                                               Čeká na Tisk
+In Print                                                                                    V Tisku
+Done Print                                                                                  Vytištěno
+Skip Print                                                                                  Přeskočit Tisk
+
+# Order item column header
+Print Status                                                                                Stav Tisku
+
+# Order activity notes
+Item #%1$d (%2$s): %3$s → %4$s.                                                             Položka č. %1$d (%2$s): %3$s → %4$s.
+%d line item advanced to "In Print" by order-status change.                                 %d položka přesunuta na "V Tisku" kvůli změně stavu objednávky.
+%d line items advanced to "In Print" by order-status change.                                %d položek přesunuto na "V Tisku" kvůli změně stavu objednávky.
+%d line item advanced to "Done Print" by Delivered Protocol import.                         %d položka přesunuta na "Vytištěno" importem protokolu Tisk Dodán.
+%d line items advanced to "Done Print" by Delivered Protocol import.                        %d položek přesunuto na "Vytištěno" importem protokolu Tisk Dodán.
+
+# Completion guard
+Cannot complete: product line items still pending print. Resolve item statuses first.       Nelze dokončit: položky objednávky ještě nejsou vytištěné. Nejprve vyřešte stavy položek.
+Order #%1$d completion blocked — %2$d product items not yet "Done Print" or "Skip Print".   Dokončení objednávky č. %1$d zablokováno — %2$d položek dosud není ve stavu "Vytištěno" nebo "Přeskočit Tisk".
+
+# Delivered Protocol summary notice (across all orders in one import)
+%d line item advanced to "Done Print" to satisfy completion guard.                          %d položka přesunuta na "Vytištěno" pro splnění podmínky dokončení.
+%d line items advanced to "Done Print" to satisfy completion guard.                         %d položek přesunuto na "Vytištěno" pro splnění podmínky dokončení.
+
+# New in 1.5.1 — auto "Done Print" on the bulk "Change status to Completed" action
+# Per-order advance note
+%d line item advanced to "Done Print" before bulk completion.                               %d položka přesunuta na "Vytištěno" před hromadným dokončením.
+%d line items advanced to "Done Print" before bulk completion.                              %d položek přesunuto na "Vytištěno" před hromadným dokončením.
+# Batch success summary notice
+Auto-advanced %1$d line item(s) to "Done Print" across %2$d order(s) before completion.      Automaticky přesunuto %1$d položek na "Vytištěno" v %2$d objednávkách před dokončením.
+# Per-order failure warning notice
+%d order could not be pre-advanced and may not complete — check the debug log.               %d objednávku se nepodařilo předem připravit a nemusí být dokončena — zkontrolujte ladicí log.
+%d orders could not be pre-advanced and may not complete — check the debug log.              %d objednávek se nepodařilo předem připravit a nemusí být dokončeno — zkontrolujte ladicí log.

+ 21 - 3
studiou-wc-ord-print-statuses/includes/class-custom-columns-manager.php

@@ -4,6 +4,14 @@
 defined('ABSPATH') || exit;
 
 class Custom_Columns_Manager {
+    /**
+     * Per-request cache of resolved WC_Order objects keyed by order ID.
+     * Used only on the legacy CPT path where WP passes us the order ID;
+     * under HPOS WC already hands us the WC_Order instance directly.
+     * @var array<int,WC_Order|false>
+     */
+    private $order_cache = array();
+
     public function __construct() {
         add_filter('manage_woocommerce_page_wc-orders_columns', array($this, 'add_custom_shop_order_column'));
         add_action('manage_woocommerce_page_wc-orders_custom_column', array($this, 'add_custom_shop_order_column_content'), 10, 2);
@@ -24,11 +32,21 @@ class Custom_Columns_Manager {
 
     /**
      * Render cell content. Under HPOS WC passes the WC_Order object as the
-     * second argument; under legacy CPT it passes the post ID. wc_get_order()
-     * normalises both forms, so we accept either.
+     * second argument; under legacy CPT it passes the post ID. Use the
+     * object directly when WC hands us one (skipping the wc_get_order
+     * lookup entirely), and memoise by ID for the legacy path so we don't
+     * re-resolve the same order for each of the three custom columns.
      */
     public function add_custom_shop_order_column_content($column, $order_or_id) {
-        $order = wc_get_order($order_or_id);
+        if ($order_or_id instanceof WC_Abstract_Order) {
+            $order = $order_or_id;
+        } else {
+            $id = (int) $order_or_id;
+            if (!array_key_exists($id, $this->order_cache)) {
+                $this->order_cache[$id] = wc_get_order($id);
+            }
+            $order = $this->order_cache[$id];
+        }
         if (!$order) {
             echo '—';
             return;

+ 126 - 11
studiou-wc-ord-print-statuses/includes/class-db-manager.php

@@ -49,6 +49,16 @@ class Studiou_DB_Manager {
             return array();
         }
 
+        $orders_table = $wpdb->prefix . 'wc_orders';
+        if (!self::table_exists($orders_table)) {
+            UtilsLog::log("get_orders_for_printing: required HPOS table {$orders_table} is missing. Is WooCommerce HPOS enabled?");
+            UtilsLog::message(
+                __('Export requires WooCommerce HPOS (custom order tables). Enable High-Performance Order Storage in WooCommerce → Settings → Advanced → Features, or contact support.', 'studiou-wc-ord-print-statuses'),
+                'error'
+            );
+            return array();
+        }
+
         $lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
         if (!self::table_exists($lookup_table)) {
             UtilsLog::log("get_orders_for_printing: required table {$lookup_table} is missing. Is WC analytics enabled?");
@@ -71,6 +81,10 @@ class Studiou_DB_Manager {
         // qty uses SUM() because a single (order, product, variation) group
         // may contain multiple lookup rows when a customer added the same
         // variation twice without cart-merge.
+        //
+        // prod_print_status comes from `_print_status` on the order line item
+        // meta (added in 1.5.0). LEFT JOIN with COALESCE so missing meta
+        // (lazy-default items) reads as `pending-print` in the export.
         $sql = "
             SELECT
                 `orders`.`id` AS `order_no`,
@@ -80,6 +94,7 @@ class Studiou_DB_Manager {
                 MIN(`product_variations_name`.`name`)    AS `prod_var_type`,
                 MIN(`product_meta`.`meta_value`)         AS `prod_img_id`,
                 SUM(`order_products`.`product_qty`)      AS `qty`,
+                COALESCE(MIN(`print_status_meta`.`meta_value`), 'pending-print') AS `prod_print_status`,
                 MIN(`orders`.`billing_email`)            AS `email`
             FROM `{$wpdb->prefix}wc_orders` `orders`
             JOIN `{$wpdb->prefix}wc_order_product_lookup` `order_products`
@@ -96,6 +111,9 @@ class Studiou_DB_Manager {
             LEFT JOIN `{$wpdb->postmeta}` `product_meta`
                 ON `product_meta`.`post_id` = `products`.`ID`
                 AND `product_meta`.`meta_key` = '_thumbnail_id'
+            LEFT JOIN `{$wpdb->prefix}woocommerce_order_itemmeta` `print_status_meta`
+                ON `print_status_meta`.`order_item_id` = `order_products`.`order_item_id`
+                AND `print_status_meta`.`meta_key` = '_print_status'
             LEFT JOIN `{$wpdb->term_relationships}` `tr`
                 ON `tr`.`object_id` = `products`.`ID`
             LEFT JOIN `{$wpdb->term_taxonomy}` `tt`
@@ -160,6 +178,10 @@ class Studiou_DB_Manager {
      * prefixing values that begin with =, +, -, @, tab, or CR with a single
      * quote. OWASP-recommended pattern. The leading quote is hidden by
      * Excel when displayed but disables formula evaluation.
+     *
+     * Checks the first non-whitespace character (not just $value[0]) so
+     * cells like " =CMD" — with leading whitespace some spreadsheet apps
+     * strip before evaluating — are also defused.
      */
     private static function csv_escape($value) {
         if (!is_string($value)) {
@@ -168,7 +190,11 @@ class Studiou_DB_Manager {
         if ($value === '') {
             return $value;
         }
-        $first = $value[0];
+        $trimmed = ltrim($value);
+        if ($trimmed === '') {
+            return $value;
+        }
+        $first = $trimmed[0];
         if (in_array($first, array('=', '+', '-', '@', "\t", "\r"), true)) {
             return "'" . $value;
         }
@@ -212,6 +238,20 @@ class Studiou_DB_Manager {
         $updated_count = 0;
         $skipped       = 0;
 
+        // Explanatory note for the order activity feed. Mirrors the
+        // "Marked … by … Protocol import." pattern used in the import
+        // handlers so the audit trail consistently records *how* a
+        // transition was triggered.
+        //
+        // Explicit map keyed by slug so a future contributor adding a
+        // third status doesn't accidentally inherit the wrong note via
+        // an else-implicit ternary.
+        $notes = array(
+            'to-print' => __('Marked Prepare to Printing by bulk action.', 'studiou-wc-ord-print-statuses'),
+            'in-print' => __('Marked In Printing by bulk action.', 'studiou-wc-ord-print-statuses'),
+        );
+        $note = isset($notes[$status_slug]) ? $notes[$status_slug] : '';
+
         foreach ((array) $order_ids as $order_id) {
             $order_id = (int) $order_id;
             $order    = wc_get_order($order_id);
@@ -224,7 +264,7 @@ class Studiou_DB_Manager {
                 $skipped++;
                 continue;
             }
-            $order->update_status($status_slug);
+            $order->update_status($status_slug, $note);
             $order->update_meta_data($meta_key, current_time('mysql'));
             $order->save();
             $updated_count++;
@@ -300,6 +340,7 @@ class Studiou_DB_Manager {
      */
     public static function import_delivered_protocol($csv_data) {
         $updated_count = 0;
+        $items_advanced_total = 0;
         $errors        = array();
 
         // Statuses we should not silently flip to "completed". `completed`
@@ -307,6 +348,8 @@ class Studiou_DB_Manager {
         // protocol on already-completed orders is a benign no-op transition.
         $non_completable = array('cancelled', 'refunded', 'failed');
 
+        $item_status_mgr = new Order_Item_Status_Manager();
+
         foreach (self::dedupe_by_order_no($csv_data) as $row) {
             if (!isset($row['order_no'])) {
                 $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
@@ -326,6 +369,24 @@ class Studiou_DB_Manager {
                 );
                 continue;
             }
+            // Advance every still-pending or in-print item to done-print so
+            // the completion guard installed by Order_Item_Status_Manager
+            // is satisfied. Items already in skip-print or done-print are
+            // untouched.
+            $advanced = $item_status_mgr->advance_remaining_to_done($order);
+            if ($advanced > 0) {
+                $items_advanced_total += $advanced;
+                $order->add_order_note(sprintf(
+                    /* translators: %d is the number of items advanced. */
+                    _n(
+                        '%d line item advanced to "Done Print" by Delivered Protocol import.',
+                        '%d line items advanced to "Done Print" by Delivered Protocol import.',
+                        $advanced,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $advanced
+                ));
+            }
             $order->update_status(
                 'completed',
                 __('Marked completed by Delivered Protocol import.', 'studiou-wc-ord-print-statuses')
@@ -333,7 +394,23 @@ class Studiou_DB_Manager {
             $order->update_meta_data('external_ref_ord_delivered', current_time('mysql'));
             $order->save();
             $updated_count++;
-            UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' marked "completed"');
+            UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' marked "completed" (' . $advanced . ' items advanced)');
+        }
+
+        if ($items_advanced_total > 0) {
+            UtilsLog::message(
+                sprintf(
+                    /* translators: %d total items advanced across all orders in this import. */
+                    _n(
+                        '%d line item advanced to "Done Print" to satisfy completion guard.',
+                        '%d line items advanced to "Done Print" to satisfy completion guard.',
+                        $items_advanced_total,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $items_advanced_total
+                ),
+                'info'
+            );
         }
 
         return array('updated_count' => $updated_count, 'errors' => $errors);
@@ -371,6 +448,14 @@ class Studiou_DB_Manager {
                 return $csv_data;
             }
             $headers = array_map(array(__CLASS__, 'normalise_header'), $headers);
+            // Reject CSVs whose headers collide after normalisation (e.g.,
+            // "Order No" and "order_no" both → "order_no"). array_combine
+            // would silently let the later column's data overwrite the
+            // earlier — losing one column's values entirely.
+            if (count($headers) !== count(array_unique($headers))) {
+                UtilsLog::log('parse_csv: duplicate column headers after normalisation: ' . implode(', ', $headers));
+                return $csv_data;
+            }
             while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
                 if (count($data) !== count($headers)) {
                     continue;
@@ -417,22 +502,52 @@ class Studiou_DB_Manager {
      * Returns '' (and logs) on parse failure so the UI does not silently
      * carry unparseable raw values through the round-trip.
      *
-     * Uses wp_date() so the resulting string is consistent with
-     * current_time('mysql') — both reflect WP local time, not server PHP
-     * timezone. Previously this used date(), which interpreted the timestamp
-     * in server timezone and produced an asymmetry between import-side
-     * external_ref_ord_date and every other meta key.
+     * Avoids strtotime() entirely. WordPress sets the PHP timezone to UTC
+     * very early in its boot (date_default_timezone_set('UTC')), so
+     * strtotime() interprets timezone-less inputs as UTC. Combining that
+     * with wp_date() — which expects a UTC timestamp and formats in WP
+     * timezone — shifted "2026-05-12 14:30" forward by the WP offset.
+     *
+     * Two paths:
+     * - Fast: ISO-like "YYYY-MM-DD[T ]HH:MM[:SS]" with no explicit timezone
+     *   → pure string reformat, no shift. This is the common shape of
+     *   what the print shop sends.
+     * - Fallback: DateTimeImmutable($raw, wp_timezone()) — interprets
+     *   timezone-less input as WP local; explicit-timezone input is honoured
+     *   by the constructor (second arg ignored in that case) and then
+     *   converted back to WP timezone via setTimezone() so the output is
+     *   always WP-local.
      */
     private static function normalise_csv_date($raw) {
         $raw = sanitize_text_field((string) $raw);
         if ($raw === '') {
             return '';
         }
-        $timestamp = strtotime($raw);
-        if (!$timestamp) {
+        // Fast path — common ISO-ish shape with no explicit timezone.
+        // checkdate() + h/m/s bounds reject structurally-valid but
+        // semantically-out-of-range inputs (e.g., "2026-13-45T25:99")
+        // so they fall through to the DateTimeImmutable path, which
+        // will throw and route us to the catch.
+        if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
+            $year   = (int) $m[1];
+            $month  = (int) $m[2];
+            $day    = (int) $m[3];
+            $hour   = (int) $m[4];
+            $minute = (int) $m[5];
+            $second = isset($m[6]) ? (int) $m[6] : 0;
+            if (checkdate($month, $day, $year) && $hour <= 23 && $minute <= 59 && $second <= 59) {
+                return $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . sprintf('%02d', $second);
+            }
+        }
+        // Fallback — let DateTimeImmutable handle locale dates and explicit
+        // timezones. Strict: throws ValueError on garbage (e.g. "foo").
+        try {
+            $dt = new DateTimeImmutable($raw, wp_timezone());
+            $dt = $dt->setTimezone(wp_timezone());
+            return $dt->format('Y-m-d H:i:s');
+        } catch (Exception $e) {
             UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
             return '';
         }
-        return wp_date('Y-m-d H:i:s', $timestamp);
     }
 }

+ 27 - 12
studiou-wc-ord-print-statuses/includes/class-import-manager.php

@@ -126,7 +126,8 @@ class Import_Manager {
             return $this->redirect_back();
         }
 
-        $result = call_user_func($cfg['handler'], $csv_data);
+        $handler = $cfg['handler'];
+        $result  = $handler($csv_data);
         $this->emit_import_summary($result);
         return $this->redirect_back();
     }
@@ -172,18 +173,32 @@ class Import_Manager {
         $updated = isset($result['updated_count']) ? (int) $result['updated_count'] : 0;
         $errors  = isset($result['errors']) && is_array($result['errors']) ? $result['errors'] : array();
 
-        UtilsLog::message(
-            sprintf(
-                _n(
-                    '%d order updated successfully.',
-                    '%d orders updated successfully.',
-                    $updated,
-                    'studiou-wc-ord-print-statuses'
+        // Skip the "0 orders updated" notice when nothing actually changed —
+        // the per-error notice below is more informative on its own and
+        // matches the pattern Bulk_Actions_Manager::notify_moved_count uses.
+        if ($updated > 0) {
+            UtilsLog::message(
+                sprintf(
+                    _n(
+                        '%d order updated successfully.',
+                        '%d orders updated successfully.',
+                        $updated,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $updated
                 ),
-                $updated
-            ),
-            $updated > 0 ? 'success' : 'info'
-        );
+                'success'
+            );
+        } elseif (empty($errors)) {
+            // Both counters zero: the CSV parsed and had rows, but
+            // dedupe_by_order_no filtered everything out — every row had
+            // an empty order_no column. Surface this so the operator
+            // isn't left wondering whether the import ran.
+            UtilsLog::message(
+                __('The CSV file contained no actionable rows (empty order_no column).', 'studiou-wc-ord-print-statuses'),
+                'info'
+            );
+        }
 
         if (!empty($errors)) {
             // Dedupe repeated error strings (e.g., "Invalid row format in

+ 546 - 0
studiou-wc-ord-print-statuses/includes/class-order-item-status-manager.php

@@ -0,0 +1,546 @@
+<?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.
+ *
+ * One scoped exception to the gate above — the HPOS Orders list "Change status
+ * to Completed" bulk action ONLY: a priority-9 pre-advance moves every still
+ * pending/in-print item on each *non-terminal* selected order to `done-print`
+ * BEFORE WooCommerce flips the status, so the completion guard then passes
+ * instead of reverting. Terminal/already-completed orders are skipped. This does
+ * NOT apply to single-order, programmatic, or import completions. See
+ * bulk_complete_advance_items() and docs/order-done-plan-01.md.
+ *
+ * Belt-and-suspenders: the completion guard itself carries a fallback for the
+ * same bulk entry point (is_bulk_complete_request()). If the priority-9
+ * pre-advance ever fails to run before WooCommerce's status flip, the guard
+ * advances the remaining items on the order it just completed instead of
+ * reverting — priority-independent, and on the same WC_Order instance. Terminal
+ * source statuses are still reverted, preserving the skip above.
+ */
+
+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);
+        // Proprietary pre-advance for the HPOS Orders list "Change status to
+        // Completed" bulk action. Priority 9 runs before WooCommerce's own
+        // priority-10 mark_completed handler on the same filter.
+        add_filter('handle_bulk_actions-woocommerce_page_wc-orders', array($this, 'bulk_complete_advance_items'), 9, 3);
+    }
+
+    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;
+            }
+
+            // §7 fallback (belt-and-suspenders for the priority-9 pre-advance in
+            // bulk_complete_advance_items()). If this completion is the HPOS
+            // Orders-list "Change status to Completed" bulk action and the order
+            // was NOT previously terminal, advance the remaining items here —
+            // priority-independent and operating on the SAME $order instance WC
+            // just completed, so it survives a future WC change to mark_*
+            // handling and any two-instance object-cache skew. Terminal source
+            // statuses (cancelled/refunded/failed) are still reverted below,
+            // matching the bulk handler's terminal-skip — see the class-level
+            // coupling notes and docs/order-done-plan-01.md §6/§7.
+            if ($this->is_bulk_complete_request()
+                && !in_array($from, Studiou_DB_Manager::TERMINAL_STATUSES, true)) {
+                $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
+                    ));
+                }
+                UtilsLog::log(sprintf(
+                    'Order #%d completed via bulk action; guard-fallback advanced %d item(s) to done-print (priority-9 pre-advance did not run first).',
+                    $order_id,
+                    $advanced
+                ));
+                return; // allow completion to stand
+            }
+
+            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');
+        }
+    }
+
+    /**
+     * True when the current request is the HPOS Orders-list "Change status to
+     * Completed" bulk action (`page=wc-orders` + the resolved bulk action is
+     * `mark_completed`). Mirrors WP_List_Table::current_action() — the active
+     * action is `action` unless it is the "-1" placeholder, in which case it is
+     * `action2` (the bottom-of-table dropdown).
+     *
+     * Scopes the §7 guard-fallback to exactly the same entry point as
+     * bulk_complete_advance_items(): a single-order edit-screen save posts
+     * `action=edit` (not `mark_completed`), and REST/cron/WP-CLI are not
+     * `is_admin()`, so none of them trip this.
+     */
+    private function is_bulk_complete_request() {
+        if (!is_admin()) {
+            return false;
+        }
+        if (!isset($_REQUEST['page']) || $_REQUEST['page'] !== 'wc-orders') {
+            return false;
+        }
+        $action = '';
+        if (isset($_REQUEST['action']) && $_REQUEST['action'] !== '-1') {
+            $action = sanitize_text_field(wp_unslash($_REQUEST['action']));
+        } elseif (isset($_REQUEST['action2']) && $_REQUEST['action2'] !== '-1') {
+            $action = sanitize_text_field(wp_unslash($_REQUEST['action2']));
+        }
+        return $action === 'mark_completed';
+    }
+
+    /* ---------------------------------------------------------------- *
+     * Bulk "Change status to Completed" pre-advance (HPOS Orders list)
+     * ---------------------------------------------------------------- */
+
+    /**
+     * 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. Hooked to
+     * `handle_bulk_actions-woocommerce_page_wc-orders` at priority 9 (before WC's
+     * own 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).
+     *
+     * @param string $redirect_to
+     * @param string $action
+     * @param int[]  $ids
+     * @return string The (unchanged) redirect URL.
+     */
+    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;
+    }
+}

BIN
studiou-wc-ord-print-statuses/languages/studiou-wc-ord-print-statuses-cs_CZ.mo


+ 88 - 1
studiou-wc-ord-print-statuses/languages/studiou-wc-ord-print-statuses-cs_CZ.po

@@ -2,7 +2,7 @@
 # This file is distributed under the GPL2 license.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Order Print Statuses 1.4.3\n"
+"Project-Id-Version: QDR - Studiou WC Order Print Statuses 1.5.1\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com/plugins/studiou-wc-ord-print-statuses\n"
 "POT-Creation-Date: 2026-05-12 00:00:00+00:00\n"
 "PO-Revision-Date: 2026-05-12 00:00:00+00:00\n"
@@ -174,6 +174,9 @@ msgstr "CSV export nebylo možné odeslat, protože výstup již začal. Zkontro
 msgid "Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support."
 msgstr "Export vyžaduje tabulku WooCommerce analytics (wc_order_product_lookup). Aktivujte WooCommerce Analytics nebo kontaktujte podporu."
 
+msgid "Export requires WooCommerce HPOS (custom order tables). Enable High-Performance Order Storage in WooCommerce → Settings → Advanced → Features, or contact support."
+msgstr "Export vyžaduje WooCommerce HPOS (vlastní tabulky objednávek). Aktivujte High-Performance Order Storage v WooCommerce → Nastavení → Pokročilé → Funkce, nebo kontaktujte podporu."
+
 # --- Order notes passed to update_status from import paths (new in 1.4.3) ---
 
 msgid "Marked In Printing by InPrint Protocol import."
@@ -181,3 +184,87 @@ msgstr "Označeno jako \"V Tisku\" importem protokolu V Tisku."
 
 msgid "Marked completed by Delivered Protocol import."
 msgstr "Označeno jako \"Dokončeno\" importem protokolu Tisk Dodán."
+
+# --- Order notes passed to update_status from bulk-action paths (new in 1.4.4) ---
+
+msgid "Marked Prepare to Printing by bulk action."
+msgstr "Označeno jako \"Příprava Tisku\" hromadnou akcí."
+
+msgid "Marked In Printing by bulk action."
+msgstr "Označeno jako \"V Tisku\" hromadnou akcí."
+
+# --- Import-summary info notice (new in 1.4.5) ---
+
+msgid "The CSV file contained no actionable rows (empty order_no column)."
+msgstr "CSV soubor neobsahoval žádné použitelné řádky (sloupec order_no je prázdný)."
+
+# --- Per-order-item print status feature (new in 1.5.0) ---
+
+# Item status labels
+msgid "Pending Print"
+msgstr "Čeká na Tisk"
+
+msgid "In Print"
+msgstr "V Tisku"
+
+msgid "Done Print"
+msgstr "Vytištěno"
+
+msgid "Skip Print"
+msgstr "Přeskočit Tisk"
+
+# Items-table column header (introduced by the same column header symbol used by Order_Item_Status_Manager — distinct from "External Order Number" / column titles above)
+msgid "Print Status"
+msgstr "Stav Tisku"
+
+# Per-item audit note
+msgid "Item #%1$d (%2$s): %3$s → %4$s."
+msgstr "Položka č. %1$d (%2$s): %3$s → %4$s."
+
+# Propagation summary (order → in-print)
+msgid "%d line item advanced to \"In Print\" by order-status change."
+msgid_plural "%d line items advanced to \"In Print\" by order-status change."
+msgstr[0] "%d položka přesunuta na \"V Tisku\" kvůli změně stavu objednávky."
+msgstr[1] "%d položky přesunuty na \"V Tisku\" kvůli změně stavu objednávky."
+msgstr[2] "%d položek přesunuto na \"V Tisku\" kvůli změně stavu objednávky."
+
+# Delivered Protocol per-order advance note
+msgid "%d line item advanced to \"Done Print\" by Delivered Protocol import."
+msgid_plural "%d line items advanced to \"Done Print\" by Delivered Protocol import."
+msgstr[0] "%d položka přesunuta na \"Vytištěno\" importem protokolu Tisk Dodán."
+msgstr[1] "%d položky přesunuty na \"Vytištěno\" importem protokolu Tisk Dodán."
+msgstr[2] "%d položek přesunuto na \"Vytištěno\" importem protokolu Tisk Dodán."
+
+# Completion guard
+msgid "Cannot complete: product line items still pending print. Resolve item statuses first."
+msgstr "Nelze dokončit: položky objednávky ještě nejsou vytištěné. Nejprve vyřešte stavy položek."
+
+msgid "Order #%1$d completion blocked — %2$d product items not yet \"Done Print\" or \"Skip Print\"."
+msgstr "Dokončení objednávky č. %1$d zablokováno — %2$d položek dosud není ve stavu \"Vytištěno\" nebo \"Přeskočit Tisk\"."
+
+# Delivered Protocol cross-import summary
+msgid "%d line item advanced to \"Done Print\" to satisfy completion guard."
+msgid_plural "%d line items advanced to \"Done Print\" to satisfy completion guard."
+msgstr[0] "%d položka přesunuta na \"Vytištěno\" pro splnění podmínky dokončení."
+msgstr[1] "%d položky přesunuty na \"Vytištěno\" pro splnění podmínky dokončení."
+msgstr[2] "%d položek přesunuto na \"Vytištěno\" pro splnění podmínky dokončení."
+
+# --- Bulk "Change status to Completed" pre-advance (new in 1.5.1) ---
+
+# Per-order advance note
+msgid "%d line item advanced to \"Done Print\" before bulk completion."
+msgid_plural "%d line items advanced to \"Done Print\" before bulk completion."
+msgstr[0] "%d položka přesunuta na \"Vytištěno\" před hromadným dokončením."
+msgstr[1] "%d položky přesunuty na \"Vytištěno\" před hromadným dokončením."
+msgstr[2] "%d položek přesunuto na \"Vytištěno\" před hromadným dokončením."
+
+# Batch success summary notice
+msgid "Auto-advanced %1$d line item(s) to \"Done Print\" across %2$d order(s) before completion."
+msgstr "Automaticky přesunuto %1$d položek na \"Vytištěno\" v %2$d objednávkách před dokončením."
+
+# Per-order failure warning notice
+msgid "%d order could not be pre-advanced and may not complete — check the debug log."
+msgid_plural "%d orders could not be pre-advanced and may not complete — check the debug log."
+msgstr[0] "%d objednávku se nepodařilo předem připravit a nemusí být dokončena — zkontrolujte ladicí log."
+msgstr[1] "%d objednávky se nepodařilo předem připravit a nemusí být dokončeny — zkontrolujte ladicí log."
+msgstr[2] "%d objednávek se nepodařilo předem připravit a nemusí být dokončeno — zkontrolujte ladicí log."

+ 19 - 13
studiou-wc-ord-print-statuses/studiou-wc-ord-print-statuses.php

@@ -3,7 +3,7 @@
  * Plugin Name: QDR - Studiou WC Order Print Statuses
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-ord-print-statuses
  * Description: Adds custom order statuses (wc-to-print, wc-in-print), bulk actions, and status change timestamps to WooCommerce for print orders to third party providers
- * Version: 1.4.3
+ * Version: 1.5.1
  * Requires at least: 5.8
  * Tested up to: 6.9.4
  * Requires PHP: 7.2
@@ -19,15 +19,18 @@
 
 defined('ABSPATH') || exit;
 
-define('STUDIOU_WC_OPS_VERSION', '1.4.3');
+define('STUDIOU_WC_OPS_VERSION', '1.5.1');
 define('STUDIOU_WC_OPS_FILE', __FILE__);
 
-// Declare HPOS (High-Performance Order Storage) compatibility
-add_action('before_woocommerce_init', function () {
+// Declare HPOS (High-Performance Order Storage) compatibility.
+// Named function so other plugins or test harnesses can remove_action it
+// if they need to override the declaration.
+function studiou_wc_ord_print_statuses_declare_hpos_compat() {
     if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
-        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
+        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', STUDIOU_WC_OPS_FILE, true);
     }
-});
+}
+add_action('before_woocommerce_init', 'studiou_wc_ord_print_statuses_declare_hpos_compat');
 
 // load logging feature
 require_once plugin_dir_path(__FILE__) . 'includes/utils-log.php';
@@ -40,6 +43,7 @@ class Studiou_WC_Ord_Print_Statuses {
     private $bulk_actions_manager;
     private $custom_columns_manager;
     private $import_manager;
+    private $order_item_status_manager;
 
     public static function initPlugin() {
         $class = __CLASS__;
@@ -69,7 +73,8 @@ class Studiou_WC_Ord_Print_Statuses {
             'class-order-search-manager.php',
             'class-bulk-actions-manager.php',
             'class-custom-columns-manager.php',
-            'class-import-manager.php'
+            'class-import-manager.php',
+            'class-order-item-status-manager.php'
         );
 
         foreach ($files as $file) {
@@ -78,12 +83,13 @@ class Studiou_WC_Ord_Print_Statuses {
     }
 
     private function initialize_managers() {
-        $this->order_status_manager   = new Order_Status_Manager();
-        $this->order_fields_manager   = new Order_Fields_Manager();
-        $this->order_search_manager   = new Order_Search_Manager();
-        $this->bulk_actions_manager   = new Bulk_Actions_Manager();
-        $this->custom_columns_manager = new Custom_Columns_Manager();
-        $this->import_manager         = new Import_Manager();
+        $this->order_status_manager      = new Order_Status_Manager();
+        $this->order_fields_manager      = new Order_Fields_Manager();
+        $this->order_search_manager      = new Order_Search_Manager();
+        $this->bulk_actions_manager      = new Bulk_Actions_Manager();
+        $this->custom_columns_manager    = new Custom_Columns_Manager();
+        $this->import_manager            = new Import_Manager();
+        $this->order_item_status_manager = new Order_Item_Status_Manager();
     }
 
     public function woocommerce_missing_notice() {