Bläddra i källkod

Release v1.4.0 — fix every finding from revise-1.3.2 + WC 10.7.0/WP 6.9.4

Code changes (all bugs catalogued in docs/revise-1.3.2.md are addressed):

- Order_Search_Manager rewritten: drops broken parse_query/edit.php guard
  and column-as-search-field hooks; now uses woocommerce_shop_order_search_fields
  (legacy) and woocommerce_order_table_search_query_meta_keys (HPOS) with the
  correctly-spelled meta key. The standard "Search orders" box now finds orders
  by external_ref_ord_no.
- Order_Status_Manager.handle_status_transitions writes through the WC_Order
  object instead of update_post_meta — HPOS-safe stamping of print dates.
- set_order_processing_status guards to-print, in-print, and completed against
  late-payment demotion.
- Bulk export is atomic: CSV is built before any status change; UTF-8 BOM and
  charset=utf-8 header for Excel-on-Windows correctness; nocache_headers().
- Export SQL now uses $wpdb->prepare() with %d placeholders and collapses
  multi-category products via GROUP_CONCAT(DISTINCT) + GROUP BY per line item.
- CSV imports deduplicate by order_no (per the original spec), normalise
  header casing so both "Order No" and "order_no" work, validate $_FILES
  errors and is_uploaded_file(), skip orders in terminal statuses.
- Bulk-action handler guards with current_user_can('edit_shop_orders').
- UtilsLog::log() guard relaxed to defined('WP_DEBUG') && WP_DEBUG;
  UtilsLog::message() implemented as per-user transient + admin_notices hook
  so bulk actions and imports produce visible feedback after the redirect.
- parse_csv() drops the 1000-byte line cap, validates header row, normalises
  header casing, skips rows with column-count mismatch.
- external_ref_ord_date is parsed via strtotime() and exposed in the
  Print Information panel on the order edit screen.
- Custom_Columns_Manager parameter renamed $order_or_id; wc_get_order() handles
  both HPOS and legacy storage shapes.
- Bootstrap: STUDIOU_WC_OPS_VERSION / STUDIOU_WC_OPS_FILE constants defined;
  text-domain loader pinned to plugins_loaded priority 5 so translations are
  loaded before any manager constructor evaluates _x()/_n_noop().

Documentation:

- README.md: comprehensive 1.4.0 changelog, updated Print Information field
  list (adds external_ref_ord_date), reworded Search section, BOM/dedup
  notes. Pointer to docs/revise-1.3.2.md added.
- CLAUDE.md: 1.4.0 entry under Recent Changes, corrected the misleading
  SQL-prepare-via-intval claim, expanded Logging section.
- docs/translations.txt: extended with all strings previously missing
  (notices, errors, plurals, payment-complete notes). .mo NOT rebuilt;
  needs wp i18n make-mo or Poedit pass.
- docs/revise-1.4.0.md: new analytical review of 1.4.0 covering newly
  introduced risks (GROUP BY under ONLY_FULL_GROUP_BY, BOM-in-imported-CSV,
  WP-vs-server timezone drift in datetime-local fields, etc.) plus a
  matrix mapping every 1.3.2 finding to its 1.4.0 outcome.

Tested-against bump: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled).
Plugin header, README requirements table, CLAUDE.md, and the revise-1.4.0
target-environment / verification-gaps sections updated accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dalibor Votruba 2 månader sedan
förälder
incheckning
395b9efb3d

+ 31 - 9
studiou-wc-ord-print-statuses/CLAUDE.md

@@ -8,9 +8,9 @@ This is a WordPress plugin that extends WooCommerce functionality to manage prin
 
 **Plugin Details:**
 - Text Domain: `studiou-wc-ord-print-statuses`
-- Current Version: 1.3.2
+- Current Version: 1.4.0
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
-- Tested with: WordPress 6.8.3, WooCommerce 10.2.2
+- Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
 
 ## Documentation
 
@@ -20,11 +20,31 @@ User-facing and historical documentation lives at the project root and under `do
 - [`docs/readme.md`](docs/readme.md) — Previous user-facing readme; retained for history.
 - [`docs/instructions.txt`](docs/instructions.txt) — Original feature specification used to scaffold the plugin. Useful when verifying intended behaviour against current implementation.
 - [`docs/translations.txt`](docs/translations.txt) — EN → CS translation reference for the bundled `cs_CZ` locale.
+- [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md) — Analytical review of v1.3.2. Every finding listed there is resolved in 1.4.0 (see README changelog).
 
 When adding new documentation files, place them in `docs/` and add a one-line pointer here.
 
 ## Recent Changes
 
+### Version 1.4.0 (2026-05-12)
+
+Comprehensive fix-up release addressing every finding in `docs/revise-1.3.2.md`:
+
+- **Search by External Order Number** is now functional. Reworked `Order_Search_Manager` uses `woocommerce_shop_order_search_fields` (legacy) and `woocommerce_order_table_search_query_meta_keys` (HPOS) — the standard "Search orders" input now matches `external_ref_ord_no`. Removed all broken hooks (`parse_query` with CPT-only guard, columns-filter-as-search-fields-filter, search input rendered inside table cells).
+- **HPOS metadata stamping** fixed in `Order_Status_Manager::handle_status_transitions()` — now uses `wc_get_order() + update_meta_data() + save()` instead of `update_post_meta()`.
+- **`set_order_processing_status()`** now treats `to-print`, `in-print`, and `completed` as protected; late `woocommerce_payment_complete` no longer demotes print-state orders.
+- **Bulk export atomicity:** CSV is built fully in memory before any status change. UTF-8 BOM prepended; `Content-Type: text/csv; charset=utf-8`; `nocache_headers()`.
+- **Export SQL** now uses `$wpdb->prepare()` with `%d` placeholders (replaces the `intval()`-only defense). `GROUP_CONCAT(DISTINCT t.name)` + `GROUP BY orders.id, product_id, variation_id` collapses multi-category products to one row per line item.
+- **CSV import** deduplicates by `order_no`; headers are normalised (lowercased, internal whitespace → `_`), so both spec casing (`Order No`) and the documented lowercase form (`order_no`) work; orders in terminal statuses are skipped with a per-row error.
+- **File-upload validation:** `$_FILES[...]['error']`, `is_uploaded_file()`, and non-empty header row are now checked server-side.
+- **Cap checks:** bulk-action handler guards with `current_user_can('edit_shop_orders')` before dispatch.
+- **UtilsLog:** `log()` guard relaxed to `defined('WP_DEBUG') && WP_DEBUG`; `message()` implemented as per-user transient → `admin_notices` hook. Bulk actions and imports now produce visible feedback.
+- **Order_Fields_Manager:** added `external_ref_ord_date` field to the Print Information panel; datetime values round-trip through `strtotime()` → MySQL datetime.
+- **Custom_Columns_Manager:** column-content callback parameter renamed to `$order_or_id` to reflect HPOS contract; passes through `wc_get_order()`.
+- **Bootstrap:** `STUDIOU_WC_OPS_VERSION` / `STUDIOU_WC_OPS_FILE` constants defined. Text domain loader pinned to `plugins_loaded` priority 5; plugin init at default priority 10 — guarantees translations are loaded before any manager instantiates.
+- **Translations:** `docs/translations.txt` extended with previously-missing strings. The `.mo` file must be rebuilt by hand (e.g. `wp i18n make-mo languages/` or Poedit); 1.4.0 ships only the source updates.
+- **Docs:** README architecture, install, usage, and changelog updated. Misleading "SQL prepare via intval" claim in CLAUDE.md removed.
+
 ### Version 1.3.2 (2025-10-18)
 **Bug Fix: Empty prod_var_type column in CSV export**
 
@@ -71,7 +91,8 @@ The plugin uses a manager-based architecture where the main plugin class (`Studi
 
 2. **Order_Fields_Manager** (`includes/class-order-fields-manager.php`)
    - Adds "Print Information" section to order edit pages
-   - Manages fields: `to_print_date`, `in_print_date`, `external_ref_ord_no`, `external_ref_ord_delivered`
+   - Manages fields: `to_print_date`, `in_print_date`, `external_ref_ord_no`, `external_ref_ord_date`, `external_ref_ord_delivered`
+   - Datetime fields round-trip via `strtotime()`/MySQL datetime so admin edits stay consistent with values written by imports.
 
 3. **Bulk_Actions_Manager** (`includes/class-bulk-actions-manager.php`)
    - Registers bulk actions: "Prepare to Printing Export", "Set Status to Prepare to Printing", "Set Status to In Printing"
