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.
(None. All 1.3.2 critical findings and the new 1.4.0 criticals are resolved in 1.4.1.)
Custom_Columns_Manager::format_meta_date() applies the WP timezone offset twice — column display can be hours off — ✅ resolved in 1.4.2Resolution: 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:
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:
strtotime($value) interprets the input string in the PHP server timezone.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):
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.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:
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:
$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.
terms.name while WC stores the slug in attribute_pa_format — ✅ resolved in 1.4.2Resolution: 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:
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:
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.
MIN(order_products.product_qty) silently undercounts duplicated line items — ✅ resolved in 1.4.2Resolution: 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:
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.
normalise_csv_date() formats with server timezone, breaking consistency with current_time('mysql') — ✅ resolved in 1.4.2Resolution: 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:
$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:
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().
wc_get_order($row['order_no']) fails silently when the site uses non-numeric order numbers — ⏸ documented, no code changeResolution 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:
orders.id as order_no — the WC internal ID, not any displayed/custom order number.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).
INNER JOIN on product_variations excludes orders containing simple (non-variable) products — ✅ resolved in 1.4.2Resolution: 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:
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.
SET SESSION group_concat_max_len = 65535 leaks to other queries on the same connection — ✅ resolved in 1.4.2Resolution: 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.
images.guid is used as the image URL, but WP's codex explicitly advises against it — ✅ resolved in 1.4.2Resolution: 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:
wp_get_attachment_url, upload_dir)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.
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:
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'csv') { /* notice + abort */ }
Cheap and good hygiene.
wc_order_product_lookup dependency is implicit — ✅ resolved in 1.4.2Resolution: 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:
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".
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.
parse_csv uses error-suppression @fopen — ✅ resolved in 1.4.2Resolution: 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 @.
STUDIOU_WC_OPS_VERSION / STUDIOU_WC_OPS_FILE remain defined but unused inside the plugin — ⏸ left as-isstudiou-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.
handle_status_transitions doesn't clear meta on transition out of a print status — ⏸ accepted as designedclass-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.
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.
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.
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.
__('Import Protocols', …) calls produce identical translatable strings — ✅ resolved in 1.4.2Resolution: 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.
register_post_status still called under HPOS — ⏸ left as-isCarried over from revise-1.4.0.md §4.9. Harmless duplication, kept for WC admin chrome compatibility.
add_custom_order_statuses_to_list silently no-ops when wc-processing is absent — ✅ resolved in 1.4.2Resolution: 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.
attribute_pa_format join as using slug; the code uses name — ✅ resolved in 1.4.2Resolution: 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_termsusing 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.
order_no means — ✅ resolved in 1.4.2Resolution: 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.
revise-1.4.0.md should appear in the project's "Known limitations" section of README — ✅ resolved in 1.4.2Resolution: 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:
Network: headerwc_order_product_lookup table (§3.7)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 | ✅ |
Ordered by user-visible severity. Status as of 1.4.2:
MIN(qty) undercount with duplicate line items. ✅ resolved (SUM(qty)).terms.name instead of terms.slug. ✅ resolved.normalise_csv_date server-timezone vs WP-timezone asymmetry. ✅ resolved (wp_date()).images.guid as the image URL. ✅ resolved (wp_get_attachment_url()).wc_order_product_lookup dependency. ✅ resolved (pre-flight SHOW TABLES).LEFT JOIN).wc_get_order($row['order_no']) for non-integer order numbers. ⏸ documented, no code change.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..po/.mo correctness — translations were verified for source-side coverage only.End of review.