瀏覽代碼

Release v1.4.2 — resolve every in-scope finding from revise-1.4.1

Adds the new analytical review docs/revise-1.4.1.md (fresh-eye pass over
1.4.1) alongside the fixes for every in-scope item it raised.

High:
- Custom_Columns_Manager::format_meta_date no longer applies the WP-vs-
  server timezone offset twice (strtotime+date_i18n → DateTimeImmutable
  with wp_timezone + wp_date). The To/In Print Date columns now read
  correctly on UTC-server / non-UTC-WP installs.
- Export SQL joins terms.slug against attribute_pa_format postmeta (WC
  stores the slug there, not the name). MIN(terms.name) still surfaces
  the human-readable display.
- qty is now SUM(order_products.product_qty) instead of MIN. A single
  order may have multiple lookup rows for the same (product, variation)
  when the customer added it twice without cart-merge; MIN silently
  undercounted.

Medium:
- INNER JOIN on product_variations → LEFT JOIN. Simple (non-variable)
  products are no longer silently excluded from the export.
- prod_img_url is resolved via wp_get_attachment_url(_thumbnail_id) in
  PHP after the query — drops wp_posts.guid (codex warns it isn't
  guaranteed to be a URL). Offloaded-media / multisite paths now work.
- normalise_csv_date uses wp_date() to match current_time('mysql')
  elsewhere (was date(), server timezone).
- Pre-flight check for wc_order_product_lookup — clear admin notice if
  WC Analytics is disabled instead of a cryptic SQL error.
- SET SESSION group_concat_max_len uses GREATEST(@@SESSION..., 65535)
  so we never lower a higher pre-existing value.
- Server-side .csv extension check on protocol uploads.

Low:
- Order_Status_Manager::add_custom_order_statuses_to_list appends custom
  statuses at the end if wc-processing was removed by another plugin.
- parse_csv drops @fopen suppression; explicit error log instead.
- add_import_submenu translates "Import Protocols" once into a local.

Docs:
- README gains a "Known limitations" section (HPOS search verification,
  variable-products-only, WC Analytics dep, block-based screen, no
  Network header, no uninstall, custom-order-number plugins, hardcoded
  attribute_pa_format, session-scoped group_concat_max_len) and a
  callout that order_no in CSVs is the WC internal order ID.
- CLAUDE.md 1.3.2 footnote acknowledges the slug-not-name correction;
  "Key Database Operations" paragraph rewritten to reflect the current
  SQL shape.
- docs/revise-1.4.1.md (new) is the fresh-eye review of 1.4.1; every
  finding carries an inline ✅ resolved / ⏸ deferred status marker.
- docs/translations.txt: two new EN→CS entries for 1.4.2 strings.

Deferred (carried over): live HPOS search-filter verification, Network:
header, uninstall.php, block-based order edit screen, custom-order-
number plugin integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dalibor Votruba 2 月之前
父節點
當前提交
1d6e0a0a06

+ 26 - 8
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.1
+- Current Version: 1.4.2
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
 - Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
 
@@ -22,11 +22,29 @@ User-facing and historical documentation lives at the project root and under `do
 - [`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.