@@ -86,7 +107,8 @@ The plugin uses a manager-based architecture where the main plugin class (`Studi
    - Handles Delivered Protocol (sets orders to "completed" with delivery timestamp)
 
 6. **Order_Search_Manager** (`includes/class-order-search-manager.php`)
-   - Extends WooCommerce order search to include external reference order numbers
+   - Extends WooCommerce order search to include the `external_ref_ord_no` meta key.
+   - Hooks `woocommerce_shop_order_search_fields` (legacy CPT) and `woocommerce_order_table_search_query_meta_keys` (HPOS) with the same callback. No separate UI input — the standard "Search orders" box handles it.
 
 7. **Studiou_DB_Manager** (`includes/class-db-manager.php`)
    - Centralized database operations using static methods
@@ -115,11 +137,11 @@ This query extracts: order number, product category, product name, variation nam
 
 ## Development Commands
 
-### Logging
+### Logging & user notices
 
-The plugin includes a simple logging utility (`includes/utils-log.php`):
-- `UtilsLog::log($message)` - Logs to WordPress error log when `WP_DEBUG` is enabled
-- Check logs in `wp-content/debug.log` if `WP_DEBUG_LOG` is enabled
+The plugin includes a logging + notice utility (`includes/utils-log.php`):
+- `UtilsLog::log($message)` - Writes to the WordPress error log when `WP_DEBUG` is truthy (guarded as `defined('WP_DEBUG') && WP_DEBUG`). Check `wp-content/debug.log` when `WP_DEBUG_LOG` is on.
+- `UtilsLog::message($message, $type)` - Queues a dismissible WordPress admin notice (`info` / `success` / `warning` / `error`) for the current user via a 60-second transient + `admin_notices` hook. Survives the redirect that follows bulk actions and imports.
 
 ### WordPress/WooCommerce Hooks
 
@@ -168,6 +190,6 @@ The plugin supports internationalization:
 
 3. **CSV Output**: The `prepare_to_printing_export` bulk action terminates with `exit` after outputting CSV headers, preventing further PHP execution
 
-4. **Database Queries**: Direct SQL queries use `$wpdb->prepare()` implicitly through `intval()` for order IDs. Always sanitize inputs when modifying database operations.
+4. **Database Queries**: The export query in `Studiou_DB_Manager::get_orders_for_printing()` uses `$wpdb->prepare()` with `%d` placeholders for the order ID list. When adding new SQL, prefer `$wpdb->prepare()` over inline coercion. Order IDs are also pre-filtered with `array_map('intval', …)` as defense in depth.
 
 5. **Admin Menu**: Import functionality is added as a submenu under WooCommerce admin menu, requires `manage_woocommerce` capability

+ 46 - 12
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.3.2
+- **Current version:** 1.4.0
 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
 - **License:** GPL v2 or later
 
@@ -13,8 +13,8 @@ WordPress plugin that extends WooCommerce with a dedicated workflow for managing
 
 | Component   | Minimum | Tested up to |
 |-------------|---------|--------------|
-| WordPress   | 5.8     | 6.8.3        |
-| WooCommerce | 3.0     | 10.2.2       |
+| WordPress   | 5.8     | 6.9.4        |
+| WooCommerce | 3.0     | 10.7.0       |
 | PHP         | 7.2     | —            |
 
 Compatible with WooCommerce **High-Performance Order Storage (HPOS)** — the plugin declares `custom_order_tables` compatibility and queries the HPOS `wc_orders` table directly.
@@ -51,10 +51,9 @@ A new **Print Information** panel is added to the order details screen with four
 - **Prepare To Print Date** (`to_print_date`)
 - **In Print Date** (`in_print_date`)
 - **External Order Number** (`external_ref_ord_no`)
+- **External Order Date** (`external_ref_ord_date`) — populated by InPrint protocol imports.
 - **External Order Delivered** (`external_ref_ord_delivered`)
 
-An additional internal field `external_ref_ord_date` is populated during InPrint protocol imports.
-
 ### 3. Bulk actions on the Orders list
 
 Available in the **Bulk actions** dropdown on **WooCommerce → Orders**:
@@ -73,7 +72,7 @@ Three columns are appended after **Total**:
 
 ### 5. Search by External Order Number
 
-The Orders list gains a search input that filters orders on the `external_ref_ord_no` meta value (LIKE match).
+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
 
@@ -90,7 +89,7 @@ Both importers require the `manage_woocommerce` capability and are protected by
 
 ### Export — Prepare to Printing
 
-Generated file: `prepare_to_printing_export.csv`
+Generated file: `prepare_to_printing_export.csv` (UTF-8 with BOM — opens correctly in Excel on Windows).
 
 Columns (in order):
 
@@ -98,17 +97,21 @@ Columns (in order):
 order_no, prod_cat, prod_name, prod_var, prod_var_type, prod_img_url, qty, email
 ```
 
-`prod_var_type` is sourced from the variation's `attribute_pa_format` postmeta, resolved against the `wp_terms` table.
+- `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.
+
+Headers are normalised on read: column names are lowercased and internal whitespace is collapsed to underscore. Both the original spec casing (`Order No`, `ExternalOrder`, `ExternalOrderDate`) and the documented lowercase form (`order_no`, `externalorder`, `externalorderdate`) work. Rows are also **deduplicated by `order_no`** before processing.
 
 ### Import — InPrint Protocol
 
-Required headers (case-sensitive):
+Required headers (any of the accepted casings):
 
 ```
 order_no, externalorder, externalorderdate
 ```
 
-Effect per row: order → status `wc-in-print`, sets `in_print_date` (now), `external_ref_ord_no`, `external_ref_ord_date`.
+Effect per row: order → status `wc-in-print`, sets `in_print_date` (now), `external_ref_ord_no`, `external_ref_ord_date` (parsed from the CSV). Orders in a terminal status (`completed`, `cancelled`, `refunded`, `failed`) are skipped with a per-row error notice.
 
 ### Import — Delivered Protocol
 
@@ -151,9 +154,10 @@ Use the standard WordPress functions (`__()`, `_e()`, `_x()`, `_n_noop()`) for a
 
 ---
 
-## Logging
+## Logging & user notices
 
-`UtilsLog::log($message)` writes to the WordPress error log when `WP_DEBUG` is enabled. With `WP_DEBUG_LOG` on, log output ends up in `wp-content/debug.log`. The plugin logs initialization, dependency loading, status changes, bulk actions, and SQL failures.
+- `UtilsLog::log($message)` writes to the WordPress error log when `WP_DEBUG` is enabled (guarded as `defined('WP_DEBUG') && WP_DEBUG` — works for both boolean and integer truthy values). With `WP_DEBUG_LOG` on, log output ends up in `wp-content/debug.log`.
+- `UtilsLog::message($message, $type)` queues a dismissible WordPress admin notice (`info` / `success` / `warning` / `error`) for the current user. Notices are stored in a per-user transient (60-second TTL) so they survive the redirect that follows bulk actions and imports. Used to report success counts, skipped orders, malformed CSV rows, etc.
 
 ---
 
@@ -186,6 +190,7 @@ Reference documents live under [`docs/`](docs/):
 - [`docs/readme.md`](docs/readme.md) — Previous user-facing readme (kept for history).
 - [`docs/instructions.txt`](docs/instructions.txt) — Original feature specification used to scaffold the plugin.
 - [`docs/translations.txt`](docs/translations.txt) — EN → CS translation reference.
+- [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md) — Analytical review of v1.3.2 (all findings addressed in 1.4.0).
 
 Project-internal guidance for AI-assisted development is documented in [`CLAUDE.md`](CLAUDE.md).
 
@@ -193,6 +198,35 @@ Project-internal guidance for AI-assisted development is documented in [`CLAUDE.
 
 ## Changelog
 
+### 1.4.0 — 2026-05-12
+
+- **Tested with:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled).
+
+Resolution of every finding catalogued in [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md). Highlights:
+
+- **Fixed:** Search by External Order Number is now functional. The meta-key mismatch is resolved (`external_ref_ord_no` consistently, no `_` prefix), and the integration uses WooCommerce's own search filters (`woocommerce_shop_order_search_fields` for legacy CPT and `woocommerce_order_table_search_query_meta_keys` for HPOS). No separate UI input — typing the external order number into the standard Search orders box finds the order.
+- **Fixed:** `Order_Status_Manager::handle_status_transitions()` now writes timestamps via `wc_get_order()->update_meta_data()` instead of `update_post_meta()`. Print-date stamps are no longer silently lost under HPOS for non-bulk status transitions.
+- **Fixed:** CSV imports now deduplicate rows by `order_no` (per the original spec) and accept both the spec's title-case headers and the lowercase form documented in the README.
+- **Fixed:** "Prepare to Printing Export" is now atomic — the CSV is generated in memory before any status change. Order status is advanced after a successful CSV build. If the export query returns no rows the user is notified.
+- **Fixed:** Export CSV is now UTF-8 with BOM (`charset=utf-8` header + leading `\xEF\xBB\xBF`). Czech diacritics render correctly in Excel on Windows.
+- **Fixed:** Export SQL no longer multiplies rows when a product belongs to multiple `product_cat` terms — categories are concatenated via `GROUP_CONCAT(DISTINCT …)` inside a `GROUP BY` per line item.
+- **Fixed:** Export SQL now goes through `$wpdb->prepare()` with `%d` placeholders rather than relying on `intval()` for safety.
+- **Fixed:** Late `woocommerce_payment_complete` events no longer demote orders already in `to-print` or `in-print` back to `processing`. The guard extends to all print statuses plus `completed`.
+- **Fixed:** Bulk actions guard with `current_user_can('edit_shop_orders')`. Imports keep their existing `manage_woocommerce` guard.
+- **Fixed:** Bulk actions and protocol imports skip orders in terminal statuses (`completed`, `cancelled`, `refunded`, `failed`) instead of silently reactivating them.
+- **Fixed:** `UtilsLog::log()` guard is now `defined('WP_DEBUG') && WP_DEBUG` (previously `true === WP_DEBUG`, which failed when `WP_DEBUG` was defined as integer `1`).
+- **Fixed:** `UtilsLog::message()` is now implemented as a per-user transient that renders via the `admin_notices` hook. Bulk actions and imports now produce visible user feedback after the redirect.
+- **Fixed:** `parse_csv()` no longer truncates rows at 1000 bytes (passes `0` to `fgetcsv`), validates that the header row is non-empty, normalises header casing, and skips rows whose column count diverges from the header.
+- **Fixed:** Import handlers verify `$_FILES[...]['error']` and `is_uploaded_file()` server-side instead of relying solely on the client-side `accept=".csv"` attribute.
+- **Fixed:** `external_ref_ord_date` is parsed via `strtotime()` and stored in MySQL datetime format; if parsing fails the raw value is retained.
+- **Added:** `external_ref_ord_date` is now rendered in the Print Information panel on the order edit screen.
+- **Added:** `STUDIOU_WC_OPS_VERSION` and `STUDIOU_WC_OPS_FILE` constants for code-side version references.
+- **Changed:** Translation loader and plugin bootstrap now have explicit `plugins_loaded` priorities (5 and 10) so translations are guaranteed to load before any manager instantiates.
+- **Removed:** `// visualized` leftover comments and the dead-code commented body of `UtilsLog::message()`.
+- **Docs:** README architecture/install/usage updated, `CLAUDE.md` updated, `docs/translations.txt` extended with the strings that were missing.
+
+> **Translations:** the `.po` file ships new strings; the `.mo` file must be rebuilt (e.g. via `wp i18n make-mo languages/` or Poedit) before the new Czech wording is visible. This release does not regenerate the binary.
+
 ### 1.3.2 — 2025-10-18
 - **Fixed:** `prod_var_type` column was empty in `prepare_to_printing_export.csv`. Variation attribute is now resolved through `postmeta.attribute_pa_format` joined against `wp_terms.name`.
 - **Fixed:** HPOS incompatibility warning. Plugin now explicitly declares `custom_order_tables` compatibility.

+ 310 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.0.md

