Browse Source

Release v1.4.1 — resolve every in-scope finding from revise-1.4.0

Critical fixes:
- Export SQL is now ONLY_FULL_GROUP_BY-safe (non-aggregated columns wrapped
  in MIN()); SET SESSION group_concat_max_len = 65535 before the query
  prevents silent prod_cat truncation.
- parse_csv() strips a leading UTF-8 BOM (which the plugin's own exports
  ship for Excel-on-Windows), so export-then-reimport workflows work.
- Order_Fields_Manager datetime round-trip is pure string reformat —
  no strtotime()/date(), no server-vs-WP timezone drift.

Other resolved items:
- output_csv() fails loudly via wp_die() when headers_sent(), logging the
  offending file:line instead of streaming CSV into a half-rendered page.
- fputcsv/fgetcsv pass an explicit '' escape — no PHP 8.4 deprecation noise.
- set_order_processing_status() records a single order note via update_status.
- notify_moved_count() suppresses zero-count success notices.
- normalise_csv_date() returns '' (and logs) on parse failure.
- dedupe_by_order_no() is now case-insensitive.
- Import_Manager::protocol_dispatch() centralises the per-protocol config;
  run_import() returns explicitly via redirect_back() on every non-happy
  branch (static-analysis friendly).
- Order_Fields_Manager render + save driven from datetime_fields()/text_fields()
  maps; current_user_can('edit_shop_order') gate added on save.
- Order_Status_Manager::handle_status_transitions carries a docblock
  explaining the intentional bulk-vs-listener double-stamp contract.
- UtilsLog notice TTL bumped 60s → 300s.

Documentation:
- README, CLAUDE.md: 1.4.1 changelog entries with per-fix detail.
- docs/revise-1.4.0.md: every finding annotated ✅ resolved / ⏸ deferred.
- docs/translations.txt: merged payment-complete string updated; new
  CSV-export safety string added. .mo NOT rebuilt.

Deferred (carried over): HPOS search-filter live verification, Network:
header, uninstall.php, block-based order edit screen, wp_kses_post
narrowing, per-tab race in render_notices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dalibor Votruba 2 months ago
parent
commit
f8b7de9317

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

@@ -8,7 +8,7 @@ This is a WordPress plugin that extends WooCommerce functionality to manage prin
 
 **Plugin Details:**
 - Text Domain: `studiou-wc-ord-print-statuses`
-- Current Version: 1.4.0
+- Current Version: 1.4.1
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
 - Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
 
@@ -21,11 +21,31 @@ User-facing and historical documentation lives at the project root and under `do
 - [`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).
