Prechádzať zdrojové kódy

Release v1.5.0 — per-order-item print status feature

Implements the feature described in docs/feature-order-item-status-analysis.md.
Minor version bump because this is a new feature, not a bug-fix.

What's new:
- Four item-level statuses: pending-print (initial), in-print, done-print,
  skip-print. Stored as _print_status meta on each WC_Order_Item_Product
  in {$wpdb->prefix}woocommerce_order_itemmeta. Missing meta defaults to
  pending-print (lazy — no migration needed for existing orders).
- "Print Status" column added to the order edit screen's Items table.
  Per-row <select> lets the operator change each product item's status
  manually. Non-product items show — and aren't editable.
- Order → wc-in-print transitions automatically advance every
  pending-print item to in-print. Other item statuses are untouched.
- Order → wc-completed transitions are gated: blocked when any product
  item is still pending-print or in-print. Blocked transitions revert
  to the previous status with an admin notice listing the unready count.
  Existing already-completed orders are unaffected.
- Order activity feed records every item-status change ("Item #5
  (Product A): Pending Print → Done Print.") and batch propagations
  ("3 line items advanced to In Print by order-status change.").
- Delivered Protocol import calls advance_remaining_to_done() before
  each order's status flip so the completion guard is satisfied
  automatically. Per-order and cross-import notices report the count.
- InPrint Protocol import benefits from the propagation listener
  without needing explicit per-item code.
- Bulk action "Set Status to In Printing" now also propagates items
  via the listener (no Bulk_Actions_Manager change). "Set Status to
  Prepare to Printing" stays order-only.
- CSV export adds prod_print_status column. The SQL LEFT JOINs
  woocommerce_order_itemmeta on order_item_id filtered to _print_status,
  with COALESCE(..., 'pending-print') for the lazy default.

New class:
- includes/class-order-item-status-manager.php — owns UI rendering,
  POST save, propagation/guard listener (priority 5 on
  woocommerce_order_status_changed, with $inside_self_revert flag for
  recursion safety), and the advance_pending_to_in /
  advance_remaining_to_done helpers consumed by import_delivered_protocol.

Coupling rules summary (see README §"Per-order-item print status"):
  Order → wc-in-print     → pending items advance to in-print
  Order → wc-completed    → gated (no propagation, just blocks)
  All other transitions   → no item change
  Item status changes     → never propagate to the order

Docs:
- README "Changelog" extends with the 1.5.0 entry; new feature section
  with the coupling-rules table; CSV column list updated.
- CLAUDE.md gets a new manager entry (#8) and a 1.5.0 Recent Changes
  block.
- docs/translations.txt: 12 new EN→CS entries (status labels, column
  header, audit-note format, propagation/guard notices, Delivered
  Protocol advance summary).
- languages/studiou-wc-ord-print-statuses-cs_CZ.po: Project-Id-Version
  bumped to 1.5.0; same new strings with Czech 3-form plurals.

Known limitations carried into 1.5.0: classic order edit screen only —
WC 10.x block-based order edit screen unsupported (tracked as phase 2).

NOTE: .mo not rebuilt — run `wp i18n make-mo languages/` or compile
via Poedit before the new Czech wording appears on the site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dalibor Votruba 2 mesiacov pred
rodič
commit
d6f2d7ccbf

+ 24 - 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.5
+- Current Version: 1.5.0
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
 - Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
 
@@ -31,6 +31,18 @@ When adding new documentation files, place them in `docs/` and add a one-line po
 
 ## Recent Changes
 
+### 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.
@@ -196,6 +208,17 @@ 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.
+
 ### Key Database Operations
 
 The export query in `Studiou_DB_Manager::get_orders_for_printing()` performs a complex join across:

+ 51 - 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.5
+- **Current version:** 1.5.0
 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
 - **License:** GPL v2 or later
 
@@ -74,7 +74,37 @@ 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.
+
+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 +126,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.
@@ -220,6 +252,22 @@ Project-internal guidance for AI-assisted development is documented in [`CLAUDE.
 
 ## Changelog
 
+### 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):

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

@@ -1,5 +1,5 @@
 Translations: all user-facing strings (English → Czech).
-Last reviewed: v1.4.5 (2026-05-12).
+Last reviewed: v1.5.0 (2026-05-12).
 
 Note: After editing the .po file, regenerate the .mo binary with one of:
   wp i18n make-mo languages/
@@ -84,3 +84,28 @@ Export requires WooCommerce HPOS (custom order tables). Enable High-Performance
 
 # 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í.

+ 46 - 1
studiou-wc-ord-print-statuses/includes/class-db-manager.php

@@ -81,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`,
@@ -90,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`
@@ -106,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`
@@ -332,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`
@@ -339,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');
@@ -358,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')
@@ -365,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);

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

@@ -0,0 +1,349 @@
+<?php
+/**
+ * Per-order-item print status — UI, persistence, propagation, completion guard.
+ *
+ * See docs/feature-order-item-status-analysis.md for the design rationale.
+ *
+ * Coupling rules (one-way, order → items):
+ * - Order → wc-in-print: items in `pending-print` advance to `in-print`.
+ *   Other item statuses are untouched.
+ * - Order → wc-completed: gated, not propagating. Blocked when any product
+ *   line item is still `pending-print` or `in-print`.
+ * - All other order-status transitions: no automatic item change.
+ * - Item status changed manually: never propagates to the order.
+ */
+
+defined('ABSPATH') || exit;
+
+class Order_Item_Status_Manager {
+    const META_KEY = '_print_status';
+
+    const STATUS_PENDING = 'pending-print';
+    const STATUS_IN      = 'in-print';
+    const STATUS_DONE    = 'done-print';
+    const STATUS_SKIP    = 'skip-print';
+
+    /**
+     * Statuses that satisfy the completion guard. Anything not in this list
+     * blocks the order from transitioning to `wc-completed`.
+     */
+    const STATUSES_THAT_SATISFY_COMPLETION = array(
+        self::STATUS_DONE,
+        self::STATUS_SKIP,
+    );
+
+    /**
+     * Re-entrancy flag for the completion-guard revert path. The revert calls
+     * $order->update_status() which fires woocommerce_order_status_changed
+     * again — without this flag we'd recurse indefinitely on a blocked
+     * completion attempt.
+     */
+    private static $inside_self_revert = false;
+
+    public function __construct() {
+        add_action('woocommerce_admin_order_item_headers', array($this, 'render_column_header'));
+        add_action('woocommerce_admin_order_item_values',  array($this, 'render_column_value'), 10, 3);
+        add_action('woocommerce_process_shop_order_meta',  array($this, 'save_item_statuses_from_post'));
+        add_action('woocommerce_order_status_changed',     array($this, 'on_order_status_changed'), 5, 4);
+    }
+
+    public static function valid_statuses() {
+        return array(
+            self::STATUS_PENDING,
+            self::STATUS_IN,
+            self::STATUS_DONE,
+            self::STATUS_SKIP,
+        );
+    }
+
+    /**
+     * Translatable labels for the four statuses. Built lazily so the
+     * translation lookup happens after the text domain is loaded.
+     */
+    public static function labels() {
+        return array(
+            self::STATUS_PENDING => __('Pending Print', 'studiou-wc-ord-print-statuses'),
+            self::STATUS_IN      => __('In Print',      'studiou-wc-ord-print-statuses'),
+            self::STATUS_DONE    => __('Done Print',    'studiou-wc-ord-print-statuses'),
+            self::STATUS_SKIP    => __('Skip Print',    'studiou-wc-ord-print-statuses'),
+        );
+    }
+
+    /**
+     * Return the item's current print status. Missing meta defaults to
+     * STATUS_PENDING — lazy default avoids a one-time migration over all
+     * existing orders.
+     */
+    public function get_status($item) {
+        if (!$item instanceof WC_Order_Item_Product) {
+            return self::STATUS_PENDING;
+        }
+        $value = (string) $item->get_meta(self::META_KEY);
+        if (in_array($value, self::valid_statuses(), true)) {
+            return $value;
+        }
+        return self::STATUS_PENDING;
+    }
+
+    /**
+     * Persist a new status for a single item. Does NOT call $item->save() —
+     * the caller is responsible for persistence (so callers that batch
+     * multiple item updates can save in one round-trip).
+     *
+     * @param WC_Order_Item_Product $item
+     * @param string                $status One of the four constants.
+     * @param bool                  $audit  If true, append an order note describing the transition.
+     * @return bool true if the meta was updated (or already at the requested value), false if rejected.
+     */
+    public function set_status($item, $status, $audit = true) {
+        if (!$item instanceof WC_Order_Item_Product) {
+            return false;
+        }
+        if (!in_array($status, self::valid_statuses(), true)) {
+            return false;
+        }
+
+        $current = $this->get_status($item);
+        if ($current === $status) {
+            return true; // no-op
+        }
+
+        $item->update_meta_data(self::META_KEY, $status);
+
+        if ($audit) {
+            $order = $item->get_order();
+            if ($order) {
+                $labels = self::labels();
+                $order->add_order_note(sprintf(
+                    /* translators: 1: item ID, 2: product name, 3: old status label, 4: new status label */
+                    __('Item #%1$d (%2$s): %3$s → %4$s.', 'studiou-wc-ord-print-statuses'),
+                    $item->get_id(),
+                    wp_strip_all_tags((string) $item->get_name()),
+                    $labels[$current],
+                    $labels[$status]
+                ));
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Inspect every product line item on the order. Returns an array with
+     * keys 'ready' (bool) and 'blocking' (array of WC_Order_Item_Product
+     * instances still in pending-print or in-print).
+     *
+     * Non-product items (shipping, fees, coupons, taxes, refunds) are
+     * ignored — they don't carry a print status and never block.
+     *
+     * @param WC_Order $order
+     * @return array{ready:bool, blocking:WC_Order_Item_Product[]}
+     */
+    public function is_order_ready_for_completion($order) {
+        $blocking = array();
+        foreach ($order->get_items() as $item) {
+            if (!$item instanceof WC_Order_Item_Product) {
+                continue;
+            }
+            $status = $this->get_status($item);
+            if (!in_array($status, self::STATUSES_THAT_SATISFY_COMPLETION, true)) {
+                $blocking[] = $item;
+            }
+        }
+        return array(
+            'ready'    => empty($blocking),
+            'blocking' => $blocking,
+        );
+    }
+
+    /**
+     * Advance every product item currently in `pending-print` to `in-print`.
+     * Used by the propagation listener on order → wc-in-print transitions.
+     * Items in `done-print` or `skip-print` are untouched.
+     *
+     * @param WC_Order $order
+     * @return int Number of items advanced.
+     */
+    public function advance_pending_to_in($order) {
+        $count = 0;
+        foreach ($order->get_items() as $item) {
+            if (!$item instanceof WC_Order_Item_Product) {
+                continue;
+            }
+            if ($this->get_status($item) !== self::STATUS_PENDING) {
+                continue;
+            }
+            // Skip per-item audit notes — the caller adds a single summary
+            // note ("N items advanced to In Print …") for the whole batch.
+            $this->set_status($item, self::STATUS_IN, false);
+            $item->save();
+            $count++;
+        }
+        return $count;
+    }
+
+    /**
+     * Advance every product item still in `pending-print` or `in-print` to
+     * `done-print`. Used by Delivered Protocol import before flipping the
+     * order to `wc-completed` so the completion guard is satisfied.
+     *
+     * @param WC_Order $order
+     * @return int Number of items advanced.
+     */
+    public function advance_remaining_to_done($order) {
+        $count = 0;
+        foreach ($order->get_items() as $item) {
+            if (!$item instanceof WC_Order_Item_Product) {
+                continue;
+            }
+            $current = $this->get_status($item);
+            if (!in_array($current, array(self::STATUS_PENDING, self::STATUS_IN), true)) {
+                continue;
+            }
+            $this->set_status($item, self::STATUS_DONE, false);
+            $item->save();
+            $count++;
+        }
+        return $count;
+    }
+
+    /* ---------------------------------------------------------------- *
+     * UI rendering
+     * ---------------------------------------------------------------- */
+
+    /**
+     * Render the new column header in the order-items table. Fires inside
+     * `<thead>` for the product items section of the order edit screen.
+     */
+    public function render_column_header(/* $order */) {
+        echo '<th class="item_print_status sortable" data-sort="string-ins">'
+            . esc_html__('Print Status', 'studiou-wc-ord-print-statuses')
+            . '</th>';
+    }
+
+    /**
+     * Render the per-row cell. Fires once per product line item in the
+     * order-items table. Non-product items are handled by their own
+     * templates, which don't fire this hook.
+     */
+    public function render_column_value($product, $item, $item_id) {
+        if (!$item instanceof WC_Order_Item_Product) {
+            echo '<td class="item_print_status">—</td>';
+            return;
+        }
+        $current = $this->get_status($item);
+        $labels  = self::labels();
+        echo '<td class="item_print_status" data-print-status="' . esc_attr($current) . '">';
+        echo '<select name="print_status[' . esc_attr((string) $item_id) . ']" class="studiou-print-status-select">';
+        foreach (self::valid_statuses() as $slug) {
+            printf(
+                '<option value="%1$s"%2$s>%3$s</option>',
+                esc_attr($slug),
+                selected($slug, $current, false),
+                esc_html($labels[$slug])
+            );
+        }
+        echo '</select>';
+        echo '</td>';
+    }
+
+    /* ---------------------------------------------------------------- *
+     * Save handler
+     * ---------------------------------------------------------------- */
+
+    /**
+     * Persist any submitted item-status changes from $_POST['print_status'].
+     * Hooked to `woocommerce_process_shop_order_meta` (fires after WC's own
+     * item processing on form submit). HPOS- and CPT-compatible.
+     */
+    public function save_item_statuses_from_post($order_id) {
+        if (!current_user_can('edit_shop_order', $order_id) && !current_user_can('edit_shop_orders')) {
+            return;
+        }
+        if (empty($_POST['print_status']) || !is_array($_POST['print_status'])) {
+            return;
+        }
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+        $valid_statuses = self::valid_statuses();
+        foreach (wp_unslash($_POST['print_status']) as $item_id => $new_status) {
+            $new_status = sanitize_text_field((string) $new_status);
+            if (!in_array($new_status, $valid_statuses, true)) {
+                continue;
+            }
+            $item = $order->get_item((int) $item_id);
+            if (!$item instanceof WC_Order_Item_Product) {
+                continue;
+            }
+            if ($this->set_status($item, $new_status)) {
+                $item->save();
+            }
+        }
+    }
+
+    /* ---------------------------------------------------------------- *
+     * Combined propagation + completion guard listener
+     * ---------------------------------------------------------------- */
+
+    /**
+     * Hooked to `woocommerce_order_status_changed` at priority 5.
+     *
+     * Two behaviours:
+     *   1. Propagation: on `→ in-print`, advance pending items to in-print.
+     *      Includes manual admin dropdown changes, bulk actions, and the
+     *      InPrint Protocol import — they all flow through update_status.
+     *   2. Guard: on `→ completed`, check that every product item is in
+     *      done-print or skip-print. If not, revert the status to $from
+     *      and surface an admin notice.
+     */
+    public function on_order_status_changed($order_id, $from, $to, $order) {
+        if (self::$inside_self_revert) {
+            return;
+        }
+
+        if ($to === 'in-print') {
+            $advanced = $this->advance_pending_to_in($order);
+            if ($advanced > 0) {
+                $order->add_order_note(sprintf(
+                    /* translators: %d is the number of items advanced. */
+                    _n(
+                        '%d line item advanced to "In Print" by order-status change.',
+                        '%d line items advanced to "In Print" by order-status change.',
+                        $advanced,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $advanced
+                ));
+            }
+            return;
+        }
+
+        if ($to === 'completed') {
+            $check = $this->is_order_ready_for_completion($order);
+            if ($check['ready']) {
+                return;
+            }
+            self::$inside_self_revert = true;
+            try {
+                $order->update_status(
+                    $from,
+                    __('Cannot complete: product line items still pending print. Resolve item statuses first.', 'studiou-wc-ord-print-statuses')
+                );
+                $order->save();
+            } finally {
+                self::$inside_self_revert = false;
+            }
+            UtilsLog::message(
+                sprintf(
+                    /* translators: 1: order ID, 2: number of blocking items. */
+                    __('Order #%1$d completion blocked — %2$d product items not yet "Done Print" or "Skip Print".', 'studiou-wc-ord-print-statuses'),
+                    $order_id,
+                    count($check['blocking'])
+                ),
+                'error'
+            );
+            UtilsLog::log('Order #' . $order_id . ' completion blocked: ' . count($check['blocking']) . ' item(s) not ready');
+        }
+    }
+}

+ 52 - 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.5\n"
+"Project-Id-Version: QDR - Studiou WC Order Print Statuses 1.5.0\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"
@@ -197,3 +197,54 @@ msgstr "Označeno jako \"V Tisku\" hromadnou akcí."
 
 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í."

+ 12 - 9
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.5
+ * Version: 1.5.0
  * Requires at least: 5.8
  * Tested up to: 6.9.4
  * Requires PHP: 7.2
@@ -19,7 +19,7 @@
 
 defined('ABSPATH') || exit;
 
-define('STUDIOU_WC_OPS_VERSION', '1.4.5');
+define('STUDIOU_WC_OPS_VERSION', '1.5.0');
 define('STUDIOU_WC_OPS_FILE', __FILE__);
 
 // Declare HPOS (High-Performance Order Storage) compatibility.
@@ -43,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__;
@@ -72,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) {
@@ -81,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() {