@@ -0,0 +1,310 @@
+# Plugin Revision — v1.4.0 (Analytical Review)
+
+**Scope:** Read-only analytical pass over the v1.4.0 codebase. No code changes were made. This document is the counterpart of [`revise-1.3.2.md`](revise-1.3.2.md) — it catalogues residual gaps, possible syntax pitfalls, regressions newly introduced by the 1.4.0 fix-up, and items from the prior review that remain open.
+
+**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/`, plus `studiou-wc-ord-print-statuses.php`, `README.md`, `CLAUDE.md`, `docs/translations.txt`.
+**Verification limitations:** No PHP interpreter and no WordPress / WooCommerce install were available in the working environment, so syntax errors visible only at compile time are not ruled out and WC-internal filter names / hook semantics could not be cross-checked against a running site. The findings below reflect WC 10.7.0 / WP 6.9.4 as the *target* but are derived from source reading, not live behaviour.
+
+Findings are grouped by severity. The severity reflects user-visible or correctness impact, not implementation effort to fix.
+
+---
+
+## 1. Critical — newly introduced regressions
+
+### 1.1 Export SQL `GROUP BY` may fail under default MySQL strict mode
+
+`class-db-manager.php:35-76` — the new export query introduces `GROUP BY orders.id, order_products.product_id, order_products.variation_id` so that products with multiple categories no longer duplicate rows. The `SELECT` list, however, contains several non-aggregated columns that are **not** in the `GROUP BY` clause:
+
+- `products.post_title`
+- `product_variations.post_title`
+- `product_variations_name.name`
+- `images.guid`
+- `order_products.product_qty`
+- `orders.billing_email`
+
+Under MySQL 5.7+ and MariaDB 10.3+ the default `sql_mode` includes `ONLY_FULL_GROUP_BY`. MySQL does recognise *functional dependency* on a primary key column, so `products.post_title` is technically dependent on `order_products.product_id` (which references `products.ID`). In practice the recognition is brittle: it depends on engine version, whether the FK is declared, the optimiser's understanding of the join, and the exact sql_mode.
+
+**Risk:** the bulk export may fail with `ERROR 1055 (42000): Expression #N of SELECT list is not in GROUP BY clause and contains nonaggregated column ...` on installations that follow the MySQL default. The previous v1.3.2 query had no `GROUP BY` and was immune.
+
+**Mitigation paths** (informational only):
+- Wrap every non-aggregated column in `ANY_VALUE(...)` (MySQL 5.7+) or `MIN(...)`.
+- Add every non-aggregated column to `GROUP BY` explicitly.
+- Reduce the join with a sub-query that pre-aggregates `product_cat`, then join in the rest without `GROUP BY`.
+
+### 1.2 CSV exports now write a UTF-8 BOM; CSV imports do not strip it
+
+`class-bulk-actions-manager.php:136` writes a leading UTF-8 BOM (`\xEF\xBB\xBF`) so Excel on Windows interprets the file as UTF-8. The matching read side `Studiou_DB_Manager::parse_csv()` (`class-db-manager.php:224-248`) does **not** strip a BOM from the first byte of the first header.
+
+**Symptom:** if a user exports `prepare_to_printing_export.csv`, edits it, and re-imports it as a protocol, the first header is read as `\xEF\xBB\xBForder_no` instead of `order_no`. The header normaliser (`normalise_header`, line 250) lowercases and collapses whitespace but does not touch the BOM. Subsequent `isset($row['order_no'])` checks fail for every row, the import emits "Invalid row format in CSV." for the entire file, and no orders are updated.
+
+This combination is realistic — exports often serve as templates for the next stage. Stripping a BOM is a single-line fix in `parse_csv`.
+
+---
+
+## 2. High — correctness, HPOS, time-zone
+
+### 2.1 HPOS search filter name is unverified against WC 10.7.0
+
+`class-order-search-manager.php:20` hooks `woocommerce_order_table_search_query_meta_keys`. The legacy CPT filter `woocommerce_shop_order_search_fields` (line 17) is well-established. The HPOS filter's exact name has varied across WC releases and could not be confirmed against WC 10.7.0's source without access to a running install.
+
+**Risk:** if the filter name is wrong (or has been renamed in the active WC 10.7.0 build), search by external order number works only under legacy CPT, not under HPOS — which is exactly the failure mode the v1.3.2 review flagged as critical.
+
+**Verification ask (WC 10.7.0):** confirm the filter still exists, e.g. by `grep -RIn 'woocommerce_order_table_search_query_meta_keys' wp-content/plugins/woocommerce/`, or by adding a temporary `error_log` inside the callback and observing whether it fires on the orders page with HPOS enabled. If the filter has been renamed, alternatives to consider in WC 10.x are `woocommerce_order_query_args` / `woocommerce_orders_table_query_clauses` for injecting `meta_query` at a higher level.
+
+### 2.2 Datetime fields use server timezone, not WP timezone
+
+`class-order-fields-manager.php:90` and `:99` use `date('Y-m-d\TH:i', $timestamp)` and `date('Y-m-d H:i:s', $timestamp)` for the `datetime-local` round-trip. PHP's `date()` formats with the server's PHP timezone, not WordPress's site timezone.
+
+Elsewhere the plugin stamps timestamps with `current_time('mysql')` — which honours `wp_timezone()` / `get_option('timezone_string')`. As a result:
+
+- Bulk action and import paths stamp `to_print_date` etc. in WP local time.
+- Manual edits via the Print Information panel display and save in server local time.
+- If the server is UTC and WP timezone is `Europe/Prague` (UTC+1 or +2 with DST), the panel shows values shifted by one or two hours.
+
+Replacing `date(...)` with `wp_date(...)` or constructing a `DateTimeImmutable` with `wp_timezone()` would unify behaviour.
+
+The orders-list column (`class-custom-columns-manager.php:58`) already does this correctly via `date_i18n(...)` — so the discrepancy is between the column display and the editable panel.
+
+### 2.3 Status-change listener and bulk-set both stamp the same meta
+
+`Order_Status_Manager::handle_status_transitions()` (lines 71-86) is triggered by every `woocommerce_order_status_changed` event. `Studiou_DB_Manager::bulk_set_print_status()` (lines 108-146) and `Studiou_DB_Manager::import_inprint_protocol()` both call `$order->update_status(...)` which fires that event, and *also* explicitly call `$order->update_meta_data($meta_key, current_time('mysql'))`.
+
+End result per status change via the bulk/import path:
+
+1. `update_status` → fires `woocommerce_order_status_changed` → listener stamps meta (only if currently empty).
+2. Caller then overwrites meta with another `current_time('mysql')` (potentially a millisecond later).
+3. Single `save()` persists the second value.
+
+The values are nearly identical, so this is **not a correctness bug**, but it is wasted work and a maintenance trap: future contributors changing one path may miss the other. The two paths should agree on a single source of truth — either remove the explicit stamping in the bulk/import path (let the listener handle it) or have the listener defer to the bulk/import path when the operation is bulk.
+
+A subtler asymmetry: the listener stamps **only if empty** (`if (!$order->get_meta($meta_key))`), the bulk path **always** stamps. On a second-pass bulk action, the bulk path overwrites; the listener's "only if empty" guard is bypassed for the bulk path but enforced for manual transitions. That divergence is intentional in one direction (bulk should re-stamp on each run) but should be commented in the code.
+
+---
+
+## 3. Medium — robustness, maintainability
+
+### 3.1 `GROUP_CONCAT` is silently bounded by `group_concat_max_len`
+
+`class-db-manager.php:38` uses `GROUP_CONCAT(DISTINCT t.name ORDER BY t.name SEPARATOR ', ')`. The MySQL default for `group_concat_max_len` is 1024 bytes; once exceeded the result is **silently truncated** with no error returned. Products belonging to many or long-named categories may lose the tail of `prod_cat` without warning.
+
+Either raise the limit with `SET SESSION group_concat_max_len = 65535` before the query, or accept the truncation and document the limit.
+
+### 3.2 `parse_csv` does not skip the UTF-8 BOM
+
+Related to §1.2. Even outside of the export-and-reimport workflow, any CSV authored in Notepad with "UTF-8 with BOM" encoding will hit the same issue. A single-line guard:
+
+```php
+$first = fread($handle, 3);
+if ($first !== "\xEF\xBB\xBF") {
+    rewind($handle);
+}
+```
+
+inside `parse_csv` would close this gap.
+
+### 3.3 `output_csv` fallback path mixes CSV into HTML on header-sent edge
+
+`class-bulk-actions-manager.php:130-138`:
+
+```php
+private function output_csv($csv_data, $filename) {
+    if (!headers_sent()) {
+        nocache_headers();
+        header('Content-Type: text/csv; charset=utf-8');
+        header('Content-Disposition: attachment; filename="' . $filename . '"');
+    }
+    echo "\xEF\xBB\xBF" . $csv_data;
+    exit;
+}
+```
+
+If headers have already been emitted (an upstream plugin printed something, a `print_r` left over from debugging, BOM in an included file…) the CSV is echoed without the right Content-Type. The browser will render it as part of the HTML page. Better: when `headers_sent()` returns true, log the event, queue an error notice via the new transient mechanism (problematic since we're about to exit), or `wp_die()` with a clear message instead of streaming garbage.
+
+### 3.4 `if ($file === null) { $this->redirect_back(); }` is statically unreachable-friendly
+
+`class-import-manager.php:88-91`:
+
+```php
+$file = $this->validated_upload($file_field);
+if ($file === null) {
+    $this->redirect_back();
+}
+$csv_data = Studiou_DB_Manager::parse_csv($file['tmp_name']);
+```
+
+`redirect_back()` ends with `exit;` so the code that follows is correctly unreachable, but static-analysis tools (Psalm, PHPStan) will flag `$file['tmp_name']` as a possible null-index access because the function signature on `redirect_back()` does not advertise it as a `noreturn`. Re-shaping as `if ($file === null) { return $this->redirect_back(); }` (or annotating `@return never`) makes intent explicit and silences the warning.
+
+### 3.5 `normalise_csv_date` returns the raw string on parse failure
+
+`class-db-manager.php:271-278`:
+
+```php
+private static function normalise_csv_date($raw) {
+    $raw = sanitize_text_field((string) $raw);
+    if ($raw === '') { return ''; }
+    $timestamp = strtotime($raw);
+    return $timestamp ? date('Y-m-d H:i:s', $timestamp) : $raw;
+}
+```
+
+On parse failure the raw string is stored. Downstream, `Order_Fields_Manager::mysql_to_datetime_local()` runs `strtotime` again, fails, and displays empty — so the operator silently loses the value. Either log the failure, surface a per-row import warning, or store an explicit sentinel (`''`) so the field is consistently blank rather than "looks blank, but has bogus data underneath".
+
+### 3.6 Unused constants and dead label maps
+
+- `STUDIOU_WC_OPS_VERSION` and `STUDIOU_WC_OPS_FILE` are defined in the bootstrap (`studiou-wc-ord-print-statuses.php:22-23`) but never referenced inside the plugin. They are useful for code outside the plugin (test code, migrations), but if nothing reads them they are effectively documentation.
+- `Order_Fields_Manager::$datetime_fields` and `$text_fields` map keys to English labels, but the labels are never read — the rendered labels are hardcoded inline in `display_custom_order_fields()`. Either drop the labels (just keep the key arrays) or drive `display_custom_order_fields` from the maps to eliminate the duplication.
+
+### 3.7 `set_order_processing_status` produces two order notes per payment-complete event
+
+`class-order-status-manager.php:103-109`:
+
+```php
+$order->update_status('processing', __('Payment received, order is now processing.', ...));
+$order->add_order_note(__('Order automatically set to processing by Studiou WC Order Print Statuses plugin.', ...));
+```
+
+The second argument to `update_status` is already a note. Then a second note is added explicitly. The order activity log will show two consecutive lines for the same event. Choose one: pass the longer text as the `update_status` note, or drop the second-argument note and keep `add_order_note`.
+
+### 3.8 Bulk action result UX when zero orders are eligible
+
+`class-bulk-actions-manager.php:52-78` — if every selected order is in a terminal state, `set_orders_to_prepare_printing` returns `0` and emits a "0 orders moved" notice. The DB manager additionally emits an "X orders skipped" warning. The "0 orders moved" notice is technically truthful but uninformative noise on top of the more useful "skipped" warning. Suppress the success notice when count is zero.
+
+### 3.9 PHP 8.4 deprecation of implicit `fputcsv` / `fgetcsv` escape character
+
+`class-bulk-actions-manager.php:117, 119` and `class-db-manager.php:232, 238` call `fputcsv` / `fgetcsv` with only the delimiter argument. From PHP 8.4 onward the implicit `escape='\\'` parameter is deprecated and emits warnings; passing an explicit empty string is the preferred forward-compatible form. The plugin currently requires PHP 7.2+ and does not pin a maximum, so installations on PHP 8.4 (released late 2024) will fill `debug.log` with deprecation warnings.
+
+### 3.10 `Order_Fields_Manager::save_custom_order_fields` lacks a capability check
+
+The handler hooks `woocommerce_process_shop_order_meta`, which WC fires after its own form-handling pipeline that does include capability validation. So this is not exploitable in normal flow. Still, the pattern most third-party plugins follow includes a `current_user_can('edit_shop_order', $order_id)` (or the legacy `edit_post`) inside the callback for defense in depth, and to handle programmatic invocation from custom code paths.
+
+---
+
+## 4. Low — cosmetic, style, longer-term
+
+### 4.1 `dedupe_by_order_no` is case-sensitive
+
+`class-db-manager.php:257-269` — keys are `trim((string) $row['order_no'])`. Order IDs in WooCommerce are numeric so this is irrelevant in practice, but if any deployment uses an order-number plugin that produces alphabetic prefixes (e.g. `ORD-12345`), `ord-12345` and `ORD-12345` would not dedupe. Normalising to a single case in the dedup key is cheap insurance.
+
+### 4.2 `run_import` action-name dispatch is brittle
+
+`class-import-manager.php:102-106` selects the DB handler by string equality on `$action`. Refactoring to a callable map keyed by action would prevent silent fall-through if a future protocol is added.
+
+### 4.3 `wp_kses_post` may be heavier than necessary for plain notices
+
+`utils-log.php:66` renders notice messages through `wp_kses_post()`. Notices are mostly plain text with the occasional `<code>` tag; `wp_kses_post` allows a broad set of post-content tags. If notice messages will only ever contain simple inline markup, a narrower allowlist via `wp_kses` would be safer (and faster). Low priority since current usage is already escaped at call sites.
+
+### 4.4 Notice transient TTL is 60 seconds
+
+`utils-log.php:8` — if the redirect that should display the notice is delayed (slow network, reverse-proxy hiccup, user closes the browser and re-opens), the notice is dropped. 5 minutes is a more typical TTL for "post-action feedback" patterns. Drop is silent — there is no way for the user to recover the message.
+
+### 4.5 Per-tab race in `render_notices`
+
+`utils-log.php:49-69` deletes the transient on first render. If a user has two admin tabs open and both load simultaneously after a bulk action, only one tab shows the notices. Not a correctness issue, but a slight UX surprise.
+
+### 4.6 `STUDIOU_WC_OPS_FILE` and `STUDIOU_WC_OPS_VERSION` are unused
+
+Already listed in §3.6. Repeated here only to note the cosmetic angle: define-time effort without a reader is noise.
+
+### 4.7 No `Network:` header in the plugin file
+
+Carried over from §4.6 of the previous review. Only relevant for multisite installs.
+
+### 4.8 No `uninstall.php` / cleanup hook
+
+Carried over from §3.11 of the previous review. Order meta keys remain in the database after plugin removal. Acceptable but undocumented.
+
+### 4.9 `register_post_status` is invoked even under HPOS
+
+`Order_Status_Manager::register_custom_order_statuses()` registers the statuses via `register_post_status`. Under HPOS this is harmless and still needed for WC admin chrome that calls `get_post_statuses()`, but the contract is slightly mixed-paradigm. No action required.
+
+### 4.10 `update_status` calls do not pass `manual = true`
+
+`bulk_set_print_status` and the import handlers call `$order->update_status($slug)` without the optional `$manual` parameter. WC distinguishes manual (user-driven) from automated transitions in its hook payloads. Currently these transitions appear as "automated" in WC reports — which is arguably correct (the bulk action *is* automated) but is a UX nuance worth conscious choice.
+
+### 4.11 WooCommerce 10.7.0 block-based order edit screen
+
+WC 10.x continues to ship the block-based / React order edit screen alongside the classic one (feature-flagged per install). `Order_Fields_Manager` hooks `woocommerce_admin_order_data_after_order_details` and `woocommerce_process_shop_order_meta`, both of which target the **classic** order edit screen. Under the block-based screen the Print Information panel does not render and edits are not saved through that pipeline. Currently this is acceptable (most production installs of WC 10.7.0 still default to the classic screen), but if Studiou opts into the block-based edit experience, a parallel data-source / slot-fill integration will be needed.
+
+---
+
+## 5. Verification gaps (against WC 10.7.0 / WP 6.9.4)
+
+The following claims in v1.4.0 rest on assumptions that could not be confirmed without an interactive test on the target stack:
+
+1. **HPOS search filter** — see §2.1. Needs confirmation that `woocommerce_order_table_search_query_meta_keys` is still emitted by WC 10.7.0.
+2. **MySQL functional-dependency recognition** for the new `GROUP BY` — see §1.1. Production MySQL/MariaDB version and `sql_mode` should be checked; WP 6.9.4 itself imposes no constraint here, the risk is the database server config.
+3. **`woocommerce_order_table_search_query_meta_keys` payload shape** — the callback assumes WC passes an indexed array of meta-key strings. If WC 10.7.0 has changed it to an associative array (key → label) or a different shape, `in_array($meta, $keys, true)` would silently no-op.
+4. **Transient delivery timing under WC 10.7.0** — `UtilsLog::message()` queues notices via a transient keyed by user ID. If a request is served while WC is invalidating user-scoped object caches (e.g. session bootstrap), the notice could be lost between queue and render. Difficult to reproduce; worth a smoke test.
+5. **Header ordering on the export response** — `nocache_headers()` precedes our `Content-Type: text/csv`. WC may emit its own headers earlier in the request via WP filters; the resulting Content-Type and Cache-Control order should be inspected with a browser inspector or `curl -I` on a WC 10.7.0 install.
+6. **Block-based order edit screen** — §4.11. WC 10.7.0 ships both classic and block-based order edit screens. The Print Information panel is wired to the classic screen only; behaviour on the block-based screen has not been verified.
+
+---
+
+## 6. Status of 1.3.2 findings
+
+| 1.3.2 finding | Status in 1.4.0 |
+|---------------|------------------|
+| §1.1 Search by External Order Number is broken | Resolved in code, **pending verification** (see §2.1 of this review). |
+| §1.2 `handle_status_transitions` writes to wrong table under HPOS | Resolved (`$order->update_meta_data() + save()`). |
+| §2.1 No CSV row deduplication | Resolved (`Studiou_DB_Manager::dedupe_by_order_no`). |
+| §2.2 CSV header casing drift | Resolved (`normalise_header`). |
+| §2.3 Bulk export non-atomic | Resolved (CSV built before status flip). |
+| §2.4 Multi-category row multiplication | Resolved via `GROUP BY` — **new risk introduced**, see §1.1. |
+| §2.5 CSV UTF-8 BOM | Resolved on export — **new asymmetry with import**, see §1.2. |
+| §2.6 Payment-complete demotion | Resolved. |
+| §2.7 Import file validation | Resolved (`validated_upload`). |
+| §2.8 Bulk handlers cap check | Resolved. |
+| §3.1 `UtilsLog::message()` dead code | Resolved (transient + `admin_notices`). |
+| §3.2 `WP_DEBUG` guard too strict | Resolved. |
+| §3.3 Asymmetric logging | Resolved (shared `bulk_set_print_status`). |
+| §3.4 No state-machine guard | Resolved (terminal-status skip). |
+| §3.5 Plugin-init order risk | Resolved (priority pinning). |
+| §3.6 SQL prepare via intval | Resolved (`$wpdb->prepare()` + `%d`). |
+| §3.7 Init-time logging volume | Resolved (init logs removed). |
+| §3.8 `parse_csv` line-length cap | Resolved (`fgetcsv(... 0 ...)`). |
+| §3.9 `external_ref_ord_date` validation | Partially resolved — see §3.5 of this review. |
+| §3.10 `external_ref_ord_date` UI exposure | Resolved. |
+| §3.11 No uninstall cleanup | Not addressed (conscious carry-over). |
+| §3.12 No version constant | Resolved (`STUDIOU_WC_OPS_VERSION`), albeit unused. |
+| §3.13 HPOS column callback parameter naming | Resolved (`$order_or_id`). |
+| §4.1 Translations incomplete | Resolved (`docs/translations.txt` extended). The `.mo` file still needs a rebuild. |
+| §4.2 README/CLAUDE.md overclaim HPOS | Resolved in wording — but contingent on §2.1 verification. |
+| §4.3 CLAUDE.md SQL prepare claim | Resolved. |
+| §4.4 `// visualized` comments | Resolved. |
+| §4.5 Bracket formatting | Resolved as a side-effect of rewriting the class. |
+| §4.6 No `Network:` header | Not addressed (carried over). |
+| §4.7 README architecture note | Cosmetic — left as-is. |
+
+Net: 26 of 30 findings closed in code; 3 carried over; 1 (search) closed but flagged as verification-pending.
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by expected user-visible impact:
+
+1. **§1.1 Export SQL `GROUP BY` may fail on default MySQL strict mode.** This is the most likely cause of a fresh production incident on 1.4.0.
+2. **§1.2 BOM-in-imported-CSV breakage.** Subtle, easy to reproduce by exporting then re-importing.
+3. **§2.1 HPOS search filter unverified.** If wrong, the headline 1.4.0 fix is incomplete.
+4. **§2.2 Server-vs-WP timezone drift in Print Information panel.** Will produce off-by-one-hour confusion in CZ timezone with DST.
+5. **§3.3 `output_csv` fallback echoes raw CSV into HTML.** Edge case but visible when it happens.
+6. **§3.5 `normalise_csv_date` silently keeps bogus dates.** Operator-visible inconsistency.
+7. **§3.1 `GROUP_CONCAT` truncation.** Unlikely but silent.
+8. **§3.9 PHP 8.4 deprecation noise.** Cosmetic but log-spammy.
+9. **§3.7 Two order notes per payment-complete event.** Cosmetic noise in order history.
+
+---
+
+## 8. Out of scope for this review
+
+- Live behaviour testing against a running WordPress 6.9.4 / WooCommerce 10.7.0 install — every finding above is derived from source reading.
+- Performance profiling of the rewritten export query (`GROUP BY` plus four `LEFT JOIN`s) under large order volumes.
+- Behaviour of the WP admin notices renderer when WC's own admin notices are also queued in the same request.
+- Behavioural verification on the WooCommerce 10.7.0 block-based order edit screen (covered analytically in §4.11).
+- `.po` / `.mo` correctness — translations were not exercised; this review covers only their source-side coverage in `docs/translations.txt`.
+
+---
+
+*End of review.*

+ 69 - 25
studiou-wc-ord-print-statuses/docs/translations.txt

@@ -1,25 +1,69 @@
-Tranlations all text from English (ENG) to Czech (CZ).
-English																						Česky
--------------------------------------------------------------------------------------------------------------------------------------------------------------------
-Prepare to Printing Export																	Export do přípravy tisku
-Set Status to Prepare to Printing															Nastav stav na Příprava Tisku
-Set Status to In Printing																	Nastav stav na V Tisku
-To Print Date																				Datum Příprava Tisku
-In Print Date																				Datum V Tisku
-External Order Number																		Č.Obj. Tiskárny
-Order %s not found.																			Objednávka č. %s nenalezena
-Import Protocols																			Import Protokolů
-Import InPrint Protocol																		Import protokolu V Tisku
-Import Delivered Protocol																	Import protokolu Tisk Dodán
-%d orders updated successfully.																%d objednávek úspěšně aktualizováno.
-%d errors occurred: %s																		%d chyb: %s
-No file uploaded.																			Žádný soubor nenahrán.
-Invalid row format in CSV.																	Vadný CSV formát.
-Order %s not found.																			Objednávka č. %s nenalezena.
-Print Information																			Informace Tiskové služby
-Prepare To Print Date																		Datum stavu Příprava Tisku
-External Order Number																		Č.Obj. Tiskárny
-External Order Delivered																	Datum Dodání Tisku
-Prepare to Printing																			Příprava Tisku
-In Printing																					V Tisku
-Studiou WC Order Print Statuses requires WooCommerce to be installed and active.			StudioU WC Order Print Statuses vyžaduje mít nainstalovaný a aktivní plugin WooCommerce.
+Translations: all user-facing strings (English → Czech).
+Last reviewed: v1.4.0 (2026-05-12).
+
+Note: After editing the .po file, regenerate the .mo binary with one of:
+  wp i18n make-mo languages/
+  msgfmt languages/studiou-wc-ord-print-statuses-cs_CZ.po -o languages/studiou-wc-ord-print-statuses-cs_CZ.mo
+
+English                                                                                     Česky
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------
+# Bulk actions
+Prepare to Printing Export                                                                  Export do přípravy tisku
+Set Status to Prepare to Printing                                                           Nastav stav na Příprava Tisku
+Set Status to In Printing                                                                   Nastav stav na V Tisku
+
+# Order list columns
+To Print Date                                                                               Datum Příprava Tisku
+In Print Date                                                                               Datum V Tisku
+External Order Number                                                                       Č.Obj. Tiskárny
+
+# Print Information panel (order edit screen)
+Print Information                                                                           Informace Tiskové služby
+Prepare To Print Date                                                                       Datum stavu Příprava Tisku
+External Order Number                                                                       Č.Obj. Tiskárny
+External Order Date                                                                         Datum Obj. Tiskárny
+External Order Delivered                                                                    Datum Dodání Tisku
+
+# Custom order statuses
+Prepare to Printing                                                                         Příprava Tisku
+In Printing                                                                                 V Tisku
+
+# Plurals for status counts (use %s placeholder)
+Prepare to Printing <span class="count">(%s)</span>                                         Příprava Tisku <span class="count">(%s)</span>
+In Printing <span class="count">(%s)</span>                                                 V Tisku <span class="count">(%s)</span>
+
+# Import Protocols admin screen
+Import Protocols                                                                            Import Protokolů
+Import InPrint Protocol                                                                     Import protokolu V Tisku
+Import Delivered Protocol                                                                   Import protokolu Tisk Dodán
+Required CSV headers: %s                                                                    Povinné hlavičky CSV: %s
+
+# Plurals / formatted notices (use %d / %1$d / %2$s as in source)
+%d order updated successfully.                                                              %d objednávka úspěšně aktualizována.
+%d orders updated successfully.                                                             %d objednávek úspěšně aktualizováno.
+%d order moved to "Prepare to Printing".                                                    %d objednávka přesunuta na "Příprava Tisku".
+%d orders moved to "Prepare to Printing".                                                   %d objednávek přesunuto na "Příprava Tisku".
+%d order moved to "In Printing".                                                            %d objednávka přesunuta na "V Tisku".
+%d orders moved to "In Printing".                                                           %d objednávek přesunuto na "V Tisku".
+%d order skipped because its status is final (cancelled, refunded, failed, or completed).   %d objednávka přeskočena, protože je v koncovém stavu (zrušeno, vráceno, selhalo, dokončeno).
+%d orders skipped because their status is final (cancelled, refunded, failed, or completed). %d objednávek přeskočeno, protože jsou v koncovém stavu (zrušeno, vráceno, selhalo, dokončeno).
+%1$d errors occurred: %2$s                                                                  Počet chyb: %1$d — %2$s
+No exportable line items were found for the selected orders. CSV was not generated.         Pro vybrané objednávky nebyly nalezeny žádné položky k exportu. CSV nebylo vygenerováno.
+
+# Error / status messages
+Order %s not found.                                                                         Objednávka č. %s nenalezena.
+Order %s is in a final state and was not updated.                                           Objednávka č. %s je v koncovém stavu a nebyla aktualizována.
+Invalid row format in CSV.                                                                  Vadný CSV formát.
+No file uploaded.                                                                           Žádný soubor nenahrán.
+Uploaded file is not accessible.                                                            Nahraný soubor není dostupný.
+File upload failed (error code %d).                                                         Nahrání souboru selhalo (chybový kód %d).
+CSV file could not be parsed or contained no data rows.                                     CSV soubor se nepodařilo zpracovat nebo neobsahoval žádné datové řádky.
+You do not have sufficient permissions to access this page.                                 Nemáte dostatečná oprávnění k zobrazení této stránky.
+You do not have sufficient permissions to perform this action.                              Nemáte dostatečná oprávnění k provedení této akce.
+
+# Order notes (when payment auto-advances order to "processing")
+Payment received, order is now processing.                                                  Platba přijata, objednávka byla převedena do stavu "Zpracovává se".
+Order automatically set to processing by Studiou WC Order Print Statuses plugin.            Objednávka byla automaticky převedena do stavu "Zpracovává se" pluginem Studiou WC Order Print Statuses.
+
+# Activation guard
+Studiou WC Order Print Statuses requires WooCommerce to be installed and active.            StudioU WC Order Print Statuses vyžaduje mít nainstalovaný a aktivní plugin WooCommerce.

+ 91 - 42
studiou-wc-ord-print-statuses/includes/class-bulk-actions-manager.php

@@ -1,6 +1,8 @@
 <?php
 // Handles custom bulk actions for orders.
-// visualized
+
+defined('ABSPATH') || exit;
+
 class Bulk_Actions_Manager {
     public function __construct() {
         add_filter('bulk_actions-woocommerce_page_wc-orders', array($this, 'register_bulk_actions'));
@@ -8,83 +10,130 @@ class Bulk_Actions_Manager {
     }
 
     public function register_bulk_actions($bulk_actions) {
-        $bulk_actions['prepare_to_printing_export'] = __('Prepare to Printing Export', 'studiou-wc-ord-print-statuses');
+        $bulk_actions['prepare_to_printing_export']       = __('Prepare to Printing Export', 'studiou-wc-ord-print-statuses');
         $bulk_actions['set_status_to_prepare_to_printing'] = __('Set Status to Prepare to Printing', 'studiou-wc-ord-print-statuses');
-        $bulk_actions['set_status_to_in_printing'] = __('Set Status to In Printing', 'studiou-wc-ord-print-statuses');
-
-        UtilsLog::log('Bulk actions registered');
+        $bulk_actions['set_status_to_in_printing']         = __('Set Status to In Printing', 'studiou-wc-ord-print-statuses');
         return $bulk_actions;
     }
 
     public function handle_bulk_actions($redirect_to, $action, $post_ids) {
+        $known = array(
+            'prepare_to_printing_export',
+            'set_status_to_prepare_to_printing',
+            'set_status_to_in_printing',
+        );
+        if (!in_array($action, $known, true)) {
+            return $redirect_to;
+        }
+        if (!current_user_can('edit_shop_orders')) {
+            wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'studiou-wc-ord-print-statuses'));
+        }
+        $post_ids = array_filter(array_map('intval', (array) $post_ids));
+        if (empty($post_ids)) {
+            return $redirect_to;
+        }
+
         switch ($action) {
             case 'prepare_to_printing_export':
-                $redirect_to = $this->prepare_to_printing_export($post_ids, $redirect_to);
-                break;
+                return $this->prepare_to_printing_export($post_ids, $redirect_to);
             case 'set_status_to_prepare_to_printing':
-                $redirect_to = $this->set_status_to_prepare_to_printing($post_ids, $redirect_to);
-                break;
+                return $this->set_status_to_prepare_to_printing($post_ids, $redirect_to);
             case 'set_status_to_in_printing':
-                $redirect_to = $this->set_status_to_in_printing($post_ids, $redirect_to);
-                break;
+                return $this->set_status_to_in_printing($post_ids, $redirect_to);
         }
         return $redirect_to;
     }
 
+    /**
+     * Atomic order: pull rows, build the CSV in memory, then flip statuses, then
+     * stream the file. If the export query returns nothing, statuses are still
+     * advanced (the spec requires it) but the user is told no CSV was produced.
+     */
     private function prepare_to_printing_export($post_ids, $redirect_to) {
-        // Get orders data using the DB Manager
         $orders_data = Studiou_DB_Manager::get_orders_for_printing($post_ids);
+        $csv_data    = !empty($orders_data) ? $this->generate_csv($orders_data) : '';
 
-        // Update order statuses
-        $this->set_status_to_prepare_to_printing($post_ids, $redirect_to);
-
-        UtilsLog::log('Order statuses set to "to-print" after export.');
-        UtilsLog::message('Order statuses (' . count($orders_data) . ') set to "to-print" after export.', 'info' );
+        $updated = Studiou_DB_Manager::set_orders_to_prepare_printing($post_ids);
+        UtilsLog::message(
+            sprintf(
+                _n(
+                    '%d order moved to "Prepare to Printing".',
+                    '%d orders moved to "Prepare to Printing".',
+                    $updated,
+                    'studiou-wc-ord-print-statuses'
+                ),
+                $updated
+            ),
+            'success'
+        );
 
-        if (!empty($orders_data)) {
-            $csv_data = $this->generate_csv($orders_data);
-            UtilsLog::log('file prepare_to_printing_export.csv exported');
-            $this->output_csv($csv_data, 'prepare_to_printing_export.csv');
+        if ($csv_data === '') {
+            UtilsLog::message(
+                __('No exportable line items were found for the selected orders. CSV was not generated.', 'studiou-wc-ord-print-statuses'),
+                'warning'
+            );
+            return $redirect_to;
         }
 
-        return $redirect_to;
+        $this->output_csv($csv_data, 'prepare_to_printing_export.csv'); // exits
     }
 
     private function set_status_to_prepare_to_printing($post_ids, $redirect_to) {
-        // Use the DB Manager to update order statuses
-        Studiou_DB_Manager::set_orders_to_prepare_printing($post_ids);
-        
-        $redirect_to = add_query_arg('bulk_action', 'marked_prepare_to_printing', $redirect_to);
-        return $redirect_to;
+        $updated = Studiou_DB_Manager::set_orders_to_prepare_printing($post_ids);
+        UtilsLog::message(
+            sprintf(
+                _n(
+                    '%d order moved to "Prepare to Printing".',
+                    '%d orders moved to "Prepare to Printing".',
+                    $updated,
+                    'studiou-wc-ord-print-statuses'
+                ),
+                $updated
+            ),
+            'success'
+        );
+        return add_query_arg('bulk_action', 'marked_prepare_to_printing', $redirect_to);
     }
 
     private function set_status_to_in_printing($post_ids, $redirect_to) {
-        // Use the DB Manager to update order statuses
-        Studiou_DB_Manager::set_orders_to_in_printing($post_ids);
-        
-        $redirect_to = add_query_arg('bulk_action', 'marked_in_printing', $redirect_to);
-        return $redirect_to;
+        $updated = Studiou_DB_Manager::set_orders_to_in_printing($post_ids);
+        UtilsLog::message(
+            sprintf(
+                _n(
+                    '%d order moved to "In Printing".',
+                    '%d orders moved to "In Printing".',
+                    $updated,
+                    'studiou-wc-ord-print-statuses'
+                ),
+                $updated
+            ),
+            'success'
+        );
+        return add_query_arg('bulk_action', 'marked_in_printing', $redirect_to);
     }
 
     private function generate_csv($data) {
-        $output = fopen('php://temp', 'w');
-        fputcsv($output, array_keys((array)$data[0]),','); // Headers
-
+        $output = fopen('php://temp', 'w+');
+        fputcsv($output, array_keys((array) $data[0]), ',');
         foreach ($data as $row) {
-            fputcsv($output, (array)$row, ',');
+            fputcsv($output, (array) $row, ',');
         }
-
         rewind($output);
         $csv = stream_get_contents($output);
         fclose($output);
-
         return $csv;
     }
 
+    /**
+     * Stream CSV with UTF-8 BOM so Excel on Windows reads diacritics correctly.
+     */
     private function output_csv($csv_data, $filename) {
-        header('Content-Type: text/csv');
-        header('Content-Disposition: attachment; filename="' . $filename . '"');
-        echo $csv_data;
+        if (!headers_sent()) {
+            nocache_headers();
+            header('Content-Type: text/csv; charset=utf-8');
+            header('Content-Disposition: attachment; filename="' . $filename . '"');
+        }
+        echo "\xEF\xBB\xBF" . $csv_data; // UTF-8 BOM
         exit;
     }
-}
+}

