# 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.*