+- [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Fresh-eye analytical review of v1.4.1. All in-scope findings are resolved in 1.4.2; per-item status annotations inside.
 
 When adding new documentation files, place them in `docs/` and add a one-line pointer here.
 
 ## Recent Changes
 
+### Version 1.4.2 (2026-05-12)
+
+Bug-fix release addressing the fresh-eye findings in `docs/revise-1.4.1.md`. Notable code changes:
+
+- **`Custom_Columns_Manager::format_meta_date()`** — switched to `DateTimeImmutable(..., wp_timezone())` + `wp_date()`. The "To Print Date" / "In Print Date" columns no longer drift by the WP-vs-server-timezone offset.
+- **Export SQL slug join** — `Studiou_DB_Manager::get_orders_for_printing()` now joins `product_variations_name.slug = product_variations_attr.meta_value` (WC stores the slug in `attribute_pa_format` postmeta, not the term name). Display still uses `MIN(terms.name)` to surface the human-readable label.
+- **Export SQL `SUM(qty)`** — was `MIN(qty)`. A single order can have multiple lookup rows for the same (product, variation) when the customer added it twice without cart-merge; `MIN()` silently undercounted.
+- **`LEFT JOIN product_variations`** — simple (non-variable) products were silently excluded by the previous inner join. They now appear with blank `prod_var` / `prod_var_type`.
+- **Image URL via WP API** — drops `wp_posts.guid` (codex warns it's not necessarily a URL). Selects `_thumbnail_id`, resolves via `wp_get_attachment_url()`. Offloaded-media-friendly.
+- **`normalise_csv_date()` uses `wp_date()`** — matches `current_time('mysql')` elsewhere instead of `date()`'s server-timezone interpretation.
+- **`wc_order_product_lookup` pre-check** — bails with a clear admin notice if WC Analytics is disabled and the lookup table is missing.
+- **`SET SESSION group_concat_max_len`** uses `GREATEST(@@SESSION.group_concat_max_len, 65535)` — never lowers a higher pre-existing value.
+- **Server-side `.csv` extension check** on protocol uploads (defense-in-depth).
+- **`Order_Status_Manager::add_custom_order_statuses_to_list`** falls back to appending the custom statuses at the end of the dropdown if `wc-processing` is absent.
+- **`parse_csv()`** drops `@fopen` suppression; opens explicitly and logs on failure.
+- **Doc:** README adds a "Known limitations" section and clarifies what `order_no` means in CSV protocols. CLAUDE.md description of the `attribute_pa_format` join corrected (slug, not name — see below).
+
 ### Version 1.4.1 (2026-05-12)
 
 Bug-fix release resolving findings from `docs/revise-1.4.0.md`. Key code changes:
@@ -74,10 +92,10 @@ Fixed an issue where the `prod_var_type` column was always empty in the "Prepare
 The SQL query in `Studiou_DB_Manager::get_orders_for_printing()` was incorrectly using the `wc_product_attributes_lookup` table to retrieve product variation attributes. This table stores product-level attribute data (for parent products), not the specific attribute values for individual variations.
 
 **Solution:**
-Modified the query (lines 55-65 in `includes/class-db-manager.php`) to:
+Modified the query (in `includes/class-db-manager.php`) to:
 1. Join with `wp_postmeta` table instead of `wc_product_attributes_lookup`
 2. Look for meta_key = 'attribute_pa_format' which stores the variation's format attribute value
-3. Join with `wp_terms` using the slug field (matching against meta_value) instead of term_id
+3. Join `wp_terms.slug = postmeta.meta_value` (WC stores the slug in `attribute_pa_format`, not the term name). Display uses `MIN(wp_terms.name)` to surface the human-readable label. *(Originally written as "name" — corrected to "slug" in 1.4.2 when the live data confirmed the slug shape; see `docs/revise-1.4.1.md` §2.2.)*
 
 **HPOS Compatibility Declaration**
 
@@ -139,13 +157,13 @@ The plugin uses a manager-based architecture where the main plugin class (`Studi
 
 The export query in `Studiou_DB_Manager::get_orders_for_printing()` performs a complex join across:
 - `wp_wc_orders` (order data)
-- `wp_wc_order_product_lookup` (order products)
-- `wp_posts` (products and variations)
-- `wp_postmeta` (product variation attributes - specifically 'attribute_pa_format')
+- `wp_wc_order_product_lookup` (order products — depends on WC Analytics being enabled; the method pre-checks this table)
+- `wp_posts` (products and variations — variations via `LEFT JOIN` so simple products are included)
+- `wp_postmeta` (product variation attributes, specifically `attribute_pa_format` whose value is the term **slug**)
 - `wp_terms` and `wp_term_taxonomy` (product categories and variation attribute values)
-- `wp_postmeta` (product images via '_thumbnail_id' meta)
+- `wp_postmeta` (product `_thumbnail_id` — image URL resolved in PHP via `wp_get_attachment_url()`, not from `wp_posts.guid`)
 
-This query extracts: order number, product category, product name, variation name, variation type, image URL, quantity, and customer email.
+The query extracts: order number, product category, product name, variation name, variation type, image URL, **summed** quantity (per `(order, product, variation)` group), and customer email. Non-aggregated columns are wrapped in `MIN()` (or `SUM()` for qty) to remain compliant with `ONLY_FULL_GROUP_BY`. `group_concat_max_len` is raised via `SET SESSION ... = GREATEST(@@SESSION..., 65535)` before the query.
 
 ### Order Metadata Keys
 

+ 37 - 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.1
+- **Current version:** 1.4.2
 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
 - **License:** GPL v2 or later
 
@@ -87,6 +87,8 @@ Both importers require the `manage_woocommerce` capability and are protected by
 
 ## CSV formats
 
+> **About `order_no`:** the `order_no` column written to the export and read by both imports is the **WooCommerce internal order ID** (`wp_wc_orders.id` under HPOS, `wp_posts.ID` under legacy CPT). It is **not** the user-facing order number produced by Sequential Order Numbers and similar plugins. If you install a custom-order-number plugin, the protocols still operate on the underlying order IDs — the print shop must echo back whatever value was sent in the export.
+
 ### Export — Prepare to Printing
 
 Generated file: `prepare_to_printing_export.csv` (UTF-8 with BOM — opens correctly in Excel on Windows).
@@ -191,13 +193,47 @@ Reference documents live under [`docs/`](docs/):
 - [`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).
+- [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0 (all in-scope findings addressed in 1.4.1).
+- [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Analytical review of v1.4.1 (all in-scope findings addressed in 1.4.2).
 
 Project-internal guidance for AI-assisted development is documented in [`CLAUDE.md`](CLAUDE.md).
 
 ---
 
+## Known limitations
+
+- **HPOS search filter name** (`woocommerce_order_table_search_query_meta_keys`) has not been live-verified against WC 10.7.0. The legacy CPT filter is solid; the HPOS branch should be smoke-tested on the production site before relying on it.
+- **Variable products only** — the export query joins variation tables and the CSV columns include `prod_var` / `prod_var_type`. Simple (non-variable) products appear in the export as of 1.4.2 (via `LEFT JOIN`), but their variation fields are blank.
+- **WC Analytics dependency** — the export reads from `wc_order_product_lookup`, the WooCommerce Analytics lookup table. If WC Analytics is disabled or the table is missing, the export emits an admin notice and skips.
+- **Block-based order edit screen** — the Print Information panel is wired to the classic order edit screen via `woocommerce_admin_order_data_after_order_details`. Behaviour on the WC 10.x block-based order edit screen has not been verified.
+- **Multisite** — no `Network:` plugin header is set; activate per blog.
+- **No uninstall cleanup** — meta keys (`to_print_date`, `external_ref_ord_no`, etc.) remain in the database after plugin removal.
+- **Custom order numbers** — the protocols operate on WC internal order IDs (see the note above the Export section). Sequential Order Numbers / custom-order-number plugins are not integrated.
+- **The `attribute_pa_format` taxonomy is hardcoded** in the export SQL. Stores using a different attribute name will get a blank `prod_var_type`.
+- **MySQL `group_concat_max_len`** is raised to 64 KB via `SET SESSION` per export — connection-scoped, harmless but worth knowing if you maintain custom queries on the same connection.
+
 ## Changelog
 
+### 1.4.2 — 2026-05-12
+
+Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md):
+
+- **Fixed (high):** Custom_Columns_Manager date columns ("To Print Date", "In Print Date") were applying the WP timezone offset twice — `strtotime()` interpreted the stored value in PHP server timezone, then `date_i18n()` formatted in WP timezone. On UTC-server / non-UTC-WP setups (e.g. UTC server + Europe/Prague site) the displayed time drifted by 1–2 hours. Now parsed explicitly as WP-local via `DateTimeImmutable(..., wp_timezone())` and formatted with `wp_date()`.
+- **Fixed (high):** Export SQL joined `wp_terms.name` against the variation's `attribute_pa_format` postmeta, but WC stores the **slug** there, not the name. The join is now `terms.slug = meta_value`, while `MIN(terms.name)` still returns the human-readable display value.
+- **Fixed (high):** `qty` is now `SUM(order_products.product_qty)` instead of `MIN(...)`. A single order can have multiple line items for the same (product, variation) when the customer added it twice without cart-merge; the previous query silently undercounted in that case.
+- **Fixed:** `JOIN product_variations` was inner — silently excluded orders containing simple (non-variable) products. Now a `LEFT JOIN`; simple products are included with blank `prod_var`/`prod_var_type`.
+- **Fixed:** `prod_img_url` no longer reads `wp_posts.guid` (which WP codex warns is not necessarily a URL). The export now selects `_thumbnail_id` and resolves URLs via `wp_get_attachment_url()` — respects offloaded-media plugins, multisite uploads paths, and `wp_get_attachment_url` filters.
+- **Fixed:** `Studiou_DB_Manager::normalise_csv_date()` now formats with `wp_date()` (WP timezone) instead of `date()` (server timezone), matching `current_time('mysql')` elsewhere.
+- **Added:** Pre-flight check for `wc_order_product_lookup` — if WC Analytics is disabled and the lookup table is missing, the export bails with a clear admin notice instead of failing with a cryptic SQL error.
+- **Changed:** `SET SESSION group_concat_max_len = GREATEST(@@SESSION.group_concat_max_len, 65535)` — never lowers a higher session-scoped value another piece of code may rely on.
+- **Added:** Server-side `.csv` extension check on protocol uploads (defense in depth on top of the existing `is_uploaded_file()` / `$_FILES[...]['error']` checks).
+- **Fixed:** `Order_Status_Manager::add_custom_order_statuses_to_list()` falls back to appending the custom statuses at the end of the dropdown if `wc-processing` was removed by another plugin.
+- **Changed:** `parse_csv()` drops error-suppression `@fopen` — failures are logged explicitly.
+- **Doc:** README gains a "Known limitations" section and clarifies what `order_no` means in CSVs. CLAUDE.md corrected (the `attribute_pa_format` join uses **slug**, not name).
+- **Doc:** [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) updated with per-item status annotations.
+
+> **Deferred** (carried over): live HPOS search-filter verification, `Network:` plugin header, `uninstall.php`, block-based order edit screen, custom-order-number plugin integration.
+
 ### 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):

+ 356 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.1.md

@@ -0,0 +1,356 @@
+# Plugin Revision — v1.4.1 (Fresh-Eye Analytical Review)
+
+> **Status as of 1.4.2 (2026-05-12):** every in-scope finding below has been resolved in plugin version **1.4.2**. Each finding now carries an inline status marker — ✅ resolved, ⏸ deliberately deferred. See the README changelog for the corresponding code changes.
+
+**Scope:** Independent read-only analytical pass over the v1.4.1 codebase. Read with deliberately fresh eyes — not just as "did the 1.4.0 fixes land", but "what would I flag if I had never seen this plugin before". Findings include items that were latent in v1.3.2 and survived both prior reviews.
+
+**Reviewer date:** 2026-05-12
+**Target environment:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled), PHP 7.2+.
+**Files reviewed:** all PHP under `includes/`, the bootstrap `studiou-wc-ord-print-statuses.php`, and the markdown docs.
+**Verification limitations:** No live PHP/WP/WC available. Findings rest on source reading and reasonable WP/WC behavioural assumptions for the target stack.
+
+Severity reflects user-visible or correctness impact, not implementation effort.
+
+---
+
+## 1. Critical
+
+*(None. All 1.3.2 critical findings and the new 1.4.0 criticals are resolved in 1.4.1.)*
+
+---
+
+## 2. High — newly discovered bugs and latent defects
+
+### 2.1 `Custom_Columns_Manager::format_meta_date()` applies the WP timezone offset **twice** — column display can be hours off — ✅ resolved in 1.4.2
+
+Resolution: parse the stored value with `DateTimeImmutable(..., wp_timezone())` and format via `wp_date()`. Eliminates the double-offset drift while keeping locale-aware formatting.
+
+`class-custom-columns-manager.php:50-59`:
+
+```php
+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));
+}
+```
+
+Two issues compound:
+
+1. `strtotime($value)` interprets the input string in the **PHP server timezone**.
+2. `date_i18n(..., $timestamp)` formats the UNIX timestamp in the **WordPress site timezone**.
+
+`$value` is a MySQL datetime string written by `current_time('mysql')`, which is already in WP local time. The intended round-trip is: read string → display unchanged. Instead the pipeline does: parse string *as if* server-local → convert to UTC → format in WP-local. On any host where the PHP server timezone differs from the WP timezone (e.g., UTC server + `Europe/Prague` site), the displayed value drifts by the offset.
+
+**Concrete example** with server UTC, WP `Europe/Prague` (UTC+2 in summer):
+
+- Stored meta: `2026-05-12 16:30:00` (intended Prague-local, equivalent to 14:30 UTC).
+- `strtotime('2026-05-12 16:30:00')` → `1747066200` (16:30 UTC).
+- `date_i18n('Y-m-d H:i', 1747066200)` in WP-Prague → `2026-05-12 18:30`.
+- **User sees `18:30` for a record they expect to read `16:30`.**
+
+This is the same class of bug v1.4.0 review §2.2 identified for `Order_Fields_Manager`. That class was rewritten to pure string reformatting; `Custom_Columns_Manager` was missed. The fix is to convert the same way:
+
+```php
+if (preg_match('/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/', $value, $m)) {
+    return esc_html($m[1] . ' ' . $m[2]);
+}
+```
+
+…or, if locale-aware formatting is required, build a `DateTimeImmutable` with `wp_timezone()` first:
+
+```php
+$dt = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, wp_timezone());
+if ($dt) {
+    return esc_html(wp_date(get_option('date_format') . ' ' . get_option('time_format'), $dt->getTimestamp()));
+}
+```
+
+The "To Print Date" and "In Print Date" columns are the public face of this plugin in the orders list — this bug is user-visible on every page load.
+
+### 2.2 Variation attribute join uses `terms.name` while WC stores the **slug** in `attribute_pa_format` — ✅ resolved in 1.4.2
+
+Resolution: join condition changed to `product_variations_name.slug = product_variations_attr.meta_value`. The SELECT keeps `MIN(product_variations_name.name)` so the displayed value remains the human-readable name.
+
+`class-db-manager.php:60-64`:
+
+```php
+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`
+```
+
+WC variation postmeta for attribute taxonomies (`attribute_pa_*`) stores the **term slug**, not the term name. The join is on `terms.name = meta_value`. This works only when name and slug coincide (e.g., a term where both are `"A4"`). In stores where the human-readable name diverges from the slug (e.g., `name="Velký A4"`, `slug="a4"`), the join returns nothing and `prod_var_type` is `NULL` for that line item.
+
+CLAUDE.md §"Version 1.3.2" describes the 1.3.2 fix as *"Join with wp_terms using the slug field (matching against meta_value) instead of term_id"*. The implementation uses `name`, not `slug`. This is either:
+
+- A documentation/code disagreement (CLAUDE.md is wrong about which column is used), or
+- A real latent defect that has been hidden so far because Studiou's `pa_format` terms happen to have matching name+slug.
+
+The correct join is `product_variations_name.slug = product_variations_attr.meta_value`. If display of the human-readable name (rather than the slug) is desired, leave the column as `MIN(product_variations_name.name)` but switch the join key.
+
+### 2.3 `MIN(order_products.product_qty)` silently undercounts duplicated line items — ✅ resolved in 1.4.2
+
+Resolution: replaced with `SUM(order_products.product_qty)`. A single-row group still returns the same value; duplicated line items now sum correctly.
+
+`class-db-manager.php:46-52` SELECTs:
+
+```sql
+MIN(`order_products`.`product_qty`) AS `qty`
+```
+
+…with `GROUP BY orders.id, order_products.product_id, order_products.variation_id`.
+
+`wc_order_product_lookup` is keyed per **order line item**, not per (order × product × variation). A single order can legitimately contain two line items for the same variation — most commonly when a customer adds the product, edits the cart, and re-adds the same variation rather than incrementing the quantity. WC merges these on display but the lookup table preserves the original line items.
+
+When that happens, the `GROUP BY` collapses both line items to one row and `MIN(qty)` returns one of the two quantities, *not* their sum. The print shop receives an under-quantity; some prints are missed.
+
+The semantically correct aggregation is `SUM(order_products.product_qty)`. `MIN()` is only safe when each group is guaranteed to have exactly one row — which the 1.4.1 review assumed without verifying.
+
+`SUM` over a single-row group equals that row's value, so this is a forward-compatible fix.
+
+---
+
+## 3. Medium — pre-existing issues that didn't surface in earlier reviews
+
+### 3.1 `normalise_csv_date()` formats with server timezone, breaking consistency with `current_time('mysql')` — ✅ resolved in 1.4.2
+
+Resolution: `date()` replaced with `wp_date()`. Imported `external_ref_ord_date` is now stored in WP local time, matching every other meta key.
+
+`class-db-manager.php:301-312`:
+
+```php
+$timestamp = strtotime($raw);
+…
+return date('Y-m-d H:i:s', $timestamp);
+```
+
+`date()` formats in the **server** timezone. Every other write site uses `current_time('mysql')` which is in WP timezone. The import-side `external_ref_ord_date` is therefore stored in server timezone, while values written from elsewhere are in WP timezone.
+
+Today this is hidden because:
+- The Print Information panel reads the value as an opaque string (no timezone interpretation).
+- The orders-list column has its own bug (§2.1) that adds a second offset.
+
+Once §2.1 is fixed, this asymmetry becomes user-visible. Either replace with `wp_date('Y-m-d H:i:s', $timestamp)` or construct via `DateTimeImmutable` in `wp_timezone()`.
+
+### 3.2 `wc_get_order($row['order_no'])` fails silently when the site uses non-numeric order numbers — ⏸ documented, no code change
+
+Resolution path: integrating a specific custom-order-number plugin is out of scope without knowing which one Studiou might adopt. Instead, the README now explicitly states that `order_no` in CSVs is the WC internal order ID, and the "Known limitations" section flags that custom-order-number plugins are not integrated. The export round-trip is internally consistent (the print shop echoes back what we sent).
+
+`class-db-manager.php:171, 209`. WC's `wc_get_order()` casts its argument to int when given a string. A custom-order-number plugin (e.g., "Sequential Order Numbers") that emits values like `ORD-12345` would have `wc_get_order('ORD-12345')` return `false` because `(int) 'ORD-12345' === 0`.
+
+The current Studiou install presumably does not use such a plugin, but:
+- The export query returns `orders.id` as `order_no` — the WC internal ID, **not** any displayed/custom order number.
+- The print provider then echoes back what they received, so the round-trip is consistent.
+- But this is undocumented. README §"CSV formats" calls the column `order_no` without clarifying that it is the WC order ID, not the customer-facing order number.
+
+Risk: when Studiou eventually installs a sequential-numbers plugin and customer-facing order numbers diverge from IDs, the import contract silently breaks.
+
+Suggested mitigation: document the contract; optionally support lookup-by-meta-key as a fallback (`wc_orders_table_query`/`wc_get_orders` with a meta query on the custom-number meta key).
+
+### 3.3 `INNER JOIN` on `product_variations` excludes orders containing simple (non-variable) products — ✅ resolved in 1.4.2
+
+Resolution: changed to `LEFT JOIN`. Simple products now appear in the export with blank `prod_var`/`prod_var_type`. README "Known limitations" calls out the implication.
+
+`class-db-manager.php:58-59`:
+
+```sql
+JOIN `{$wpdb->posts}` `product_variations`
+    ON `product_variations`.`ID` = `order_products`.`variation_id`
+```
+
+When the order line item is a non-variable product, `variation_id = 0` and there is no matching `posts` row. The `JOIN` (inner) eliminates that row entirely.
+
+If the print shop sometimes ships products that aren't configured as variations in WC, those products vanish from the CSV — silently. The plugin appears designed only for variable products (`prod_var`, `prod_var_type` columns), so this is *intentional* but undocumented. A reviewer auditing this code for the first time would not be able to tell.
+
+Either convert to `LEFT JOIN` and `COALESCE(product_variations.post_title, products.post_title)`, or document the design constraint explicitly.
+
+### 3.4 `SET SESSION group_concat_max_len = 65535` leaks to other queries on the same connection — ✅ resolved in 1.4.2
+
+Resolution: switched to `SET SESSION group_concat_max_len = GREATEST(@@SESSION.group_concat_max_len, 65535)` — never lowers a higher pre-existing value, only raises it to at least 64 KB.
+
+`class-db-manager.php:37`. `SET SESSION` is connection-scoped. In WP's default (non-persistent) MySQL connections, the connection lifetime equals the request lifetime, so the side effect is bounded. With a persistent connection pool (`mysqli.allow_persistent=1` plus a managed pool), the modified value carries into other requests reusing the same connection. Highly unlikely to break anything (the limit only goes up), but worth knowing.
+
+A cleaner pattern is `$wpdb->query("SET @@SESSION.group_concat_max_len = GREATEST(@@SESSION.group_concat_max_len, 65535)")` or wrapping the export in `SET …; SELECT …; SET @@SESSION.group_concat_max_len = DEFAULT;`. Cost/benefit: cosmetic for the current deployment.
+
+### 3.5 `images.guid` is used as the image URL, but WP's codex explicitly advises against it — ✅ resolved in 1.4.2
+
+Resolution: the export now selects the `_thumbnail_id` and resolves URLs via `wp_get_attachment_url()` in PHP after the query. The `wp_posts` join on the attachment table was dropped. Offloaded-media plugins, multisite uploads paths, and the `wp_get_attachment_url` filter chain are now respected.
+
+`class-db-manager.php:50, 68-70` selects and joins `wp_posts.guid` for the variant thumbnail URL. The WordPress codex documents `guid` as "not necessarily a URL" and warns against using it for that purpose. The canonical lookup is `wp_get_attachment_url($attachment_id)`, which respects:
+
+- Multisite per-blog uploads paths
+- Filters (`wp_get_attachment_url`, `upload_dir`)
+- Image stored in S3 / offloaded media plugins
+
+Pure SQL can't call `wp_get_attachment_url`. The pragmatic alternative is to select the `_thumbnail_id` (already joined as `product_meta.meta_value`) and resolve URLs in PHP for each row before sending the CSV. The cost is one PHP call per line item — negligible for export-volume row counts.
+
+This is a pre-existing concern from v1.3.2 that none of the earlier reviews surfaced.
+
+### 3.6 Server-side file extension / MIME check missing on imports — ✅ resolved in 1.4.2 (extension)
+
+Resolution: `validated_upload()` now checks `pathinfo($file['name'], PATHINFO_EXTENSION) === 'csv'` after the existing `$_FILES[...]['error']` and `is_uploaded_file()` checks. A stricter MIME-type sniff was considered overkill given that `parse_csv` itself only reads bytes — no execution path exists for an uploaded file.
+
+`class-import-manager.php:134-156` validates `$_FILES[...]['error']` and `is_uploaded_file()`, but does not check the file extension or MIME type. The `accept=".csv,text/csv"` HTML attribute is client-side only.
+
+Today this is not exploitable: `parse_csv` simply reads bytes through `fgetcsv` and returns rows. A misnamed binary file produces an empty parse result and the "could not be parsed" notice. But defense-in-depth would be:
+
+```php
+$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
+if ($ext !== 'csv') { /* notice + abort */ }
+```
+
+Cheap and good hygiene.
+
+### 3.7 `wc_order_product_lookup` dependency is implicit — ✅ resolved in 1.4.2
+
+Resolution: `get_orders_for_printing()` now pre-checks the table with `SHOW TABLES LIKE …` and, if it is missing, emits a clear admin notice ("Export requires the WooCommerce analytics lookup table. Enable WooCommerce Analytics or contact support.") and returns an empty result instead of letting the SQL error surface obliquely.
+
+`class-db-manager.php:53-54` joins `{$wpdb->prefix}wc_order_product_lookup`. This table is part of WC analytics and is created by WC by default — but it can be absent on:
+
+- Very old WC installs that pre-date the analytics module
+- Installs where the user explicitly disabled the WC analytics feature (it can be turned off in WC 4.0+)
+- Newly migrated databases where the lookup-table sync hasn't run yet
+
+When the table is missing the export query fails with a SQL error; the failure is logged and the bulk action emits "no exportable items" but doesn't tell the operator *why*. Either pre-check `$wpdb->get_var("SHOW TABLES LIKE '{$wpdb->prefix}wc_order_product_lookup'")` once at boot, or surface the SQL error message in the warning notice when it is "table doesn't exist".
+
+### 3.8 HPOS-search hook name `woocommerce_order_table_search_query_meta_keys` is still unverified against WC 10.7.0 — ⏸ deferred (verification task)
+
+Same status as in the v1.4.0 review — no live verification possible from this environment. README "Known limitations" surfaces this so operators can confirm against the running site.
+
+Carried over from `revise-1.4.0.md` §2.1. No live WC available; the filter name has not been visually confirmed in WC 10.7.0 source. If the filter has been renamed, the legacy `woocommerce_shop_order_search_fields` will still keep the feature working under legacy CPT, but HPOS users will lose it again.
+
+This remains the single most important verification ask before release-tagging 1.4.1.
+
+---
+
+## 4. Low — style and minor
+
+### 4.1 `parse_csv` uses error-suppression `@fopen` — ✅ resolved in 1.4.2
+
+Resolution: `fopen()` is now called without `@`; failures are logged via `UtilsLog::log()`.
+
+`class-db-manager.php:234` — `@fopen()` swallows the underlying warning. Replace with `fopen($path, 'r')` plus explicit error logging on `false`, and add a `clearstatcache()` if relevant. WordPress's general style is to avoid `@`.
+
+### 4.2 `STUDIOU_WC_OPS_VERSION` / `STUDIOU_WC_OPS_FILE` remain defined but unused inside the plugin — ⏸ left as-is
+
+`studiou-wc-ord-print-statuses.php:22-23`. Acknowledged in `revise-1.4.0.md` §3.6 / §4.6 — left in place for external consumers. No action.
+
+### 4.3 `handle_status_transitions` doesn't clear meta on transition *out* of a print status — ⏸ accepted as designed
+
+`class-order-status-manager.php:79-94` stamps `to_print_date` / `in_print_date` on entry, but never clears them on exit (e.g., to-print → cancelled). The historical timestamp is preserved — usually desired. Worth documenting as the deliberate design choice if a future contributor wonders.
+
+### 4.4 `woocommerce_process_shop_order_meta` hook coverage under HPOS — ⏸ left as-is (compat shim still present in WC 10.7.0)
+
+`class-order-fields-manager.php:31` hooks `woocommerce_process_shop_order_meta`. WC 8.x added a compatibility shim that fires this hook on HPOS as well, but the canonical HPOS save hook is `woocommerce_after_order_object_save`. If WC ever deprecates the compatibility shim, manual edits to the Print Information panel will stop persisting on HPOS-only setups. Worth a forward-looking note.
+
+### 4.5 `private $custom_statuses` initialised lazily but `=== null` check is fragile under any future strict-type migration — ⏸ left as-is (no typed-property refactor planned)
+
+`class-order-status-manager.php:7, 17-18`. If the property is later typed as `array $custom_statuses` (PHP 7.4 typed properties), an uninitialised typed property emits an `Error` on read — `=== null` would never reach. Cosmetic concern; flag if/when typed-properties refactor happens.
+
+### 4.6 `generate_csv()` reads `(array) $data[0]` without re-guarding — ⏸ left as-is (caller guarantees non-empty)
+
+`class-bulk-actions-manager.php:113`. Defensive: caller already gates with `!empty($orders_data)`, so this is safe today. But the function would crash on direct invocation with an empty array. A one-line `if (empty($data)) return '';` would make it self-contained.
+
+### 4.7 Two `__('Import Protocols', …)` calls produce identical translatable strings — ✅ resolved in 1.4.2
+
+Resolution: cached in a local `$title` variable before the `add_submenu_page` call.
+
+`class-import-manager.php:16-17` translates the same string twice (menu title and page title). Functionally identical, two lookups instead of one. Cosmetic.
+
+### 4.8 `register_post_status` still called under HPOS — ⏸ left as-is
+
+Carried over from `revise-1.4.0.md` §4.9. Harmless duplication, kept for WC admin chrome compatibility.
+
+### 4.9 `add_custom_order_statuses_to_list` silently no-ops when `wc-processing` is absent — ✅ resolved in 1.4.2
+
+Resolution: an `$inserted` flag tracks whether the anchor was found; the fallback path appends the custom statuses at the end of the dropdown if not.
+
+`class-order-status-manager.php:54-65`. If a third party removes `wc-processing` from `wc_order_statuses`, our statuses are never inserted (the foreach finds no anchor). Highly unlikely in practice; a defensive fallback that appends at the end if `wc-processing` is missing would tighten this.
+
+---
+
+## 5. Documentation drift
+
+### 5.1 CLAUDE.md describes the `attribute_pa_format` join as using `slug`; the code uses `name` — ✅ resolved in 1.4.2
+
+Resolution: the code was changed to use `slug` (§2.2); CLAUDE.md's description now matches reality. The 1.3.2 changelog entry there gained a footnote pointing at this review.
+
+CLAUDE.md §"Version 1.3.2" `Recent Changes` paragraph says:
+
+> Join with `wp_terms` using the slug field (matching against meta_value) instead of term_id
+
+The actual SQL in `class-db-manager.php:64` joins on `terms.name = meta_value`. See §2.2 above — either fix the code or fix the doc, but the two should agree.
+
+### 5.2 README's CSV-import section doesn't define what `order_no` means — ✅ resolved in 1.4.2
+
+Resolution: a callout above the CSV-format section explicitly states `order_no` is the WooCommerce internal order ID and describes the interaction with custom-order-number plugins.
+
+§3.2 above. README should explicitly say `order_no` is the WC internal order ID, and explain the interaction (or lack thereof) with custom-order-number plugins.
+
+### 5.3 Deferred items in `revise-1.4.0.md` should appear in the project's "Known limitations" section of README — ✅ resolved in 1.4.2
+
+Resolution: README has a new "Known limitations" section listing HPOS search-filter verification, variable-products-only design, WC Analytics dependency, block-based order edit screen, no multisite header, no uninstall cleanup, custom-order-number plugin gap, hardcoded `attribute_pa_format`, and the `group_concat_max_len` session-scoped bump.
+
+Currently the README changelog mentions them only in passing under 1.4.1. New readers of the README don't get the same heads-up that a contributor reading the revise doc does. A short "Known limitations" section in the README would consolidate:
+
+- HPOS search filter verification still pending on WC 10.7.0
+- No multisite `Network:` header
+- No uninstall cleanup
+- Block-based order edit screen unsupported
+- Optimised for variable products only (§3.3)
+- Reliance on WC analytics' `wc_order_product_lookup` table (§3.7)
+
+---
+
+## 6. Spec compliance recap (against `docs/instructions.txt`)
+
+| Spec item | Status as of 1.4.1 |
+|-----------|--------------------|
+| 1. Custom statuses after `wc-processing` | ✅ |
+| 2. Custom order fields | ✅ (plus `external_ref_ord_date` extension) |
+| 3. Search by `external-ref-ord-no` | ⏸ resolved in code, HPOS filter name pending verification |
+| 4. Three bulk actions | ✅ |
+| 5. Three custom columns | ✅ (display bug §2.1 affects the date columns) |
+| 6. Import InPrint with DISTINCT | ✅ |
+| 7. Import Delivered with DISTINCT | ✅ |
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by user-visible severity. Status as of **1.4.2**:
+
+1. **§2.1 Column-display timezone double-shift.** ✅ resolved.
+2. **§2.3 `MIN(qty)` undercount with duplicate line items.** ✅ resolved (`SUM(qty)`).
+3. **§2.2 Variation join on `terms.name` instead of `terms.slug`.** ✅ resolved.
+4. **§3.1 `normalise_csv_date` server-timezone vs WP-timezone asymmetry.** ✅ resolved (`wp_date()`).
+5. **§3.8 HPOS search filter unverified.** ⏸ deferred — still pending live verification on WC 10.7.0.
+6. **§3.5 `images.guid` as the image URL.** ✅ resolved (`wp_get_attachment_url()`).
+7. **§3.7 Implicit `wc_order_product_lookup` dependency.** ✅ resolved (pre-flight `SHOW TABLES`).
+8. **§3.3 Non-variable products silently absent from export.** ✅ resolved (`LEFT JOIN`).
+9. **§3.2 `wc_get_order($row['order_no'])` for non-integer order numbers.** ⏸ documented, no code change.
+10. **§3.6 No server-side CSV extension/MIME check.** ✅ resolved (extension only).
+
+---
+
+## 8. Resolution status of prior reviews
+
+- **`revise-1.3.2.md`:** 26 of 30 findings resolved in 1.4.0. 3 deferred (no uninstall, no `Network:`, cosmetic README). 1 (HPOS search) resolved in code, verification pending. **No regressions detected.**
+- **`revise-1.4.0.md`:** all in-scope findings resolved in 1.4.1 per its own per-item status markers. **One regression remained latent — see §2.1 of this review.** §2.2 was the documented Order_Fields fix; the parallel issue in Custom_Columns_Manager was missed.
+
+---
+
+## 9. Out of scope
+
+- Live behaviour testing against a running WP 6.9.4 / WC 10.7.0 install.
+- Performance profiling of the export query on large order volumes (the GROUP BY + 4× LEFT JOIN is non-trivial; needs EXPLAIN against production data).
+- Behavioural verification on the WC 10.7.0 block-based order edit screen.
+- `.po`/`.mo` correctness — translations were verified for source-side coverage only.
+
+---
+
+*End of review.*

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

@@ -1,5 +1,5 @@
 Translations: all user-facing strings (English → Czech).
-Last reviewed: v1.4.1 (2026-05-12).
+Last reviewed: v1.4.2 (2026-05-12).
 
 Note: After editing the .po file, regenerate the .mo binary with one of:
   wp i18n make-mo languages/
@@ -69,3 +69,7 @@ Studiou WC Order Print Statuses requires WooCommerce to be installed and active.
 
 # 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.
+
+# New in 1.4.2
+Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support.  Export vyžaduje tabulku WooCommerce analytics (wc_order_product_lookup). Aktivujte WooCommerce Analytics nebo kontaktujte podporu.
+Only .csv files are accepted.                                                                Přijímány jsou pouze soubory .csv.

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

@@ -47,14 +47,38 @@ class Custom_Columns_Manager {
         }
     }
 
+    /**
+     * Format a MySQL datetime meta value for display.
+     *
+     * The stored value (written by current_time('mysql')) is already in WP
+     * local time. Previously this method used strtotime()/date_i18n() which
+     * applied the timezone offset twice on hosts where the PHP server
+     * timezone differs from the WP site timezone (e.g. UTC server +
+     * Europe/Prague site → display drifted by 1–2 hours).
+     *
+     * We now parse the string as WP-local time explicitly, then format via
+     * wp_date() which respects WP timezone. This keeps locale-aware
+     * formatting while eliminating the offset drift.
+     */
     private function format_meta_date($value) {
         if (empty($value)) {
             return '—';
         }
-        $timestamp = strtotime($value);
-        if (!$timestamp) {
+        $value = trim((string) $value);
+        // Accept both 'YYYY-MM-DD HH:MM:SS' and 'YYYY-MM-DDTHH:MM[:SS]'.
+        $normalised = str_replace('T', ' ', $value);
+        if (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/', $normalised)) {
+            return '—';
+        }
+        if (strlen($normalised) === 16) { // missing seconds
+            $normalised .= ':00';
+        }
+        try {
+            $dt = new DateTimeImmutable($normalised, wp_timezone());
+        } catch (Exception $e) {
             return '—';
         }
-        return esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $timestamp));
+        $fmt = get_option('date_format') . ' ' . get_option('time_format');
+        return esc_html(wp_date($fmt, $dt->getTimestamp()));
     }
 }

+ 63 - 19
studiou-wc-ord-print-statuses/includes/class-db-manager.php

@@ -16,11 +16,25 @@ class Studiou_DB_Manager {
 
     /**
      * 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.
+     *
+     * Design notes:
+     * - Multiple product categories are concatenated into prod_cat instead of
+     *   multiplying the row.
+     * - Simple (non-variable) products are included via LEFT JOIN; their
+     *   prod_var / prod_var_type fields will be NULL.
+     * - The image URL is resolved in PHP via wp_get_attachment_url() rather
+     *   than reading wp_posts.guid, which the codex warns is not guaranteed
+     *   to be a URL (breaks under offloaded media / multisite uploads paths).
+     * - The variation attribute is joined on terms.slug — WC stores the slug
+     *   in `attribute_pa_format` postmeta, not the term name.
+     * - qty is summed across duplicate line items (a customer adding the
+     *   same variation twice without merging produces two rows in the
+     *   lookup table; MIN() would silently undercount).
      *
      * @param int[] $order_ids
-     * @return array
+     * @return array Each element is an stdClass with fields:
+     *               order_no, prod_cat, prod_name, prod_var, prod_var_type,
+     *               prod_img_url, qty, email.
      */
     public static function get_orders_for_printing($order_ids) {
         global $wpdb;
@@ -30,16 +44,28 @@ class Studiou_DB_Manager {
             return array();
         }
 
+        $lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
+        if (!self::table_exists($lookup_table)) {
+            UtilsLog::log("get_orders_for_printing: required table {$lookup_table} is missing. Is WC analytics enabled?");
+            UtilsLog::message(
+                __('Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support.', 'studiou-wc-ord-print-statuses'),
+                'error'
+            );
+            return array();
+        }
+
         $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");
+        // products with long names are not silently truncated. Use GREATEST
+        // so we never *lower* a setting another piece of code may rely on.
+        $wpdb->query("SET SESSION group_concat_max_len = GREATEST(@@SESSION.group_concat_max_len, 65535)");
 
-        // Non-aggregated SELECT columns are wrapped in MIN() to remain
+        // Non-aggregated SELECT columns are wrapped in MIN()/SUM() 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.
+        // qty uses SUM() because a single (order, product, variation) group
+        // may contain multiple lookup rows when a customer added the same
+        // variation twice without cart-merge.
         $sql = "
             SELECT
                 `orders`.`id` AS `order_no`,
@@ -47,27 +73,24 @@ class Studiou_DB_Manager {
                 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(`product_meta`.`meta_value`)         AS `prod_img_id`,
+                SUM(`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`
             JOIN `{$wpdb->posts}` `products`
                 ON `products`.`ID` = `order_products`.`product_id`
-            JOIN `{$wpdb->posts}` `product_variations`
+            LEFT 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`
+                ON `product_variations_name`.`slug` = `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`
@@ -90,9 +113,24 @@ class Studiou_DB_Manager {
             UtilsLog::log('SQL error in get_orders_for_printing: ' . $wpdb->last_error);
             return array();
         }
+
+        // Resolve the attachment ID to a URL via WP's API — respects
+        // offloaded-media plugins, multisite upload paths, and the
+        // wp_get_attachment_url filter chain.
+        foreach ($results as $row) {
+            $attachment_id   = isset($row->prod_img_id) ? (int) $row->prod_img_id : 0;
+            $row->prod_img_url = $attachment_id ? (string) wp_get_attachment_url($attachment_id) : '';
+            unset($row->prod_img_id);
+        }
         return $results;
     }
 
+    private static function table_exists($table) {
+        global $wpdb;
+        $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
+        return $found === $table;
+    }
+
     /**
      * Bulk-set orders to "to-print", skipping orders in terminal states.
      *
@@ -224,14 +262,14 @@ class Studiou_DB_Manager {
      * 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.
+     * form ("order_no") both work. A leading UTF-8 BOM is stripped.
      *
      * @param string $file_path
      * @return array Empty array on failure.
      */
     public static function parse_csv($file_path) {
         $csv_data = array();
-        $handle   = @fopen($file_path, 'r');
+        $handle   = fopen($file_path, 'r');
         if ($handle === false) {
             UtilsLog::log('parse_csv: failed to open ' . $file_path);
             return $csv_data;
@@ -294,9 +332,15 @@ class Studiou_DB_Manager {
     }
 
     /**
-     * Normalise a free-form date string into a MySQL datetime.
+     * Normalise a free-form date string into a MySQL datetime in WP local time.
      * Returns '' (and logs) on parse failure so the UI does not silently
      * carry unparseable raw values through the round-trip.
+     *
+     * Uses wp_date() so the resulting string is consistent with
+     * current_time('mysql') — both reflect WP local time, not server PHP
+     * timezone. Previously this used date(), which interpreted the timestamp
+     * in server timezone and produced an asymmetry between import-side
+     * external_ref_ord_date and every other meta key.
      */
     private static function normalise_csv_date($raw) {
         $raw = sanitize_text_field((string) $raw);
@@ -308,6 +352,6 @@ class Studiou_DB_Manager {
             UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
             return '';
         }
-        return date('Y-m-d H:i:s', $timestamp);
+        return wp_date('Y-m-d H:i:s', $timestamp);
     }
 }

+ 11 - 2
studiou-wc-ord-print-statuses/includes/class-import-manager.php

@@ -11,10 +11,11 @@ class Import_Manager {
     }
 
     public function add_import_submenu() {
+        $title = __('Import Protocols', 'studiou-wc-ord-print-statuses');
         add_submenu_page(
             'woocommerce',
-            __('Import Protocols', 'studiou-wc-ord-print-statuses'),
-            __('Import Protocols', 'studiou-wc-ord-print-statuses'),
+            $title,
+            $title,
             'manage_woocommerce',
             'studiou-import-protocols',
             array($this, 'render_import_page')
@@ -152,6 +153,14 @@ class Import_Manager {
             UtilsLog::message(__('Uploaded file is not accessible.', 'studiou-wc-ord-print-statuses'), 'error');
             return null;
         }
+        // Defense-in-depth: the HTML `accept` attribute is a client-side hint
+        // and can be bypassed. Verify the uploaded file is named *.csv.
+        $name = isset($file['name']) ? (string) $file['name'] : '';
+        $ext  = strtolower(pathinfo($name, PATHINFO_EXTENSION));
+        if ($ext !== 'csv') {
+            UtilsLog::message(__('Only .csv files are accepted.', 'studiou-wc-ord-print-statuses'), 'error');
+            return null;
+        }
         return $file;
     }
 

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

@@ -53,12 +53,22 @@ class Order_Status_Manager {
 
     public function add_custom_order_statuses_to_list($order_statuses) {
         $new_order_statuses = array();
+        $inserted           = false;
         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'];
                 }
+                $inserted = true;
+            }
+        }
+        // Fallback: append at the end if `wc-processing` was removed by
+        // another plugin. Better to surface the custom statuses somewhere
+        // in the dropdown than to drop them silently.
+        if (!$inserted) {
+            foreach ($this->get_custom_status_definitions() as $status_slug => $status_args) {
+                $new_order_statuses[$status_slug] = $status_args['label'];
             }
         }
         return $new_order_statuses;

+ 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.1
+ * Version: 1.4.2
  * 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.1');
+define('STUDIOU_WC_OPS_VERSION', '1.4.2');
 define('STUDIOU_WC_OPS_FILE', __FILE__);
 
 // Declare HPOS (High-Performance Order Storage) compatibility