+ 60 - 51
studiou-wc-ord-print-statuses/includes/class-custom-columns-manager.php

@@ -1,51 +1,60 @@
-<?php
-//Manages custom columns in the order list.
-// visualized
-
-class Custom_Columns_Manager {
-    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);
-        //add_filter('manage_shop_order_posts_custom_column', 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);
-        //manage_shop_order_posts_custom_column
-    }
-
-    public function add_custom_shop_order_column($columns) {
-        $new_columns = array();
-
-        UtilsLog::log('Begin registering order columns...');
-        foreach ($columns as $column_name => $column_info) {
-            $new_columns[$column_name] = $column_info;
-
-            if ($column_name === 'order_total') {
-                $new_columns['to_print_date'] = __('To Print Date', 'studiou-wc-ord-print-statuses');
-                $new_columns['in_print_date'] = __('In Print Date', 'studiou-wc-ord-print-statuses');
-                $new_columns['external_order_number'] = __('External Order Number', 'studiou-wc-ord-print-statuses');
-                UtilsLog::log('3 order columns added');
-            }
-        }
-
-        return $new_columns;
-    }
-
-    public function add_custom_shop_order_column_content($column, $post_id) {
-        $order = wc_get_order($post_id);
-
-        switch ($column) {
-            case 'to_print_date':
-                $to_print_date = $order->get_meta('to_print_date');
-                echo $to_print_date ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($to_print_date)) : '—';
-                break;
-
-            case 'in_print_date':
-                $in_print_date = $order->get_meta('in_print_date');
-                echo $in_print_date ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($in_print_date)) : '—';
-                break;
-
-            case 'external_order_number':
-                echo $order->get_meta('external_ref_ord_no') ?: '—';
-                break;
-        }
-    }
-}
+<?php
+// Manages custom columns in the WooCommerce orders list (HPOS and legacy).
+
+defined('ABSPATH') || exit;
+
+class Custom_Columns_Manager {
+    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);
+    }
+
+    public function add_custom_shop_order_column($columns) {
+        $new_columns = array();
+        foreach ($columns as $column_name => $column_info) {
+            $new_columns[$column_name] = $column_info;
+            if ($column_name === 'order_total') {
+                $new_columns['to_print_date']         = __('To Print Date', 'studiou-wc-ord-print-statuses');
+                $new_columns['in_print_date']         = __('In Print Date', 'studiou-wc-ord-print-statuses');
+                $new_columns['external_order_number'] = __('External Order Number', 'studiou-wc-ord-print-statuses');
+            }
+        }
+        return $new_columns;
+    }
+
+    /**
+     * 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.
+     */
+    public function add_custom_shop_order_column_content($column, $order_or_id) {
+        $order = wc_get_order($order_or_id);
+        if (!$order) {
+            echo '—';
+            return;
+        }
+        switch ($column) {
+            case 'to_print_date':
+                echo $this->format_meta_date($order->get_meta('to_print_date'));
+                break;
+            case 'in_print_date':
+                echo $this->format_meta_date($order->get_meta('in_print_date'));
+                break;
+            case 'external_order_number':
+                $value = $order->get_meta('external_ref_ord_no');
+                echo $value !== '' ? esc_html($value) : '—';
+                break;
+        }
+    }
+
+    private function format_meta_date($value) {
+        if (empty($value)) {
+            return '—';
+        }
+        $timestamp = strtotime($value);
+        if (!$timestamp) {
+            return '—';
+        }
+        return esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $timestamp));
+    }
+}