+- [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0. All in-scope findings are resolved in 1.4.1; the doc carries per-item status annotations.
 
 When adding new documentation files, place them in `docs/` and add a one-line pointer here.
 
 ## Recent Changes
 
+### Version 1.4.1 (2026-05-12)
+
+Bug-fix release resolving findings from `docs/revise-1.4.0.md`. Key code changes:
+
+- **SQL strict-mode safety** in `Studiou_DB_Manager::get_orders_for_printing()` — non-aggregated SELECT columns wrapped in `MIN()`; `SET SESSION group_concat_max_len = 65535` before the query.
+- **CSV BOM handling** in `parse_csv()` — strips a leading UTF-8 BOM (which our own export writes); `normalise_header()` also defensively strips one.
+- **Datetime round-trip** in `Order_Fields_Manager` — switched to pure string reformatting (`YYYY-MM-DD HH:MM:SS` ↔ `YYYY-MM-DDTHH:MM`). No `strtotime()`/`date()` so server-vs-WP-timezone drift is eliminated.
+- **Capability check** added to `Order_Fields_Manager::save_custom_order_fields()`.
+- **Single order note** in `set_order_processing_status()` — the second `add_order_note()` call was removed.
+- **Bulk action UX** — `notify_moved_count()` helper suppresses notices when count is zero.
+- **`output_csv()` safety** — fails loudly via `wp_die()` if `headers_sent()`, logging the source file/line.
+- **PHP 8.4 compatibility** — `fputcsv`/`fgetcsv` now pass an explicit empty-string escape character.
+- **Import dispatch map** — `Import_Manager::protocol_dispatch()` centralises the protocol → handler mapping; `run_import()` uses explicit `return $this->redirect_back()` on every non-happy branch.
+- **Dedup case-insensitive** — `dedupe_by_order_no()` lowercases keys.
+- **`normalise_csv_date()`** returns `''` and logs on parse failure (was returning the raw unparseable string).
+- **Notice TTL** bumped 60 s → 300 s.
+
+Deferred (not addressed in 1.4.1): live HPOS search-filter verification, `Network:` header, `uninstall.php`, block-based order edit screen.
+
 ### Version 1.4.0 (2026-05-12)
 
 Comprehensive fix-up release addressing every finding in `docs/revise-1.3.2.md`:

+ 23 - 1
studiou-wc-ord-print-statuses/README.md

@@ -3,7 +3,7 @@
 WordPress plugin that extends WooCommerce with a dedicated workflow for managing print orders sent to third-party print providers. It adds custom order statuses, tracking fields, CSV export, protocol-based CSV imports, and search/filter capabilities tailored to a print fulfillment pipeline.
 
 - **Plugin slug / text domain:** `studiou-wc-ord-print-statuses`
-- **Current version:** 1.4.0
+- **Current version:** 1.4.1
 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
 - **License:** GPL v2 or later
 
@@ -198,6 +198,28 @@ Project-internal guidance for AI-assisted development is documented in [`CLAUDE.
 
 ## Changelog
 
+### 1.4.1 — 2026-05-12
+
+Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md):
+
+- **Fixed (critical):** Export SQL is now compatible with MySQL's `ONLY_FULL_GROUP_BY` mode (default in MySQL 5.7+ / MariaDB 10.3+). Non-aggregated columns (`prod_name`, `prod_var`, `prod_var_type`, `prod_img_url`, `qty`, `email`) are wrapped in `MIN()`; each `(product_id, variation_id)` group has exactly one value per column so `MIN()` is value-preserving.
+- **Fixed (critical):** `parse_csv()` now strips a leading UTF-8 BOM. Re-importing a previously exported CSV (which ships with a BOM for Excel-on-Windows compatibility) no longer breaks header parsing.
+- **Fixed (high):** Datetime fields in the Print Information panel now reformat values as pure strings (`YYYY-MM-DD HH:MM:SS` ↔ `YYYY-MM-DDTHH:MM`) instead of running them through `strtotime()`/`date()`. The previous timezone-shift bug (off by 1–2 hours on UTC servers with non-UTC WP timezones) is gone.
+- **Fixed:** `set_order_processing_status()` now records a single order note via `update_status()` instead of also calling `add_order_note()`. The order activity feed no longer duplicates the "payment received" line.
+- **Fixed:** Bulk-action notices are suppressed when zero orders were actually moved — the more informative "skipped because terminal status" warning from the DB manager stands alone.
+- **Fixed:** `output_csv()` now aborts with `wp_die()` (and logs the offender) when `headers_sent()` is true, instead of streaming raw CSV bytes into a half-rendered HTML page.
+- **Fixed:** `fputcsv`/`fgetcsv` now pass an explicit empty-string escape character — no more PHP 8.4 deprecation warnings.
+- **Fixed:** `Order_Fields_Manager::save_custom_order_fields()` now checks `current_user_can('edit_shop_order' / 'edit_shop_orders')` before writing. Defense-in-depth on top of WC's own validation.
+- **Fixed:** `Studiou_DB_Manager::get_orders_for_printing()` raises `group_concat_max_len` to 64 KB before the export query, preventing silent `prod_cat` truncation for products in many categories.
+- **Fixed:** `dedupe_by_order_no()` is now case-insensitive — `ord-12345` and `ORD-12345` collapse to a single row.
+- **Fixed:** `normalise_csv_date()` returns an empty string (and logs) on parse failure instead of persisting the unparseable raw value.
+- **Changed:** `Import_Manager::run_import()` is driven by a single `protocol_dispatch()` map (file field, nonce, DB handler per protocol). Every non-happy-path branch returns explicitly via `redirect_back()` for static-analysis friendliness.
+- **Changed:** `Order_Fields_Manager` rendering and saving are driven from the `datetime_fields()` / `text_fields()` maps. Adding a new field is a single-map-entry change.
+- **Changed:** Notice transient TTL raised from 60 s to 300 s so admin notices survive slow redirects (proxies, HTTPS handshake latency).
+- **Doc:** [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) updated with per-item status annotations.
+
+> **Deliberately deferred** (carried over from the 1.4.0 review): live verification of the HPOS search filter name (`woocommerce_order_table_search_query_meta_keys`), `Network:` plugin header for multisite, `uninstall.php` cleanup, and behaviour on the WC 10.x block-based order edit screen. None of these were addressed by 1.4.1.
+
 ### 1.4.0 — 2026-05-12
 
 - **Tested with:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled).

+ 72 - 38
studiou-wc-ord-print-statuses/docs/revise-1.4.0.md

@@ -1,6 +1,8 @@
 # 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.
+> **Status as of 1.4.1 (2026-05-12):** all in-scope findings below have been resolved in plugin version **1.4.1**. Each finding now carries an inline status marker — ✅ resolved, ⏸ deliberately deferred. See the README changelog for the corresponding code changes.
+
+**Scope:** Read-only analytical pass over the v1.4.0 codebase. No code changes were made *as part of this review*; the resolution was implemented separately and is recorded in the status markers below. 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+.
@@ -13,7 +15,7 @@ Findings are grouped by severity. The severity reflects user-visible or correctn
 
 ## 1. Critical — newly introduced regressions
 
-### 1.1 Export SQL `GROUP BY` may fail under default MySQL strict mode
+### 1.1 Export SQL `GROUP BY` may fail under default MySQL strict mode — ✅ resolved in 1.4.1
 
 `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:
 
@@ -33,7 +35,7 @@ Under MySQL 5.7+ and MariaDB 10.3+ the default `sql_mode` includes `ONLY_FULL_GR
 - 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
+### 1.2 CSV exports now write a UTF-8 BOM; CSV imports do not strip it — ✅ resolved in 1.4.1
 
 `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.
 
@@ -45,7 +47,7 @@ This combination is realistic — exports often serve as templates for the next
 
 ## 2. High — correctness, HPOS, time-zone
 
-### 2.1 HPOS search filter name is unverified against WC 10.7.0
+### 2.1 HPOS search filter name is unverified against WC 10.7.0 — ⏸ deferred (verification task)
 
 `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.
 
@@ -53,7 +55,9 @@ This combination is realistic — exports often serve as templates for the next
 
 **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
+### 2.2 Datetime fields use server timezone, not WP timezone — ✅ resolved in 1.4.1
+
+Resolution: switched `Order_Fields_Manager` to pure-string round-tripping (no `strtotime()`/`date()`). The form value and the stored value are now both treated as WP local time, matching `current_time('mysql')`.
 
 `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.
 
@@ -67,7 +71,9 @@ Replacing `date(...)` with `wp_date(...)` or constructing a `DateTimeImmutable`
 
 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
+### 2.3 Status-change listener and bulk-set both stamp the same meta — ✅ resolved in 1.4.1 (documented as intentional)
+
+Resolution: the listener and the bulk/import paths intentionally coexist — the listener owns manual single-order transitions, the bulk/import paths always re-stamp. A docblock above `handle_status_transitions` now explains the contract.
 
 `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'))`.
 
@@ -85,13 +91,15 @@ A subtler asymmetry: the listener stamps **only if empty** (`if (!$order->get_me
 
 ## 3. Medium — robustness, maintainability
 
-### 3.1 `GROUP_CONCAT` is silently bounded by `group_concat_max_len`
+### 3.1 `GROUP_CONCAT` is silently bounded by `group_concat_max_len` — ✅ resolved in 1.4.1
+
+Resolution: `SET SESSION group_concat_max_len = 65535` is issued before the export query.
 
 `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
+### 3.2 `parse_csv` does not skip the UTF-8 BOM — ✅ resolved in 1.4.1 (see §1.2)
 
 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:
 
@@ -104,7 +112,9 @@ if ($first !== "\xEF\xBB\xBF") {
 
 inside `parse_csv` would close this gap.
 
-### 3.3 `output_csv` fallback path mixes CSV into HTML on header-sent edge
+### 3.3 `output_csv` fallback path mixes CSV into HTML on header-sent edge — ✅ resolved in 1.4.1
+
+Resolution: when `headers_sent()` returns true, the source file/line is logged and the request `wp_die()`s with a clear message instead of streaming CSV bytes into the HTML body.
 
 `class-bulk-actions-manager.php:130-138`:
 
@@ -122,7 +132,9 @@ private function output_csv($csv_data, $filename) {
 
 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
+### 3.4 `if ($file === null) { $this->redirect_back(); }` is statically unreachable-friendly — ✅ resolved in 1.4.1
+
+Resolution: `run_import()` now uses `return $this->redirect_back()` on every non-happy branch.
 
 `class-import-manager.php:88-91`:
 
@@ -136,7 +148,9 @@ $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
+### 3.5 `normalise_csv_date` returns the raw string on parse failure — ✅ resolved in 1.4.1
+
+Resolution: parse failures are logged and the function returns `''` so downstream UI is consistent (blank rather than "looks blank but has bogus data").
 
 `class-db-manager.php:271-278`:
 
@@ -151,12 +165,16 @@ private static function normalise_csv_date($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
+### 3.6 Unused constants and dead label maps — ✅ partially resolved in 1.4.1
+
+Resolution: `Order_Fields_Manager` now drives both rendering and saving from the `datetime_fields()` / `text_fields()` maps. The `STUDIOU_WC_OPS_VERSION` / `STUDIOU_WC_OPS_FILE` constants remain defined but unused inside the plugin — they are intentionally exported for external consumers (test fixtures, sibling plugins, custom code) and left in place.
 
 - `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
+### 3.7 `set_order_processing_status` produces two order notes per payment-complete event — ✅ resolved in 1.4.1
+
+Resolution: the explicit `add_order_note()` call was removed; the explanatory note is now passed as the second argument to `update_status()`.
 
 `class-order-status-manager.php:103-109`:
 
@@ -167,15 +185,21 @@ $order->add_order_note(__('Order automatically set to processing by Studiou WC O
 
 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
+### 3.8 Bulk action result UX when zero orders are eligible — ✅ resolved in 1.4.1
+
+Resolution: a shared `notify_moved_count()` helper suppresses the "X orders moved" success notice when the count is zero. The "skipped because terminal status" warning from the DB manager stands alone.
 
 `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
+### 3.9 PHP 8.4 deprecation of implicit `fputcsv` / `fgetcsv` escape character — ✅ resolved in 1.4.1
+
+Resolution: every `fputcsv` and `fgetcsv` call now passes `'"'` as the enclosure and `''` as the escape character explicitly.
 
 `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
+### 3.10 `Order_Fields_Manager::save_custom_order_fields` lacks a capability check — ✅ resolved in 1.4.1
+
+Resolution: a `current_user_can('edit_shop_order', $order_id)` (with `'edit_shop_orders'` fallback) gate was added.
 
 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.
 
@@ -183,47 +207,57 @@ The handler hooks `woocommerce_process_shop_order_meta`, which WC fires after it
 
 ## 4. Low — cosmetic, style, longer-term
 
-### 4.1 `dedupe_by_order_no` is case-sensitive
+### 4.1 `dedupe_by_order_no` is case-sensitive — ✅ resolved in 1.4.1
+
+Resolution: keys are lowercased before insertion into the `$seen` set.
 
 `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
+### 4.2 `run_import` action-name dispatch is brittle — ✅ resolved in 1.4.1
+
+Resolution: dispatch is now data-driven via `Import_Manager::protocol_dispatch()` (file field, nonce, DB handler callable per protocol).
 
 `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
+### 4.3 `wp_kses_post` may be heavier than necessary for plain notices — ⏸ left as-is
+
+All current notice messages are constant strings or sprintf-with-`esc_html` content. `wp_kses_post` is already safe in this context; the optimisation would be cosmetic. Revisit if richer notice content is added.
 
 `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
+### 4.4 Notice transient TTL is 60 seconds — ✅ resolved in 1.4.1
+
+Resolution: `UtilsLog::NOTICE_TTL` raised to 300 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`
+### 4.5 Per-tab race in `render_notices` — ⏸ accepted
+
+UX edge case; cost/benefit of a more elaborate "notices delivered" registry doesn't justify the added complexity.
 
 `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
+### 4.6 `STUDIOU_WC_OPS_FILE` and `STUDIOU_WC_OPS_VERSION` are unused — ⏸ left as-is (see §3.6)
 
 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
+### 4.7 No `Network:` header in the plugin file — ⏸ deferred (multisite not in scope)
 
 Carried over from §4.6 of the previous review. Only relevant for multisite installs.
 
-### 4.8 No `uninstall.php` / cleanup hook
+### 4.8 No `uninstall.php` / cleanup hook — ⏸ deferred (conscious carry-over)
 
 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
+### 4.9 `register_post_status` is invoked even under HPOS — ⏸ left as-is (required for WC admin chrome)
 
 `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`
+### 4.10 `update_status` calls do not pass `manual = true` — ⏸ left as-is (bulk transitions are *not* manual)
 
 `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
+### 4.11 WooCommerce 10.7.0 block-based order edit screen — ⏸ deferred (needs live verification)
 
 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.
 
@@ -283,17 +317,17 @@ Net: 26 of 30 findings closed in code; 3 carried over; 1 (search) closed but fla
 
 ## 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.
+Ordered by expected user-visible impact (status as of 1.4.1):
+
+1. **§1.1 Export SQL `GROUP BY` may fail on default MySQL strict mode.** ✅ resolved.
+2. **§1.2 BOM-in-imported-CSV breakage.** ✅ resolved.
+3. **§2.1 HPOS search filter unverified.** ⏸ deferred — still pending live verification on WC 10.7.0.
+4. **§2.2 Server-vs-WP timezone drift in Print Information panel.** ✅ resolved.
+5. **§3.3 `output_csv` fallback echoes raw CSV into HTML.** ✅ resolved.
+6. **§3.5 `normalise_csv_date` silently keeps bogus dates.** ✅ resolved.
+7. **§3.1 `GROUP_CONCAT` truncation.** ✅ resolved.
+8. **§3.9 PHP 8.4 deprecation noise.** ✅ resolved.
+9. **§3.7 Two order notes per payment-complete event.** ✅ resolved.
 
 ---
 

+ 6 - 4
studiou-wc-ord-print-statuses/docs/translations.txt

@@ -1,5 +1,5 @@
 Translations: all user-facing strings (English → Czech).
-Last reviewed: v1.4.0 (2026-05-12).
+Last reviewed: v1.4.1 (2026-05-12).
 
 Note: After editing the .po file, regenerate the .mo binary with one of:
   wp i18n make-mo languages/
@@ -61,9 +61,11 @@ CSV file could not be parsed or contained no data rows.
 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.
+# Order note (when payment auto-advances order to "processing")
+Payment received — order automatically set to processing by Studiou WC Order Print Statuses plugin.  Platba přijata — 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.
+
+# CSV export safety guard (1.4.1)
+The CSV export could not be streamed because output had already started. Check for plugin/theme code that prints before headers are sent.  CSV export nebylo možné odeslat, protože výstup již začal. Zkontrolujte kód pluginů/šablon, který tiskne před odesláním HTTP hlaviček.

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

@@ -54,18 +54,7 @@ class Bulk_Actions_Manager {
         $csv_data    = !empty($orders_data) ? $this->generate_csv($orders_data) : '';
 
         $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'
-        );
+        $this->notify_moved_count($updated, 'to-print');
 
         if ($csv_data === '') {
             UtilsLog::message(
@@ -80,43 +69,50 @@ class Bulk_Actions_Manager {
 
     private function set_status_to_prepare_to_printing($post_ids, $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'
-        );
+        $this->notify_moved_count($updated, 'to-print');
         return add_query_arg('bulk_action', 'marked_prepare_to_printing', $redirect_to);
     }
 
     private function set_status_to_in_printing($post_ids, $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'
-        );
+        $this->notify_moved_count($updated, 'in-print');
         return add_query_arg('bulk_action', 'marked_in_printing', $redirect_to);
     }
 
+    /**
+     * Emit a "%d orders moved to …" notice unless zero orders were actually
+     * changed — in that case the "skipped because terminal status" warning
+     * from Studiou_DB_Manager is more informative on its own.
+     */
+    private function notify_moved_count($count, $slug) {
+        if ($count <= 0) {
+            return;
+        }
+        if ($slug === 'to-print') {
+            $message = _n(
+                '%d order moved to "Prepare to Printing".',
+                '%d orders moved to "Prepare to Printing".',
+                $count,
+                'studiou-wc-ord-print-statuses'
+            );
+        } else {
+            $message = _n(
+                '%d order moved to "In Printing".',
+                '%d orders moved to "In Printing".',
+                $count,
+                'studiou-wc-ord-print-statuses'
+            );
+        }
+        UtilsLog::message(sprintf($message, $count), 'success');
+    }
+
     private function generate_csv($data) {
         $output = fopen('php://temp', 'w+');
-        fputcsv($output, array_keys((array) $data[0]), ',');
+        // Explicit empty-string escape suppresses the PHP 8.4 deprecation
+        // notice for the implicit '\\' default.
+        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);
@@ -126,13 +122,25 @@ class Bulk_Actions_Manager {
 
     /**
      * Stream CSV with UTF-8 BOM so Excel on Windows reads diacritics correctly.
+     * If output has already started (some upstream code printed something) we
+     * cannot reliably stream a CSV download — bail with a clear error rather
+     * than echoing CSV bytes into an HTML page.
      */
     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 . '"');
+        if (headers_sent($file, $line)) {
+            UtilsLog::log(sprintf(
+                'output_csv: headers already sent from %s:%d — aborting CSV stream.',
+                $file,
+                $line
+            ));
+            wp_die(esc_html__(
+                'The CSV export could not be streamed because output had already started. Check for plugin/theme code that prints before headers are sent.',
+                'studiou-wc-ord-print-statuses'
+            ));
         }
+        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;
     }

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

@@ -32,16 +32,24 @@ class Studiou_DB_Manager {
 
         $placeholders = implode(',', array_fill(0, count($order_ids), '%d'));
 
+        // Raise the GROUP_CONCAT byte cap (default 1024) so multi-category
+        // products with long names are not silently truncated.
+        $wpdb->query("SET SESSION group_concat_max_len = 65535");
+
+        // Non-aggregated SELECT columns are wrapped in MIN() to remain
+        // compliant with MySQL's ONLY_FULL_GROUP_BY mode (default in 5.7+).
+        // Each (product_id, variation_id) maps 1:1 to title/qty/email/image,
+        // so MIN() returns the same value a free pick would.
         $sql = "
             SELECT
                 `orders`.`id` AS `order_no`,
                 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`
+                MIN(`products`.`post_title`)             AS `prod_name`,
+                MIN(`product_variations`.`post_title`)   AS `prod_var`,
+                MIN(`product_variations_name`.`name`)    AS `prod_var_type`,
+                MIN(`images`.`guid`)                     AS `prod_img_url`,
+                MIN(`order_products`.`product_qty`)      AS `qty`,
+                MIN(`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`
@@ -229,13 +237,22 @@ class Studiou_DB_Manager {
             return $csv_data;
         }
         try {
-            $headers = fgetcsv($handle, 0, ',');
+            // Strip a leading UTF-8 BOM if present so the first header parses
+            // cleanly. The plugin's own exports include a BOM (for Excel),
+            // and Notepad-saved CSVs frequently include one.
+            $first_bytes = fread($handle, 3);
+            if ($first_bytes !== "\xEF\xBB\xBF") {
+                rewind($handle);
+            }
+            // Explicit empty-string escape suppresses the PHP 8.4 deprecation
+            // notice for the implicit '\\' default.
+            $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) {
+            while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
                 if (count($data) !== count($headers)) {
                     continue;
                 }
@@ -249,6 +266,8 @@ class Studiou_DB_Manager {
 
     private static function normalise_header($header) {
         $header = strtolower(trim((string) $header));
+        // Strip any UTF-8 BOM that survived the file-level guard.
+        $header = preg_replace('/^\xEF\xBB\xBF/', '', $header);
         // Map "order no" → "order_no" by collapsing internal whitespace to underscore.
         $header = preg_replace('/\s+/', '_', $header);
         return $header;
@@ -258,8 +277,14 @@ class Studiou_DB_Manager {
         $seen   = array();
         $unique = array();
         foreach ($rows as $row) {
-            $key = isset($row['order_no']) ? trim((string) $row['order_no']) : '';
-            if ($key === '' || isset($seen[$key])) {
+            $raw_key = isset($row['order_no']) ? trim((string) $row['order_no']) : '';
+            if ($raw_key === '') {
+                continue;
+            }
+            // Case-insensitive dedup so alphabetic order numbers
+            // ("ord-12345" vs "ORD-12345") collapse to a single row.
+            $key = strtolower($raw_key);
+            if (isset($seen[$key])) {
                 continue;
             }
             $seen[$key] = true;
@@ -268,12 +293,21 @@ class Studiou_DB_Manager {
         return $unique;
     }
 
+    /**
+     * Normalise a free-form date string into a MySQL datetime.
+     * Returns '' (and logs) on parse failure so the UI does not silently
+     * carry unparseable raw values through the round-trip.
+     */
     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;
+        if (!$timestamp) {
+            UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
+            return '';
+        }
+        return date('Y-m-d H:i:s', $timestamp);
     }
 }

+ 36 - 19
studiou-wc-ord-print-statuses/includes/class-import-manager.php

@@ -63,31 +63,53 @@ class Import_Manager {
         <?php
     }
 
+    /**
+     * Map from admin-post action name → [file field, nonce field/action, DB handler callable].
+     * Adding a new protocol is a single-entry change here.
+     */
+    private function protocol_dispatch() {
+        return array(
+            'import_inprint_protocol' => array(
+                'file_field' => 'inprint_csv',
+                'nonce'      => 'import_inprint_protocol_nonce',
+                'handler'    => array('Studiou_DB_Manager', 'import_inprint_protocol'),
+            ),
+            'import_delivered_protocol' => array(
+                'file_field' => 'delivered_csv',
+                'nonce'      => 'import_delivered_protocol_nonce',
+                'handler'    => array('Studiou_DB_Manager', 'import_delivered_protocol'),
+            ),
+        );
+    }
+
     public function handle_import_inprint_protocol() {
-        $this->run_import('inprint_csv', 'import_inprint_protocol', 'import_inprint_protocol_nonce', 'import_inprint_protocol');
+        $this->run_import('import_inprint_protocol');
     }
 
     public function handle_import_delivered_protocol() {
-        $this->run_import('delivered_csv', 'import_delivered_protocol', 'import_delivered_protocol_nonce', 'import_delivered_protocol');
+        $this->run_import('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
+     * Every non-success branch returns immediately via redirect_back() so static
+     * analysis can prove that subsequent code is only reached on the happy path.
      */
-    private function run_import($file_field, $action, $nonce_field, $nonce_action) {
+    private function run_import($action) {
+        $dispatch = $this->protocol_dispatch();
+        if (!isset($dispatch[$action])) {
+            return $this->redirect_back();
+        }
+        $cfg = $dispatch[$action];
+
         if (!current_user_can('manage_woocommerce')) {
             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($action, $cfg['nonce']);
 
-        $file = $this->validated_upload($file_field);
+        $file = $this->validated_upload($cfg['file_field']);
         if ($file === null) {
-            $this->redirect_back();
+            return $this->redirect_back();
         }
 
         $csv_data = Studiou_DB_Manager::parse_csv($file['tmp_name']);
@@ -96,17 +118,12 @@ class Import_Manager {
                 __('CSV file could not be parsed or contained no data rows.', 'studiou-wc-ord-print-statuses'),
                 'error'
             );
-            $this->redirect_back();
-        }
-
-        if ($action === 'import_inprint_protocol') {
-            $result = Studiou_DB_Manager::import_inprint_protocol($csv_data);
-        } else {
-            $result = Studiou_DB_Manager::import_delivered_protocol($csv_data);
+            return $this->redirect_back();
         }
 
+        $result = call_user_func($cfg['handler'], $csv_data);
         $this->emit_import_summary($result);
-        $this->redirect_back();
+        return $this->redirect_back();
     }
 
     /**

+ 68 - 50
studiou-wc-ord-print-statuses/includes/class-order-fields-manager.php

@@ -4,15 +4,27 @@
 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',
-    );
+    /**
+     * Meta key → translatable label for fields rendered as `datetime-local`.
+     * The render and save loops both walk this map.
+     */
+    private static function datetime_fields() {
+        return array(
+            'to_print_date'              => __('Prepare To Print Date', 'studiou-wc-ord-print-statuses'),
+            'in_print_date'              => __('In Print Date', 'studiou-wc-ord-print-statuses'),
+            'external_ref_ord_date'      => __('External Order Date', 'studiou-wc-ord-print-statuses'),
+            'external_ref_ord_delivered' => __('External Order Delivered', 'studiou-wc-ord-print-statuses'),
+        );
+    }
+
+    /**
+     * Meta key → translatable label for plain text fields.
+     */
+    private static function text_fields() {
+        return array(
+            'external_ref_ord_no' => __('External Order Number', 'studiou-wc-ord-print-statuses'),
+        );
+    }
 
     public function __construct() {
         add_action('woocommerce_admin_order_data_after_order_details', array($this, 'display_custom_order_fields'));
@@ -24,57 +36,43 @@ class Order_Fields_Manager {
         <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',
-            ));
+            foreach (self::datetime_fields() as $key => $label) {
+                woocommerce_wp_text_input(array(
+                    'id'            => $key,
+                    'label'         => $label,
+                    'type'          => 'datetime-local',
+                    'value'         => $this->mysql_to_datetime_local($order->get_meta($key)),
+                    'wrapper_class' => 'form-field-wide',
+                ));
+            }
+            foreach (self::text_fields() as $key => $label) {
+                woocommerce_wp_text_input(array(
+                    'id'            => $key,
+                    'label'         => $label,
+                    'value'         => $order->get_meta($key),
+                    'wrapper_class' => 'form-field-wide',
+                ));
+            }
             ?>
         </div>
         <?php
     }
 
     public function save_custom_order_fields($order_id) {
+        if (!current_user_can('edit_shop_order', $order_id) && !current_user_can('edit_shop_orders')) {
+            return;
+        }
         $order = wc_get_order($order_id);
         if (!$order) {
             return;
         }
 
-        foreach (array_keys($this->datetime_fields) as $field) {
+        foreach (array_keys(self::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) {
+        foreach (array_keys(self::text_fields()) as $field) {
             if (isset($_POST[$field])) {
                 $order->update_meta_data($field, sanitize_text_field(wp_unslash($_POST[$field])));
             }
@@ -82,20 +80,40 @@ class Order_Fields_Manager {
         $order->save();
     }
 
+    /**
+     * Convert a MySQL datetime string (as written by current_time('mysql')) to
+     * the HTML5 `datetime-local` format. Done as pure string reformatting so
+     * the value's timezone is preserved — current_time('mysql') already uses
+     * WP local time, and the browser interprets datetime-local as local time.
+     * Using strtotime()/date() here would re-interpret the value in the server
+     * timezone and drift on UTC-server / non-UTC-site configurations.
+     */
     private function mysql_to_datetime_local($value) {
-        if (empty($value)) {
+        $value = trim((string) $value);
+        if ($value === '') {
             return '';
         }
-        $timestamp = strtotime($value);
-        return $timestamp ? date('Y-m-d\TH:i', $timestamp) : '';
+        // Accepts 'YYYY-MM-DD HH:MM[:SS]' or 'YYYY-MM-DDTHH:MM[:SS]'.
+        if (preg_match('/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/', $value, $m)) {
+            return $m[1] . 'T' . $m[2];
+        }
+        return '';
     }
 
+    /**
+     * Convert an HTML5 datetime-local form value to a MySQL datetime string.
+     * No timezone conversion — the form value and the stored value are both
+     * treated as WP local time (matching current_time('mysql')).
+     */
     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) : '';
+        if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?$/', $value, $m)) {
+            $seconds = isset($m[3]) ? $m[3] : '00';
+            return $m[1] . ' ' . $m[2] . ':' . $seconds;
+        }
+        return '';
     }
 }

+ 13 - 4
studiou-wc-ord-print-statuses/includes/class-order-status-manager.php

@@ -67,6 +67,14 @@ class Order_Status_Manager {
     /**
      * Stamp the relevant meta key when an order transitions into a print status.
      * Uses the order object (HPOS-safe) rather than update_post_meta.
+     *
+     * Note: bulk and import paths in Studiou_DB_Manager also stamp the same
+     * meta key explicitly. That redundancy is intentional — the bulk path
+     * always re-stamps (a re-run should refresh the timestamp), while this
+     * listener handles manual single-order transitions originating from the
+     * order edit screen. The "only if empty" guard below preserves the
+     * original timestamp for manual flows; the bulk path overwrites it via
+     * its own explicit call.
      */
     public function handle_status_transitions($order_id, $old_status, $new_status) {
         UtilsLog::log('Order #' . $order_id . ' status change ' . $old_status . ' -> ' . $new_status);
@@ -100,12 +108,13 @@ class Order_Status_Manager {
         if ($order->has_status($protected)) {
             return;
         }
+        // Pass the explanatory note to update_status — that's the canonical
+        // place for a status-change note. Adding a second note via
+        // add_order_note() would double-log the same event in the order
+        // activity feed.
         $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')
+            __('Payment received — order automatically set to processing by Studiou WC Order Print Statuses plugin.', 'studiou-wc-ord-print-statuses')
         );
     }
 

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

@@ -5,7 +5,7 @@ defined('ABSPATH') || exit;
 
 class UtilsLog {
     const NOTICE_TRANSIENT_PREFIX = 'studiou_wc_ops_notices_';
-    const NOTICE_TTL              = 60; // seconds
+    const NOTICE_TTL              = 300; // 5 minutes — survives slow redirects.
 
     public static function init() {
         add_action('admin_notices', array(__CLASS__, 'render_notices'));

+ 2 - 2
studiou-wc-ord-print-statuses/studiou-wc-ord-print-statuses.php

@@ -3,7 +3,7 @@
  * Plugin Name: QDR - Studiou WC Order Print Statuses
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-ord-print-statuses
  * Description: Adds custom order statuses (wc-to-print, wc-in-print), bulk actions, and status change timestamps to WooCommerce for print orders to third party providers
- * Version: 1.4.0
+ * Version: 1.4.1
  * Requires at least: 5.8
  * Tested up to: 6.9.4
  * Requires PHP: 7.2
@@ -19,7 +19,7 @@
 
 defined('ABSPATH') || exit;
 
-define('STUDIOU_WC_OPS_VERSION', '1.4.0');
+define('STUDIOU_WC_OPS_VERSION', '1.4.1');
 define('STUDIOU_WC_OPS_FILE', __FILE__);
 
 // Declare HPOS (High-Performance Order Storage) compatibility