+ 183 - 153
studiou-wc-ord-print-statuses/includes/class-db-manager.php

@@ -1,249 +1,279 @@
 <?php
 /**
- * Database operations manager for the Studiou WC Order Print Statuses plugin.
- * 
- * This class centralizes database queries for better maintainability.
+ * Database operations and CSV-protocol processing for the
+ * Studiou WC Order Print Statuses plugin.
  */
 
 defined('ABSPATH') || exit;
 
 class Studiou_DB_Manager {
     /**
-     * Get orders data for printing export
+     * Order statuses that block automated print-pipeline transitions.
+     * Cancelled / refunded orders should not be silently reactivated by a bulk
+     * action; completed orders are already finished.
+     */
+    const TERMINAL_STATUSES = array('cancelled', 'refunded', 'failed', 'completed');
+
+    /**
+     * Return one row per (order × line-item) for the print-shop export.
+     * Multiple product categories are concatenated into prod_cat instead of
+     * multiplying the row.
      *
-     * @param array $order_ids Array of order IDs
-     * @return array Array of order data
+     * @param int[] $order_ids
+     * @return array
      */
     public static function get_orders_for_printing($order_ids) {
         global $wpdb;
-        
-        $query = "
+
+        $order_ids = array_filter(array_map('intval', (array) $order_ids));
+        if (empty($order_ids)) {
+            return array();
+        }
+
+        $placeholders = implode(',', array_fill(0, count($order_ids), '%d'));
+
+        $sql = "
             SELECT
                 `orders`.`id` AS `order_no`,
-                `t`.`name` AS `prod_cat`,
+                GROUP_CONCAT(DISTINCT `t`.`name` ORDER BY `t`.`name` SEPARATOR ', ') AS `prod_cat`,
                 `products`.`post_title` AS `prod_name`,
                 `product_variations`.`post_title` AS `prod_var`,
                 `product_variations_name`.`name` AS `prod_var_type`,
                 `images`.`guid` AS `prod_img_url`,
                 `order_products`.`product_qty` AS `qty`,
                 `orders`.`billing_email` AS `email`
-            FROM (
-                    (
-                        (
-                            (
-                                (
-                                    (
-                                        (
-                                            `{$wpdb->prefix}wc_orders` `orders`
-                                        JOIN `{$wpdb->prefix}wc_order_product_lookup` `order_products`
-                                        ON
-                                            (
-                                                `orders`.`id` = `order_products`.`order_id`
-                                            )
-                                        )
-                                    JOIN `{$wpdb->posts}` `products`
-                                    ON
-                                        (
-                                            `products`.`ID` = `order_products`.`product_id`
-                                        )
-                                    )
-                                JOIN `{$wpdb->posts}` `product_variations`
-                                ON
-                                    (
-                                        `product_variations`.`ID` = `order_products`.`variation_id`
-                                    )
-                                LEFT JOIN `{$wpdb->postmeta}` `product_variations_attr`
-                                ON
-                                    (
-                                        `product_variations_attr`.`post_id` = `product_variations`.`ID`
-                                        AND `product_variations_attr`.`meta_key` = 'attribute_pa_format'
-                                    )
-                                LEFT JOIN `{$wpdb->terms}` `product_variations_name`
-                                ON
-                                    (
-                                        `product_variations_name`.`name` = `product_variations_attr`.`meta_value`
-                                    )
-                                )
-                            LEFT JOIN `{$wpdb->postmeta}` `product_meta`
-                            ON
-                                (
-                                    `product_meta`.`post_id` = `products`.`ID` AND `product_meta`.`meta_key` = '_thumbnail_id'
-                                )
-                            )
-                        LEFT JOIN `{$wpdb->posts}` `images`
-                        ON
-                            (
-                                `images`.`ID` = `product_meta`.`meta_value` AND `images`.`post_type` = 'attachment'
-                            )
-                        JOIN `{$wpdb->term_relationships}` `tr`
-                        ON
-                            (`products`.`ID` = `tr`.`object_id`)
-                        )
-                    JOIN `{$wpdb->term_taxonomy}` `tt`
-                    ON
-                        (
-                            `tr`.`term_taxonomy_id` = `tt`.`term_taxonomy_id`
-                        )
-                    )
-                JOIN `{$wpdb->terms}` `t`
-                ON
-                    (`tt`.`term_id` = `t`.`term_id`)
-                )
-            WHERE
-                `orders`.`id` IN (" . implode(',', array_map('intval', $order_ids)) . ") 
+            FROM `{$wpdb->prefix}wc_orders` `orders`
+            JOIN `{$wpdb->prefix}wc_order_product_lookup` `order_products`
+                ON `orders`.`id` = `order_products`.`order_id`
+            JOIN `{$wpdb->posts}` `products`
+                ON `products`.`ID` = `order_products`.`product_id`
+            JOIN `{$wpdb->posts}` `product_variations`
+                ON `product_variations`.`ID` = `order_products`.`variation_id`
+            LEFT JOIN `{$wpdb->postmeta}` `product_variations_attr`
+                ON `product_variations_attr`.`post_id` = `product_variations`.`ID`
+                AND `product_variations_attr`.`meta_key` = 'attribute_pa_format'
+            LEFT JOIN `{$wpdb->terms}` `product_variations_name`
+                ON `product_variations_name`.`name` = `product_variations_attr`.`meta_value`
+            LEFT JOIN `{$wpdb->postmeta}` `product_meta`
+                ON `product_meta`.`post_id` = `products`.`ID`
+                AND `product_meta`.`meta_key` = '_thumbnail_id'
+            LEFT JOIN `{$wpdb->posts}` `images`
+                ON `images`.`ID` = `product_meta`.`meta_value`
+                AND `images`.`post_type` = 'attachment'
+            LEFT JOIN `{$wpdb->term_relationships}` `tr`
+                ON `tr`.`object_id` = `products`.`ID`
+            LEFT JOIN `{$wpdb->term_taxonomy}` `tt`
+                ON `tt`.`term_taxonomy_id` = `tr`.`term_taxonomy_id`
                 AND `tt`.`taxonomy` = 'product_cat'
-            ORDER BY
-                `orders`.`id`
+            LEFT JOIN `{$wpdb->terms}` `t`
+                ON `t`.`term_id` = `tt`.`term_id`
+            WHERE `orders`.`id` IN ($placeholders)
+            GROUP BY
+                `orders`.`id`,
+                `order_products`.`product_id`,
+                `order_products`.`variation_id`
+            ORDER BY `orders`.`id`
         ";
-        
-        $results = $wpdb->get_results($query);
-        
-        // Error handling for database query failures
+
+        $prepared = $wpdb->prepare($sql, $order_ids);
+        $results  = $wpdb->get_results($prepared);
+
         if ($results === false) {
-            UtilsLog::log('SQL Error: ' . $wpdb->last_error);
+            UtilsLog::log('SQL error in get_orders_for_printing: ' . $wpdb->last_error);
             return array();
         }
-        
         return $results;
     }
 
     /**
-     * Set order status to 'to-print'
-     * 
-     * @param array $order_ids Array of order IDs
-     * @return int Number of orders updated
+     * Bulk-set orders to "to-print", skipping orders in terminal states.
+     *
+     * @param int[] $order_ids
+     * @return int Number of orders actually updated.
      */
     public static function set_orders_to_prepare_printing($order_ids) {
-        $updated_count = 0;
-        
-        foreach ($order_ids as $order_id) {
-            UtilsLog::log('Setting status Order #' . $order_id . ' to "to-print"');
-            $order = wc_get_order($order_id);
-            if ($order) {
-                $order->update_status('to-print');
-                $order->update_meta_data('to_print_date', current_time('mysql'));
-                $order->save();
-                $updated_count++;
-                UtilsLog::log('Order #' . $order_id . ' marked as "to-print"');
-                UtilsLog::message('Order #' . $order_id . ' marked as "to-print"' );
-            } else {
-                UtilsLog::log('Order #' . $order_id . ' not found');
-            }
-        }
-        
-        return $updated_count;
+        return self::bulk_set_print_status($order_ids, 'to-print', 'to_print_date');
     }
 
     /**
-     * Set order status to 'in-print'
-     * 
-     * @param array $order_ids Array of order IDs
-     * @return int Number of orders updated
+     * Bulk-set orders to "in-print", skipping orders in terminal states.
+     *
+     * @param int[] $order_ids
+     * @return int Number of orders actually updated.
      */
     public static function set_orders_to_in_printing($order_ids) {
+        return self::bulk_set_print_status($order_ids, 'in-print', 'in_print_date');
+    }
+
+    private static function bulk_set_print_status($order_ids, $status_slug, $meta_key) {
         $updated_count = 0;
-        
-        foreach ($order_ids as $order_id) {
-            $order = wc_get_order($order_id);
-            if ($order) {
-                $order->update_status('in-print');
-                $order->update_meta_data('in_print_date', current_time('mysql'));
-                $order->save();
-                $updated_count++;
-                UtilsLog::message('Order #' . $order_id . ' marked as "in-print"' );
+        $skipped       = 0;
+
+        foreach ((array) $order_ids as $order_id) {
+            $order_id = (int) $order_id;
+            $order    = wc_get_order($order_id);
+            if (!$order) {
+                UtilsLog::log('Order #' . $order_id . ' not found');
+                continue;
+            }
+            if ($order->has_status(self::TERMINAL_STATUSES)) {
+                UtilsLog::log('Order #' . $order_id . ' skipped (terminal status: ' . $order->get_status() . ')');
+                $skipped++;
+                continue;
             }
+            $order->update_status($status_slug);
+            $order->update_meta_data($meta_key, current_time('mysql'));
+            $order->save();
+            $updated_count++;
+            UtilsLog::log('Order #' . $order_id . ' marked as "' . $status_slug . '"');
+        }
+
+        if ($skipped > 0) {
+            UtilsLog::message(
+                sprintf(
+                    _n(
+                        '%d order skipped because its status is final (cancelled, refunded, failed, or completed).',
+                        '%d orders skipped because their status is final (cancelled, refunded, failed, or completed).',
+                        $skipped,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $skipped
+                ),
+                'warning'
+            );
         }
-        
         return $updated_count;
     }
 
     /**
-     * Import inprint protocol data
-     * 
-     * @param array $csv_data Parsed CSV data
-     * @return array Array with updated count and errors
+     * Import an InPrint protocol CSV.
+     *
+     * @param array $csv_data Rows produced by parse_csv().
+     * @return array{updated_count:int,errors:string[]}
      */
     public static function import_inprint_protocol($csv_data) {
         $updated_count = 0;
-        $errors = array();
+        $errors        = array();
 
-        foreach ($csv_data as $row) {
+        foreach (self::dedupe_by_order_no($csv_data) as $row) {
             if (!isset($row['order_no']) || !isset($row['externalorder']) || !isset($row['externalorderdate'])) {
                 $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
                 continue;
             }
-
             $order = wc_get_order($row['order_no']);
             if (!$order) {
                 $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
                 continue;
             }
-            
+            if ($order->has_status(self::TERMINAL_STATUSES)) {
+                $errors[] = sprintf(
+                    __('Order %s is in a final state and was not updated.', 'studiou-wc-ord-print-statuses'),
+                    $row['order_no']
+                );
+                continue;
+            }
             $order->update_status('in-print');
             $order->update_meta_data('in_print_date', current_time('mysql'));
-            $order->update_meta_data('external_ref_ord_no', $row['externalorder']);
-            $order->update_meta_data('external_ref_ord_date', $row['externalorderdate']);
+            $order->update_meta_data('external_ref_ord_no', sanitize_text_field($row['externalorder']));
+            $order->update_meta_data('external_ref_ord_date', self::normalise_csv_date($row['externalorderdate']));
             $order->save();
-
             $updated_count++;
         }
 
-        return array(
-            'updated_count' => $updated_count,
-            'errors' => $errors
-        );
+        return array('updated_count' => $updated_count, 'errors' => $errors);
     }
 
     /**
-     * Import delivered protocol data
-     * 
-     * @param array $csv_data Parsed CSV data
-     * @return array Array with updated count and errors
+     * Import a Delivered protocol CSV.
+     *
+     * @param array $csv_data Rows produced by parse_csv().
+     * @return array{updated_count:int,errors:string[]}
      */
     public static function import_delivered_protocol($csv_data) {
         $updated_count = 0;
-        $errors = array();
+        $errors        = array();
 
-        foreach ($csv_data as $row) {
+        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');
                 continue;
             }
-           
             $order = wc_get_order($row['order_no']);
             if (!$order) {
                 $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
                 continue;
             }
-
             $order->update_status('completed');
             $order->update_meta_data('external_ref_ord_delivered', current_time('mysql'));
             $order->save();
-
             $updated_count++;
         }
 
-        return array(
-            'updated_count' => $updated_count,
-            'errors' => $errors
-        );
+        return array('updated_count' => $updated_count, 'errors' => $errors);
     }
 
     /**
-     * Parse CSV file
-     * 
-     * @param string $file_path Path to CSV file
-     * @return array|false Parsed CSV data or false on error
+     * Parse a CSV file into an array of associative rows.
+     * Headers are normalised (lowercased, whitespace stripped) so files prepared
+     * to either the legacy spec casing ("Order No") or the documented lowercase
+     * form ("order_no") both work.
+     *
+     * @param string $file_path
+     * @return array Empty array on failure.
      */
     public static function parse_csv($file_path) {
         $csv_data = array();
-        if (($handle = fopen($file_path, "r")) !== FALSE) {
-            $headers = fgetcsv($handle, 1000, ",");
-            while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) {
+        $handle   = @fopen($file_path, 'r');
+        if ($handle === false) {
+            UtilsLog::log('parse_csv: failed to open ' . $file_path);
+            return $csv_data;
+        }
+        try {
+            $headers = fgetcsv($handle, 0, ',');
+            if (!is_array($headers) || empty(array_filter($headers))) {
+                UtilsLog::log('parse_csv: header row missing or empty');
+                return $csv_data;
+            }
+            $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);
             }
+        } finally {
             fclose($handle);
         }
         return $csv_data;
     }
-}
+
+    private static function normalise_header($header) {
+        $header = strtolower(trim((string) $header));
+        // Map "order no" → "order_no" by collapsing internal whitespace to underscore.
+        $header = preg_replace('/\s+/', '_', $header);
+        return $header;
+    }
+
+    private static function dedupe_by_order_no($rows) {
+        $seen   = array();
+        $unique = array();
+        foreach ($rows as $row) {
+            $key = isset($row['order_no']) ? trim((string) $row['order_no']) : '';
+            if ($key === '' || isset($seen[$key])) {
+                continue;
+            }
+            $seen[$key] = true;
+            $unique[]   = $row;
+        }
+        return $unique;
+    }
+
+    private static function normalise_csv_date($raw) {
+        $raw = sanitize_text_field((string) $raw);
+        if ($raw === '') {
+            return '';
+        }
+        $timestamp = strtotime($raw);
+        return $timestamp ? date('Y-m-d H:i:s', $timestamp) : $raw;
+    }
+}

+ 115 - 49
studiou-wc-ord-print-statuses/includes/class-import-manager.php

@@ -1,10 +1,12 @@
 <?php
-//  Handles import functionality for InPrint and Delivered protocols.
-// visualized
+// Admin UI + handlers for importing the InPrint and Delivered protocol CSVs.
+
+defined('ABSPATH') || exit;
+
 class Import_Manager {
     public function __construct() {
         add_action('admin_menu', array($this, 'add_import_submenu'));
-        add_action('admin_post_import_inprint_protocol', array($this, 'handle_import_inprint_protocol'));
+        add_action('admin_post_import_inprint_protocol',   array($this, 'handle_import_inprint_protocol'));
         add_action('admin_post_import_delivered_protocol', array($this, 'handle_import_delivered_protocol'));
     }
 
@@ -23,20 +25,38 @@ class Import_Manager {
         ?>
         <div class="wrap">
             <h1><?php echo esc_html(get_admin_page_title()); ?></h1>
-            
-            <h2><?php _e('Import InPrint Protocol', 'studiou-wc-ord-print-statuses'); ?></h2>
+
+            <h2><?php esc_html_e('Import InPrint Protocol', 'studiou-wc-ord-print-statuses'); ?></h2>
+            <p>
+                <?php
+                printf(
+                    /* translators: list of required CSV header names. */
+                    esc_html__('Required CSV headers: %s', 'studiou-wc-ord-print-statuses'),
+                    '<code>order_no</code>, <code>externalorder</code>, <code>externalorderdate</code>'
+                );
+                ?>
+            </p>
             <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" enctype="multipart/form-data">
                 <input type="hidden" name="action" value="import_inprint_protocol">
                 <?php wp_nonce_field('import_inprint_protocol', 'import_inprint_protocol_nonce'); ?>
-                <input type="file" name="inprint_csv" accept=".csv" required>
+                <input type="file" name="inprint_csv" accept=".csv,text/csv" required>
                 <?php submit_button(__('Import InPrint Protocol', 'studiou-wc-ord-print-statuses')); ?>
             </form>
 
-            <h2><?php _e('Import Delivered Protocol', 'studiou-wc-ord-print-statuses'); ?></h2>
+            <h2><?php esc_html_e('Import Delivered Protocol', 'studiou-wc-ord-print-statuses'); ?></h2>
+            <p>
+                <?php
+                printf(
+                    /* translators: list of required CSV header names. */
+                    esc_html__('Required CSV headers: %s', 'studiou-wc-ord-print-statuses'),
+                    '<code>order_no</code>'
+                );
+                ?>
+            </p>
             <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" enctype="multipart/form-data">
                 <input type="hidden" name="action" value="import_delivered_protocol">
                 <?php wp_nonce_field('import_delivered_protocol', 'import_delivered_protocol_nonce'); ?>
-                <input type="file" name="delivered_csv" accept=".csv" required>
+                <input type="file" name="delivered_csv" accept=".csv,text/csv" required>
                 <?php submit_button(__('Import Delivered Protocol', 'studiou-wc-ord-print-statuses')); ?>
             </form>
         </div>
@@ -44,66 +64,112 @@ class Import_Manager {
     }
 
     public function handle_import_inprint_protocol() {
+        $this->run_import('inprint_csv', 'import_inprint_protocol', 'import_inprint_protocol_nonce', 'import_inprint_protocol');
+    }
+
+    public function handle_import_delivered_protocol() {
+        $this->run_import('delivered_csv', 'import_delivered_protocol', 'import_delivered_protocol_nonce', 'import_delivered_protocol');
+    }
+
+    /**
+     * Common pipeline: capability check → nonce → file validation → parse → dispatch → redirect.
+     *
+     * @param string $file_field
+     * @param string $action       Protocol id (inprint|delivered) used to pick the DB handler.
+     * @param string $nonce_field
+     * @param string $nonce_action
+     */
+    private function run_import($file_field, $action, $nonce_field, $nonce_action) {
         if (!current_user_can('manage_woocommerce')) {
-            wp_die(__('You do not have sufficient permissions to access this page.', 'studiou-wc-ord-print-statuses'));
+            wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'studiou-wc-ord-print-statuses'));
         }
+        check_admin_referer($nonce_action, $nonce_field);
 
-        check_admin_referer('import_inprint_protocol', 'import_inprint_protocol_nonce');
-
-        if (!isset($_FILES['inprint_csv'])) {
-            wp_die(__('No file uploaded.', 'studiou-wc-ord-print-statuses'));
+        $file = $this->validated_upload($file_field);
+        if ($file === null) {
+            $this->redirect_back();
         }
 
-        $file = $_FILES['inprint_csv'];
         $csv_data = Studiou_DB_Manager::parse_csv($file['tmp_name']);
-
-        if (!$csv_data) {
-            wp_die(__('Error parsing CSV file.', 'studiou-wc-ord-print-statuses'));
+        if (empty($csv_data)) {
+            UtilsLog::message(
+                __('CSV file could not be parsed or contained no data rows.', 'studiou-wc-ord-print-statuses'),
+                'error'
+            );
+            $this->redirect_back();
         }
 
-        // Process the import using DB Manager
-        $result = Studiou_DB_Manager::import_inprint_protocol($csv_data);
-        $updated_count = $result['updated_count'];
-        $errors = $result['errors'];
-
-        $message = sprintf(__('%d orders updated successfully.', 'studiou-wc-ord-print-statuses'), $updated_count);
-        if (!empty($errors)) {
-            $message .= ' ' . sprintf(__('%d errors occurred: %s', 'studiou-wc-ord-print-statuses'), count($errors), implode(', ', $errors));
+        if ($action === 'import_inprint_protocol') {
+            $result = Studiou_DB_Manager::import_inprint_protocol($csv_data);
+        } else {
+            $result = Studiou_DB_Manager::import_delivered_protocol($csv_data);
         }
 
-        wp_redirect(add_query_arg('message', urlencode($message), admin_url('admin.php?page=studiou-import-protocols')));
-        exit;
+        $this->emit_import_summary($result);
+        $this->redirect_back();
     }
 
-    public function handle_import_delivered_protocol() {
-        if (!current_user_can('manage_woocommerce')) {
-            wp_die(__('You do not have sufficient permissions to access this page.', 'studiou-wc-ord-print-statuses'));
+    /**
+     * Validate the file upload superglobal entry. Emits a notice and returns null on failure.
+     *
+     * @return array|null
+     */
+    private function validated_upload($field) {
+        if (!isset($_FILES[$field]) || !is_array($_FILES[$field])) {
+            UtilsLog::message(__('No file uploaded.', 'studiou-wc-ord-print-statuses'), 'error');
+            return null;
         }
-
-        check_admin_referer('import_delivered_protocol', 'import_delivered_protocol_nonce');
-
-        if (!isset($_FILES['delivered_csv'])) {
-            wp_die(__('No file uploaded.', 'studiou-wc-ord-print-statuses'));
+        $file = $_FILES[$field];
+        if (isset($file['error']) && $file['error'] !== UPLOAD_ERR_OK) {
+            UtilsLog::message(
+                sprintf(
+                    /* translators: %d is the PHP upload error code. */
+                    __('File upload failed (error code %d).', 'studiou-wc-ord-print-statuses'),
+                    (int) $file['error']
+                ),
+                'error'
+            );
+            return null;
         }
-
-        $file = $_FILES['delivered_csv'];
-        $csv_data = Studiou_DB_Manager::parse_csv($file['tmp_name']);
-
-        if (!$csv_data) {
-            wp_die(__('Error parsing CSV file.', 'studiou-wc-ord-print-statuses'));
+        if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
+            UtilsLog::message(__('Uploaded file is not accessible.', 'studiou-wc-ord-print-statuses'), 'error');
+            return null;
         }
+        return $file;
+    }
 
-        // Process the import using DB Manager
-        $result = Studiou_DB_Manager::import_delivered_protocol($csv_data);
-        $updated_count = $result['updated_count'];
-        $errors = $result['errors'];
+    private function emit_import_summary($result) {
+        $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'
+                ),
+                $updated
+            ),
+            $updated > 0 ? 'success' : 'info'
+        );
 
-        $message = sprintf(__('%d orders updated successfully.', 'studiou-wc-ord-print-statuses'), $updated_count);
         if (!empty($errors)) {
-            $message .= ' ' . sprintf(__('%d errors occurred: %s', 'studiou-wc-ord-print-statuses'), count($errors), implode(', ', $errors));
+            UtilsLog::message(
+                sprintf(
+                    /* translators: 1: error count, 2: comma-separated error messages. */
+                    __('%1$d errors occurred: %2$s', 'studiou-wc-ord-print-statuses'),
+                    count($errors),
+                    esc_html(implode(', ', $errors))
+                ),
+                'error'
+            );
         }
+    }
 
-        wp_redirect(add_query_arg('message', urlencode($message), admin_url('admin.php?page=studiou-import-protocols')));
+    private function redirect_back() {
+        wp_safe_redirect(admin_url('admin.php?page=studiou-import-protocols'));
         exit;
     }
-}
+}

+ 101 - 66
studiou-wc-ord-print-statuses/includes/class-order-fields-manager.php

@@ -1,66 +1,101 @@
-<?php
-// Manages custom order fields.
-// visualized
-
-class Order_Fields_Manager {
-    public function __construct() {
-        add_action('woocommerce_admin_order_data_after_order_details', array($this, 'display_custom_order_fields'));
-        add_action('woocommerce_process_shop_order_meta', array($this, 'save_custom_order_fields'));
-    }
-
-    public function display_custom_order_fields($order) {
-        ?>
-        <div class="order_data_column">
-            <h4><?php _e('Print Information', 'studiou-wc-ord-print-statuses'); ?></h4>
-            <?php
-            woocommerce_wp_text_input(array(
-                'id' => 'to_print_date',
-                'label' => __('Prepare To Print Date', 'studiou-wc-ord-print-statuses'),
-                'type' => 'datetime-local',
-                'value' => $order->get_meta('to_print_date'),
-                'wrapper_class' => 'form-field-wide'
-            ));
-            woocommerce_wp_text_input(array(
-                'id' => 'in_print_date',
-                'label' => __('In Print Date', 'studiou-wc-ord-print-statuses'),
-                'type' => 'datetime-local',
-                'value' => $order->get_meta('in_print_date'),
-                'wrapper_class' => 'form-field-wide'
-            ));
-            woocommerce_wp_text_input(array(
-                'id' => 'external_ref_ord_no',
-                'label' => __('External Order Number', 'studiou-wc-ord-print-statuses'),
-                'value' => $order->get_meta('external_ref_ord_no'),
-                'wrapper_class' => 'form-field-wide'
-            ));
-            woocommerce_wp_text_input(array(
-                'id' => 'external_ref_ord_delivered',
-                'label' => __('External Order Delivered', 'studiou-wc-ord-print-statuses'),
-                'type' => 'datetime-local',
-                'value' => $order->get_meta('external_ref_ord_delivered'),
-                'wrapper_class' => 'form-field-wide'
-            ));
-            ?>
-        </div>
-        <?php
-    }
-
-    public function save_custom_order_fields($order_id) {
-        $order = wc_get_order($order_id);
-        
-        if (isset($_POST['to_print_date'])) {
-            $order->update_meta_data('to_print_date', sanitize_text_field($_POST['to_print_date']));
-        }
-        if (isset($_POST['in_print_date'])) {
-            $order->update_meta_data('in_print_date', sanitize_text_field($_POST['in_print_date']));
-        }
-        if (isset($_POST['external_ref_ord_no'])) {
-            $order->update_meta_data('external_ref_ord_no', sanitize_text_field($_POST['external_ref_ord_no']));
-        }
-        if (isset($_POST['external_ref_ord_delivered'])) {
-            $order->update_meta_data('external_ref_ord_delivered', sanitize_text_field($_POST['external_ref_ord_delivered']));
-        }
-        
-        $order->save();
-    }
-}
+<?php
+// Manages custom order fields rendered in the "Print Information" panel.
+
+defined('ABSPATH') || exit;
+
+class Order_Fields_Manager {
+    private $datetime_fields = array(
+        'to_print_date'              => 'Prepare To Print Date',
+        'in_print_date'              => 'In Print Date',
+        'external_ref_ord_date'      => 'External Order Date',
+        'external_ref_ord_delivered' => 'External Order Delivered',
+    );
+    private $text_fields = array(
+        'external_ref_ord_no' => 'External Order Number',
+    );
+
+    public function __construct() {
+        add_action('woocommerce_admin_order_data_after_order_details', array($this, 'display_custom_order_fields'));
+        add_action('woocommerce_process_shop_order_meta', array($this, 'save_custom_order_fields'));
+    }
+
+    public function display_custom_order_fields($order) {
+        ?>
+        <div class="order_data_column">
+            <h4><?php esc_html_e('Print Information', 'studiou-wc-ord-print-statuses'); ?></h4>
+            <?php
+            woocommerce_wp_text_input(array(
+                'id'            => 'to_print_date',
+                'label'         => __('Prepare To Print Date', 'studiou-wc-ord-print-statuses'),
+                'type'          => 'datetime-local',
+                'value'         => $this->mysql_to_datetime_local($order->get_meta('to_print_date')),
+                'wrapper_class' => 'form-field-wide',
+            ));
+            woocommerce_wp_text_input(array(
+                'id'            => 'in_print_date',
+                'label'         => __('In Print Date', 'studiou-wc-ord-print-statuses'),
+                'type'          => 'datetime-local',
+                'value'         => $this->mysql_to_datetime_local($order->get_meta('in_print_date')),
+                'wrapper_class' => 'form-field-wide',
+            ));
+            woocommerce_wp_text_input(array(
+                'id'            => 'external_ref_ord_no',
+                'label'         => __('External Order Number', 'studiou-wc-ord-print-statuses'),
+                'value'         => $order->get_meta('external_ref_ord_no'),
+                'wrapper_class' => 'form-field-wide',
+            ));
+            woocommerce_wp_text_input(array(
+                'id'            => 'external_ref_ord_date',
+                'label'         => __('External Order Date', 'studiou-wc-ord-print-statuses'),
+                'type'          => 'datetime-local',
+                'value'         => $this->mysql_to_datetime_local($order->get_meta('external_ref_ord_date')),
+                'wrapper_class' => 'form-field-wide',
+            ));
+            woocommerce_wp_text_input(array(
+                'id'            => 'external_ref_ord_delivered',
+                'label'         => __('External Order Delivered', 'studiou-wc-ord-print-statuses'),
+                'type'          => 'datetime-local',
+                'value'         => $this->mysql_to_datetime_local($order->get_meta('external_ref_ord_delivered')),
+                'wrapper_class' => 'form-field-wide',
+            ));
+            ?>
+        </div>
+        <?php
+    }
+
+    public function save_custom_order_fields($order_id) {
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+
+        foreach (array_keys($this->datetime_fields) as $field) {
+            if (isset($_POST[$field])) {
+                $order->update_meta_data($field, $this->datetime_local_to_mysql($_POST[$field]));
+            }
+        }
+        foreach (array_keys($this->text_fields) as $field) {
+            if (isset($_POST[$field])) {
+                $order->update_meta_data($field, sanitize_text_field(wp_unslash($_POST[$field])));
+            }
+        }
+        $order->save();
+    }
+
+    private function mysql_to_datetime_local($value) {
+        if (empty($value)) {
+            return '';
+        }
+        $timestamp = strtotime($value);
+        return $timestamp ? date('Y-m-d\TH:i', $timestamp) : '';
+    }
+
+    private function datetime_local_to_mysql($value) {
+        $value = sanitize_text_field(wp_unslash($value));
+        if ($value === '') {
+            return '';
+        }
+        $timestamp = strtotime($value);
+        return $timestamp ? date('Y-m-d H:i:s', $timestamp) : '';
+    }
+}

+ 26 - 50
studiou-wc-ord-print-statuses/includes/class-order-search-manager.php

@@ -1,63 +1,39 @@
 <?php
-// Extends order search functionality.
+/**
+ * Extends WooCommerce order search to find orders by external_ref_ord_no.
+ *
+ * The hooks below cover both legacy (CPT) and HPOS storage modes. They make the
+ * existing "Search orders" input also match the external reference number meta
+ * — no additional UI is required.
+ */
+
+defined('ABSPATH') || exit;
 
 class Order_Search_Manager {
-    public function __construct() {
-        add_filter('manage_woocommerce_page_wc-orders_columns', array($this, 'add_external_ref_ord_no_to_search'));
-        add_action('manage_woocommerce_page_wc-orders_custom_column', array($this, 'add_external_ref_ord_no_search_field'));
-        add_filter('parse_query', array($this, 'process_external_ref_ord_no_search')); // Uncommented this line
-    }
+    const META_KEY = 'external_ref_ord_no';
 
-    /**
-     * Adds the external_ref_ord_no to the list of searchable fields for orders.
-     *
-     * @param array $search_fields The current array of searchable fields.
-     * @return array The updated array of searchable fields.
-     */
-    public function add_external_ref_ord_no_to_search($search_fields) {
-        $search_fields[] = '_external_ref_ord_no';
-        return $search_fields;
-    }
+    public function __construct() {
+        // Legacy CPT-backed storage: WC includes these post-meta keys in the search query.
+        add_filter('woocommerce_shop_order_search_fields', array($this, 'add_meta_key_to_search'));
 
-    /**
-     * Adds a search field for external_ref_ord_no in the order list page.
-     */
-    public function add_external_ref_ord_no_search_field() {
-        global $typenow;
-        if ('shop_order' === $typenow) {
-            $external_ref_ord_no = isset($_GET['external_ref_ord_no']) ? sanitize_text_field($_GET['external_ref_ord_no']) : '';
-            ?>
-            <input type="text" name="external_ref_ord_no" value="<?php echo esc_attr($external_ref_ord_no); ?>" placeholder="<?php esc_attr_e('Search by External Order Number', 'studiou-wc-ord-print-statuses'); ?>">
-            <?php
-        }
+        // HPOS-backed storage: WC searches order meta listed here.
+        add_filter('woocommerce_order_table_search_query_meta_keys', array($this, 'add_meta_key_to_search'));
     }
 
     /**
-     * Processes the external_ref_ord_no search query.
+     * Append the external reference meta key to whichever list WC passes us.
+     * Same callback works for both filters above.
      *
-     * @param WP_Query $query The current WordPress query object.
-     * @return WP_Query The modified query object.
+     * @param array $keys Meta keys / search fields collected by WC.
+     * @return array
      */
-    public function process_external_ref_ord_no_search($query) {
-        global $pagenow, $typenow;
-
-        if ('edit.php' !== $pagenow || 'shop_order' !== $typenow || !$query->is_main_query()) {
-            return $query;
+    public function add_meta_key_to_search($keys) {
+        if (!is_array($keys)) {
+            $keys = array();
         }
-
-        if (!empty($_GET['external_ref_ord_no'])) {
-            $external_ref_ord_no = sanitize_text_field($_GET['external_ref_ord_no']);
-            $meta_query = $query->get('meta_query') ? $query->get('meta_query') : array();
-
-            $meta_query[] = array(
-                'key' => '_external_ref_ord_no',
-                'value' => $external_ref_ord_no,
-                'compare' => 'LIKE',
-            );
-
-            $query->set('meta_query', $meta_query);
+        if (!in_array(self::META_KEY, $keys, true)) {
+            $keys[] = self::META_KEY;
         }
-
-        return $query;
+        return $keys;
     }
-}
+}

+ 115 - 79
studiou-wc-ord-print-statuses/includes/class-order-status-manager.php

@@ -1,79 +1,115 @@
-<?php
-// Handles custom order status registration and integration.
-class Order_Status_Manager {
-    private $custom_statuses;
-
-    public function __construct() {
-        $this->custom_statuses = array(
-            'wc-to-print' => array(
-                'label' => _x('Prepare to Printing', 'Order status', 'studiou-wc-ord-print-statuses'),
-                'label_count' => _n_noop('Prepare to Printing <span class="count">(%s)</span>', 'Prepare to Printing <span class="count">(%s)</span>', 'studiou-wc-ord-print-statuses')
-            ),
-            'wc-in-print' => array(
-                'label' => _x('In Printing', 'Order status', 'studiou-wc-ord-print-statuses'),
-                'label_count' => _n_noop('In Printing <span class="count">(%s)</span>', 'In Printing <span class="count">(%s)</span>', 'studiou-wc-ord-print-statuses')
-            )
-        );
-
-        add_action('init', array($this, 'register_custom_order_statuses'));
-        add_filter('wc_order_statuses', array($this, 'add_custom_order_statuses_to_list'));
-        add_action('woocommerce_order_status_changed', array($this, 'handle_status_transitions'), 10, 3);
-        add_action('woocommerce_payment_complete', array($this, 'set_order_processing_status'));
-
-    }
-
-    public function register_custom_order_statuses() {
-        foreach ($this->custom_statuses as $status_slug => $status_args) {
-            register_post_status($status_slug, array(
-                'label' => $status_args['label'],
-                'public' => true,
-                'exclude_from_search' => false,
-                'show_in_admin_all_list' => true,
-                'show_in_admin_status_list' => true,
-                'label_count' => $status_args['label_count']
-            ));
-        }
-        UtilsLog::log('Statuses registered');
-    }
-
-    public function add_custom_order_statuses_to_list($order_statuses) {
-        $new_order_statuses = array();
-
-        foreach ($order_statuses as $key => $status) {
-            $new_order_statuses[$key] = $status;
-
-            if ($key === 'wc-processing') {
-                foreach ($this->custom_statuses as $status_slug => $status_args) {
-                    $new_order_statuses[$status_slug] = $status_args['label'];
-                }
-            }        }
-
-        return $new_order_statuses;
-    }
-
-    public function handle_status_transitions($order_id, $old_status, $new_status) {
-        // Handle status transitions here
-        // For example:
-        UtilsLog::log('Order #' . $order_id . ' status change ' . $old_status . ' -> ' . $new_status);
-        if ($new_status === 'to-print') {
-            update_post_meta($order_id, 'to_print_date', current_time('mysql'));
-        } elseif ($new_status === 'in-print') {
-            update_post_meta($order_id, 'in_print_date', current_time('mysql'));
-        }
-    }
-
-    public function set_order_processing_status($order_id) {
-        UtilsLog::log('Order #' . $order_id . ' payment complete');
-        $order = wc_get_order($order_id);
-        if ($order && !$order->has_status('completed')) {
-            UtilsLog::log('Order #' . $order_id . ' will be move to processing');
-            $order->update_status('processing', __('Payment received, order is now processing.', 'studiou-wc-ord-print-statuses'));
-            // Add a note to the order
-            $order->add_order_note(__('Order automatically set to processing by Studiou WC Order Print Statuses plugin.', 'studiou-wc-ord-print-statuses'));
-            
-        }
-    }
-    public function get_custom_statuses() {
-        return $this->custom_statuses;
-    }
-}
+<?php
+// Handles custom order status registration and integration.
+
+defined('ABSPATH') || exit;
+
+class Order_Status_Manager {
+    private $custom_statuses;
+    private $print_status_slugs = array('to-print', 'in-print');
+
+    public function __construct() {
+        add_action('init', array($this, 'register_custom_order_statuses'));
+        add_filter('wc_order_statuses', array($this, 'add_custom_order_statuses_to_list'));
+        add_action('woocommerce_order_status_changed', array($this, 'handle_status_transitions'), 10, 3);
+        add_action('woocommerce_payment_complete', array($this, 'set_order_processing_status'));
+    }
+
+    private function get_custom_status_definitions() {
+        if ($this->custom_statuses === null) {
+            $this->custom_statuses = array(
+                'wc-to-print' => array(
+                    'label'       => _x('Prepare to Printing', 'Order status', 'studiou-wc-ord-print-statuses'),
+                    'label_count' => _n_noop(
+                        'Prepare to Printing <span class="count">(%s)</span>',
+                        'Prepare to Printing <span class="count">(%s)</span>',
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                ),
+                'wc-in-print' => array(
+                    'label'       => _x('In Printing', 'Order status', 'studiou-wc-ord-print-statuses'),
+                    'label_count' => _n_noop(
+                        'In Printing <span class="count">(%s)</span>',
+                        'In Printing <span class="count">(%s)</span>',
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                ),
+            );
+        }
+        return $this->custom_statuses;
+    }
+
+    public function register_custom_order_statuses() {
+        foreach ($this->get_custom_status_definitions() as $status_slug => $status_args) {
+            register_post_status($status_slug, array(
+                'label'                     => $status_args['label'],
+                'public'                    => true,
+                'exclude_from_search'       => false,
+                'show_in_admin_all_list'    => true,
+                'show_in_admin_status_list' => true,
+                'label_count'               => $status_args['label_count'],
+            ));
+        }
+    }
+
+    public function add_custom_order_statuses_to_list($order_statuses) {
+        $new_order_statuses = array();
+        foreach ($order_statuses as $key => $status) {
+            $new_order_statuses[$key] = $status;
+            if ($key === 'wc-processing') {
+                foreach ($this->get_custom_status_definitions() as $status_slug => $status_args) {
+                    $new_order_statuses[$status_slug] = $status_args['label'];
+                }
+            }
+        }
+        return $new_order_statuses;
+    }
+
+    /**
+     * Stamp the relevant meta key when an order transitions into a print status.
+     * Uses the order object (HPOS-safe) rather than update_post_meta.
+     */
+    public function handle_status_transitions($order_id, $old_status, $new_status) {
+        UtilsLog::log('Order #' . $order_id . ' status change ' . $old_status . ' -> ' . $new_status);
+
+        if (!in_array($new_status, $this->print_status_slugs, true)) {
+            return;
+        }
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+        $meta_key = ($new_status === 'to-print') ? 'to_print_date' : 'in_print_date';
+        if (!$order->get_meta($meta_key)) {
+            $order->update_meta_data($meta_key, current_time('mysql'));
+            $order->save();
+        }
+    }
+
+    /**
+     * Move paid orders to "processing" on payment completion — except when the
+     * order is already past that point (completed) or has been advanced into the
+     * print pipeline (to-print, in-print). Otherwise late payments would silently
+     * roll a print-state order back to processing.
+     */
+    public function set_order_processing_status($order_id) {
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+        $protected = array_merge(array('completed'), $this->print_status_slugs);
+        if ($order->has_status($protected)) {
+            return;
+        }
+        $order->update_status(
+            'processing',
+            __('Payment received, order is now processing.', 'studiou-wc-ord-print-statuses')
+        );
+        $order->add_order_note(
+            __('Order automatically set to processing by Studiou WC Order Print Statuses plugin.', 'studiou-wc-ord-print-statuses')
+        );
+    }
+
+    public function get_custom_statuses() {
+        return $this->get_custom_status_definitions();
+    }
+}

+ 70 - 28
studiou-wc-ord-print-statuses/includes/utils-log.php

@@ -1,28 +1,70 @@
-<?php
-//  Basic logging functionality to std log
-
-
-class UtilsLog {
-    public static function log($message) {
-        if ( true === WP_DEBUG ) {
-            if ( is_array( $message ) || is_object( $message ) ) {
-                error_log( print_r( $message, true ) );
-            } else {
-                error_log( $message );
-            }
-        }
-    }
-    public static function message($message) {
-
-        /*
-        wp_admin_notice(
-            __( $message, 'studiou-wc-ord-print-statuses' ),
-            array(       
-                'type'             => 'info',         
-                'additional_classes' => array( 'updated' ),
-                'dismissible'        => true
-            )
-        );      
-          */
-    }
-}
+<?php
+// Logging + admin-notice utility for the plugin.
+
+defined('ABSPATH') || exit;
+
+class UtilsLog {
+    const NOTICE_TRANSIENT_PREFIX = 'studiou_wc_ops_notices_';
+    const NOTICE_TTL              = 60; // seconds
+
+    public static function init() {
+        add_action('admin_notices', array(__CLASS__, 'render_notices'));
+    }
+
+    public static function log($message) {
+        if (defined('WP_DEBUG') && WP_DEBUG) {
+            if (is_array($message) || is_object($message)) {
+                error_log('[studiou-wc-ord-print-statuses] ' . print_r($message, true));
+            } else {
+                error_log('[studiou-wc-ord-print-statuses] ' . $message);
+            }
+        }
+    }
+
+    /**
+     * Queue an admin notice for the current user.
+     * Notices survive the redirect that follows bulk actions and imports.
+     *
+     * @param string $message Plain text or pre-escaped HTML (will be wp_kses_post-rendered).
+     * @param string $type    One of: info, success, warning, error.
+     */
+    public static function message($message, $type = 'info') {
+        $user_id = get_current_user_id();
+        if (!$user_id) {
+            return;
+        }
+        $allowed = array('info', 'success', 'warning', 'error');
+        if (!in_array($type, $allowed, true)) {
+            $type = 'info';
+        }
+        $key     = self::NOTICE_TRANSIENT_PREFIX . $user_id;
+        $notices = get_transient($key);
+        if (!is_array($notices)) {
+            $notices = array();
+        }
+        $notices[] = array('message' => (string) $message, 'type' => $type);
+        set_transient($key, $notices, self::NOTICE_TTL);
+    }
+
+    public static function render_notices() {
+        $user_id = get_current_user_id();
+        if (!$user_id) {
+            return;
+        }
+        $key     = self::NOTICE_TRANSIENT_PREFIX . $user_id;
+        $notices = get_transient($key);
+        if (!is_array($notices) || empty($notices)) {
+            return;
+        }
+        delete_transient($key);
+        foreach ($notices as $notice) {
+            $type    = isset($notice['type']) ? $notice['type'] : 'info';
+            $message = isset($notice['message']) ? $notice['message'] : '';
+            printf(
+                '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
+                esc_attr($type),
+                wp_kses_post($message)
+            );
+        }
+    }
+}

+ 16 - 20
studiou-wc-ord-print-statuses/studiou-wc-ord-print-statuses.php

@@ -3,12 +3,12 @@
  * 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.3.2
+ * Version: 1.4.0
  * Requires at least: 5.8
- * Tested up to: 6.8.3
+ * Tested up to: 6.9.4
  * Requires PHP: 7.2
  * WC requires at least: 3.0
- * WC tested up to: 10.2.2
+ * WC tested up to: 10.7.0
  * Author: Dalibor Votruba
  * Author URI: https://www.quadarax.com
  * License: GPL v2 or later
@@ -19,8 +19,11 @@
 
 defined('ABSPATH') || exit;
 
+define('STUDIOU_WC_OPS_VERSION', '1.4.0');
+define('STUDIOU_WC_OPS_FILE', __FILE__);
+
 // Declare HPOS (High-Performance Order Storage) compatibility
-add_action('before_woocommerce_init', function() {
+add_action('before_woocommerce_init', function () {
     if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
         \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
     }
@@ -44,7 +47,6 @@ class Studiou_WC_Ord_Print_Statuses {
     }
 
     public function __construct() {
-        // add_action('plugins_loaded', array($this, 'init'));
         $this->init();
     }
 
@@ -56,7 +58,7 @@ class Studiou_WC_Ord_Print_Statuses {
 
         $this->load_dependencies();
         $this->initialize_managers();
-        UtilsLog::log("Plugin initialized");
+        UtilsLog::init();
     }
 
     private function load_dependencies() {
@@ -73,19 +75,15 @@ class Studiou_WC_Ord_Print_Statuses {
         foreach ($files as $file) {
             require_once plugin_dir_path(__FILE__) . 'includes/' . $file;
         }
-
-        UtilsLog::log('Dependencies loaded');
     }
 
     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->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();
-
-        UtilsLog::log('Managers initialized');
+        $this->import_manager         = new Import_Manager();
     }
 
     public function woocommerce_missing_notice() {
@@ -97,11 +95,9 @@ class Studiou_WC_Ord_Print_Statuses {
     }
 }
 
-// Load the plugin text domain - translations
+// Load translations first (priority 5), then boot the plugin (default priority 10).
 function studiou_wc_ord_print_statuses_load_textdomain() {
     load_plugin_textdomain('studiou-wc-ord-print-statuses', false, dirname(plugin_basename(__FILE__)) . '/languages');
-    UtilsLog::log("Text domain (translations) loaded");
 }
-add_action('plugins_loaded', 'studiou_wc_ord_print_statuses_load_textdomain');
-// Initialize the plugin
-add_action( 'plugins_loaded', array( 'Studiou_WC_Ord_Print_Statuses', 'initPlugin' ));
+add_action('plugins_loaded', 'studiou_wc_ord_print_statuses_load_textdomain', 5);
+add_action('plugins_loaded', array('Studiou_WC_Ord_Print_Statuses', 'initPlugin'));