# QDR — Studiou WC Order Print Statuses 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.5.0 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com) - **License:** GPL v2 or later --- ## Requirements | Component | Minimum | Tested up to | |-------------|---------|--------------| | WordPress | 5.8 | 6.9.4 | | WooCommerce | 3.0 | 10.7.0 | | PHP | 7.2 | — | Compatible with WooCommerce **High-Performance Order Storage (HPOS)** — the plugin declares `custom_order_tables` compatibility and queries the HPOS `wc_orders` table directly. --- ## Installation 1. Copy the `studiou-wc-ord-print-statuses` folder into `wp-content/plugins/`. 2. Make sure WooCommerce is installed and active. 3. Activate the plugin from **Plugins** in the WordPress admin. No additional setup is required — custom statuses, fields, columns, and bulk actions are registered on activation. --- ## Features ### 1. Custom order statuses Two new statuses are inserted in the WooCommerce status pipeline, right after `processing`: | Slug | Label | Purpose | |---------------|----------------------|-------------------------------------------------| | `wc-to-print` | Prepare to Printing | Order is queued and exported to the print shop. | | `wc-in-print` | In Printing | Print shop has accepted the order. | Status transitions automatically stamp the related metadata field (`to_print_date` / `in_print_date`) with the current server time. ### 2. Print Information fields on the order edit page A new **Print Information** panel is added to the order details screen with four editable fields: - **Prepare To Print Date** (`to_print_date`) - **In Print Date** (`in_print_date`) - **External Order Number** (`external_ref_ord_no`) - **External Order Date** (`external_ref_ord_date`) — populated by InPrint protocol imports. - **External Order Delivered** (`external_ref_ord_delivered`) ### 3. Bulk actions on the Orders list Available in the **Bulk actions** dropdown on **WooCommerce → Orders**: - **Prepare to Printing Export** — runs the print-shop export SQL across the selected orders, streams the result as a CSV download, and moves every selected order to `wc-to-print`. - **Set Status to Prepare to Printing** — moves selected orders to `wc-to-print` and stamps `to_print_date`. - **Set Status to In Printing** — moves selected orders to `wc-in-print` and stamps `in_print_date`. ### 4. Extra columns in the Orders list Three columns are appended after **Total**: - **To Print Date** - **In Print Date** - **External Order Number** ### 5. Search by External Order Number The standard WooCommerce **Search orders** input also matches the `external_ref_ord_no` meta value — no separate input is required. Works under both legacy CPT storage and HPOS (`woocommerce_shop_order_search_fields` and `woocommerce_order_table_search_query_meta_keys` filters). ### 6. Per-order-item print status (1.5.0+) Each product line item on an order carries its own print status independently of the order's status: | Status | Meaning | |----------------|------------------------------------------------------------| | `pending-print` | Initial. Waiting for the print provider. | | `in-print` | Currently being printed. | | `done-print` | Printed. | | `skip-print` | Skipped — won't be printed (operator decision, exclusion). | The status is editable per item from the **Items** table on the order edit screen via a `` lets the operator change item status manually. Non-product items show `—`. - **Added:** Order → item propagation on `wc-in-print` transitions. The order being flipped to "In Printing" (via manual admin change, bulk action, or InPrint Protocol import) advances all `pending-print` items to `in-print`. `done-print` and `skip-print` items are untouched. - **Added:** Completion guard on `wc-completed` transitions. The order cannot complete while any product line item is still `pending-print` or `in-print`. Blocked transitions revert with an admin notice listing the count of unready items. Existing already-completed orders are unaffected. - **Added:** Order activity notes for every item-status change ("Item #5 (Product A): Pending Print → Done Print.") and for batch auto-advances ("3 line items advanced to In Print by order-status change."). - **Changed:** `Studiou_DB_Manager::import_delivered_protocol()` now calls `advance_remaining_to_done()` before flipping the order to `wc-completed`. Without this the completion guard would block every Delivered Protocol import. The import summary surfaces the total items advanced. - **Changed:** Export CSV adds `prod_print_status` column. Existing print-shop tooling that imports the CSV needs to add (or ignore) this column. - **Changed:** Bulk action "Set Status to In Printing" now also propagates items (via the new listener — no Bulk_Actions_Manager code change). Bulk action "Set Status to Prepare to Printing" remains order-only. - **Doc:** new manager class added to the manager-pattern in CLAUDE.md; planning doc at [`docs/feature-order-item-status-analysis.md`](docs/feature-order-item-status-analysis.md). > **Known limitations carried into 1.5.0:** the new column renders on the classic order edit screen only — WC 10.x's block-based order edit screen (still feature-flagged) is not supported. Tracked as a phase-2 follow-up. ### 1.4.5 — 2026-05-12 Defensive-hardening release addressing the medium and low findings in [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md): - **Fixed:** `Studiou_DB_Manager::normalise_csv_date()` fast path now validates date and time ranges with `checkdate()` + hour/minute/second bounds. Structurally-valid but semantically-bogus inputs like `"2026-13-45T25:99"` fall through to the `DateTimeImmutable` fallback, which throws → caught → empty string returned. Mirrors the validation pattern already in `Order_Fields_Manager::datetime_local_to_mysql`. - **Fixed:** `Import_Manager::emit_import_summary()` now surfaces an info notice ("The CSV file contained no actionable rows (empty order_no column).") when both `$updated` and `$errors` are zero — closes the silent-import case where every CSV row had an empty `order_no` column. - **Fixed:** `Studiou_DB_Manager::csv_escape()` now `ltrim`s before checking the first character. Cells like `" =CMD"` — which some spreadsheet apps strip and evaluate — are no longer slipping past the formula-injection defence. - **Fixed:** `Studiou_DB_Manager::parse_csv()` rejects CSVs whose normalised headers collide (e.g., two columns both ending up as `order_no` after whitespace/case normalisation). Previously `array_combine` silently overwrote, losing one column's data. Now logs and returns empty so the downstream import bails with the existing "could not be parsed" notice. - **Changed:** `bulk_set_print_status` order-note selection lifted from a binary ternary to an explicit `array(slug => note)` map. Adding a third status in the future no longer risks inheriting the wrong note via the else branch. - **Doc:** [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md) updated with per-item status annotations. README "Known limitations" gains an entry for the theoretical MySQL collation mismatch on the slug join (§3.4 of the review). > **Deferred** (low-impact, carried over): main plugin instance lifecycle (architectural, no functional impact), `woocommerce_missing_notice` mid-request re-fire (edge case), collation mismatch (theoretical, no production occurrence). ### 1.4.4 — 2026-05-12 Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md): - **Fixed (high):** `Studiou_DB_Manager::normalise_csv_date()` was shifting timezone-less CSV dates by the WP timezone offset. WordPress sets the PHP timezone to UTC very early, so `strtotime("2026-05-12 14:30")` returned a UTC timestamp; `wp_date()` then formatted it in WP-local, producing a Prague-shifted "2026-05-12 16:30:00". Now uses pure-regex reformat for ISO-like inputs (no shift) and `DateTimeImmutable(..., wp_timezone())->setTimezone(wp_timezone())` for the fallback path (handles explicit-timezone inputs correctly). **No remaining `strtotime` calls in `includes/`** — the recurring timezone-bug pattern is now structurally eliminated. - **Fixed:** `Studiou_DB_Manager::get_orders_for_printing()` now pre-checks the HPOS `wc_orders` table parallel to the existing `wc_order_product_lookup` check. If HPOS is disabled the export bails with a clear admin notice ("Export requires WooCommerce HPOS … Enable High-Performance Order Storage …") instead of failing with a cryptic SQL error. - **Fixed:** `bulk_set_print_status` now passes an explanatory note to `update_status` — "Marked Prepare to Printing by bulk action." / "Marked In Printing by bulk action." — matching the audit-trail pattern the import paths use. - **Changed:** `Custom_Columns_Manager` uses the `WC_Order` object directly when WC hands one to the column-content callback (HPOS path); under legacy CPT it memoises by ID per request so the same order isn't re-resolved across the three custom columns. Avoids 100–150 `wc_get_order` calls per 50-orders-per-page list. - **Changed:** `Import_Manager::emit_import_summary()` suppresses the "0 orders updated successfully" notice when no rows changed — the per-error notice stands on its own. Matches the `notify_moved_count` pattern. - **Changed:** `call_user_func($cfg['handler'], $csv_data)` → direct callable invocation. Tighter and slightly faster. - **Changed:** The `before_woocommerce_init` HPOS-compat declaration is now a named function (`studiou_wc_ord_print_statuses_declare_hpos_compat`). Can be `remove_action`-ed from outside. - **Doc:** [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md) updated with per-item status annotations. ### 1.4.3 — 2026-05-12 Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md): - **Fixed (high):** `import_delivered_protocol` was missing the terminal-status guard that `import_inprint_protocol` had. A `cancelled`, `refunded`, or `failed` order in the delivered CSV was silently reactivated to `completed`. Now skipped with a per-row error notice. `completed` orders are still allowed through (re-running the same protocol is a benign no-op transition). - **Fixed (high):** Import Protocols screen required `manage_woocommerce` (Administrator-only) while bulk actions only required `edit_shop_orders` (Shop Manager). Shop Managers can now run protocol imports — the two paths share the same capability requirement. - **Fixed:** `Studiou_DB_Manager::table_exists()` wraps the table name in `$wpdb->esc_like()`. The previous `SHOW TABLES LIKE %s` left the conventional `_` in `$wpdb->prefix` as a SQL wildcard; the post-query equality comparison mitigated false positives but the safety relied on the wrong layer. - **Fixed:** CSV export now defuses Excel/LibreOffice/Sheets formula injection — any cell beginning with `=`, `+`, `-`, `@`, `\t`, or `\r` is prefixed with a single quote. OWASP-recommended pattern; the leading quote is hidden by spreadsheet apps but disables formula evaluation. - **Fixed:** `Order_Fields_Manager::datetime_local_to_mysql()` now validates date and time ranges via `checkdate()` plus hour/minute/second bounds. Direct POST or DevTools-edited form values like `2026-13-45T25:99` no longer slip through to be stored as bogus MySQL datetimes. - **Fixed:** `qty` is now formatted in PHP — `SUM(DECIMAL)` returns `"2.0000"` from MySQL; the post-processing step normalises whole numbers to `"2"` and trims trailing zeros from decimals. - **Fixed:** Both import handlers now pass an explanatory note to `update_status` ("Marked In Printing by InPrint Protocol import." / "Marked completed by Delivered Protocol import."). The order activity feed reflects how the transition was triggered. - **Fixed:** Both import handlers now log per-row to `UtilsLog::log` — symmetric with the bulk-action path. Helps debug failed protocol imports. - **Fixed:** `Import_Manager::emit_import_summary()` deduplicates repeated error strings. A CSV with 100 malformed rows shows "100 errors occurred: Invalid row format in CSV." once, not 100 times concatenated. - **Changed:** `UtilsLog::render_notices()` switched from `wp_kses_post()` to `esc_html()` — current notice messages are plain text, the tighter escape narrows the trust boundary at the API edge. - **Removed:** `Order_Status_Manager::set_order_processing_status()` and its `woocommerce_payment_complete` hook. On modern WC the handler was effectively dead code — `WC_Order::payment_complete()` sets the order to `processing` (or `completed` for downloadable-only) *before* firing the action, so our handler was either a no-op same-state update or a deliberate skip. The corresponding translation string was removed too. - **Doc:** README "Known limitations" gains two entries (Excel CS-locale CSV separator, trashed/draft products in historical orders). CLAUDE.md picks up the 1.4.3 changes. - **Doc:** [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) updated with per-item status annotations. > **Deferred** (carried over from earlier reviews): live HPOS search-filter verification, block-based order edit screen, `Network:` plugin header, `uninstall.php`, custom-order-number plugin integration. ### 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): - **Fixed (critical):** Export SQL is now compatible with MySQL's `ONLY_FULL_GROUP_BY` mode (default in MySQL 5.7+ / MariaDB 10.3+). Non-aggregated columns (`prod_name`, `prod_var`, `prod_var_type`, `prod_img_url`, `qty`, `email`) are wrapped in `MIN()`; each `(product_id, variation_id)` group has exactly one value per column so `MIN()` is value-preserving. - **Fixed (critical):** `parse_csv()` now strips a leading UTF-8 BOM. Re-importing a previously exported CSV (which ships with a BOM for Excel-on-Windows compatibility) no longer breaks header parsing. - **Fixed (high):** Datetime fields in the Print Information panel now reformat values as pure strings (`YYYY-MM-DD HH:MM:SS` ↔ `YYYY-MM-DDTHH:MM`) instead of running them through `strtotime()`/`date()`. The previous timezone-shift bug (off by 1–2 hours on UTC servers with non-UTC WP timezones) is gone. - **Fixed:** `set_order_processing_status()` now records a single order note via `update_status()` instead of also calling `add_order_note()`. The order activity feed no longer duplicates the "payment received" line. - **Fixed:** Bulk-action notices are suppressed when zero orders were actually moved — the more informative "skipped because terminal status" warning from the DB manager stands alone. - **Fixed:** `output_csv()` now aborts with `wp_die()` (and logs the offender) when `headers_sent()` is true, instead of streaming raw CSV bytes into a half-rendered HTML page. - **Fixed:** `fputcsv`/`fgetcsv` now pass an explicit empty-string escape character — no more PHP 8.4 deprecation warnings. - **Fixed:** `Order_Fields_Manager::save_custom_order_fields()` now checks `current_user_can('edit_shop_order' / 'edit_shop_orders')` before writing. Defense-in-depth on top of WC's own validation. - **Fixed:** `Studiou_DB_Manager::get_orders_for_printing()` raises `group_concat_max_len` to 64 KB before the export query, preventing silent `prod_cat` truncation for products in many categories. - **Fixed:** `dedupe_by_order_no()` is now case-insensitive — `ord-12345` and `ORD-12345` collapse to a single row. - **Fixed:** `normalise_csv_date()` returns an empty string (and logs) on parse failure instead of persisting the unparseable raw value. - **Changed:** `Import_Manager::run_import()` is driven by a single `protocol_dispatch()` map (file field, nonce, DB handler per protocol). Every non-happy-path branch returns explicitly via `redirect_back()` for static-analysis friendliness. - **Changed:** `Order_Fields_Manager` rendering and saving are driven from the `datetime_fields()` / `text_fields()` maps. Adding a new field is a single-map-entry change. - **Changed:** Notice transient TTL raised from 60 s to 300 s so admin notices survive slow redirects (proxies, HTTPS handshake latency). - **Doc:** [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) updated with per-item status annotations. > **Deliberately deferred** (carried over from the 1.4.0 review): live verification of the HPOS search filter name (`woocommerce_order_table_search_query_meta_keys`), `Network:` plugin header for multisite, `uninstall.php` cleanup, and behaviour on the WC 10.x block-based order edit screen. None of these were addressed by 1.4.1. ### 1.4.0 — 2026-05-12 - **Tested with:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled). Resolution of every finding catalogued in [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md). Highlights: - **Fixed:** Search by External Order Number is now functional. The meta-key mismatch is resolved (`external_ref_ord_no` consistently, no `_` prefix), and the integration uses WooCommerce's own search filters (`woocommerce_shop_order_search_fields` for legacy CPT and `woocommerce_order_table_search_query_meta_keys` for HPOS). No separate UI input — typing the external order number into the standard Search orders box finds the order. - **Fixed:** `Order_Status_Manager::handle_status_transitions()` now writes timestamps via `wc_get_order()->update_meta_data()` instead of `update_post_meta()`. Print-date stamps are no longer silently lost under HPOS for non-bulk status transitions. - **Fixed:** CSV imports now deduplicate rows by `order_no` (per the original spec) and accept both the spec's title-case headers and the lowercase form documented in the README. - **Fixed:** "Prepare to Printing Export" is now atomic — the CSV is generated in memory before any status change. Order status is advanced after a successful CSV build. If the export query returns no rows the user is notified. - **Fixed:** Export CSV is now UTF-8 with BOM (`charset=utf-8` header + leading `\xEF\xBB\xBF`). Czech diacritics render correctly in Excel on Windows. - **Fixed:** Export SQL no longer multiplies rows when a product belongs to multiple `product_cat` terms — categories are concatenated via `GROUP_CONCAT(DISTINCT …)` inside a `GROUP BY` per line item. - **Fixed:** Export SQL now goes through `$wpdb->prepare()` with `%d` placeholders rather than relying on `intval()` for safety. - **Fixed:** Late `woocommerce_payment_complete` events no longer demote orders already in `to-print` or `in-print` back to `processing`. The guard extends to all print statuses plus `completed`. - **Fixed:** Bulk actions guard with `current_user_can('edit_shop_orders')`. Imports keep their existing `manage_woocommerce` guard. - **Fixed:** Bulk actions and protocol imports skip orders in terminal statuses (`completed`, `cancelled`, `refunded`, `failed`) instead of silently reactivating them. - **Fixed:** `UtilsLog::log()` guard is now `defined('WP_DEBUG') && WP_DEBUG` (previously `true === WP_DEBUG`, which failed when `WP_DEBUG` was defined as integer `1`). - **Fixed:** `UtilsLog::message()` is now implemented as a per-user transient that renders via the `admin_notices` hook. Bulk actions and imports now produce visible user feedback after the redirect. - **Fixed:** `parse_csv()` no longer truncates rows at 1000 bytes (passes `0` to `fgetcsv`), validates that the header row is non-empty, normalises header casing, and skips rows whose column count diverges from the header. - **Fixed:** Import handlers verify `$_FILES[...]['error']` and `is_uploaded_file()` server-side instead of relying solely on the client-side `accept=".csv"` attribute. - **Fixed:** `external_ref_ord_date` is parsed via `strtotime()` and stored in MySQL datetime format; if parsing fails the raw value is retained. - **Added:** `external_ref_ord_date` is now rendered in the Print Information panel on the order edit screen. - **Added:** `STUDIOU_WC_OPS_VERSION` and `STUDIOU_WC_OPS_FILE` constants for code-side version references. - **Changed:** Translation loader and plugin bootstrap now have explicit `plugins_loaded` priorities (5 and 10) so translations are guaranteed to load before any manager instantiates. - **Removed:** `// visualized` leftover comments and the dead-code commented body of `UtilsLog::message()`. - **Docs:** README architecture/install/usage updated, `CLAUDE.md` updated, `docs/translations.txt` extended with the strings that were missing. > **Translations:** the `.po` file ships new strings; the `.mo` file must be rebuilt (e.g. via `wp i18n make-mo languages/` or Poedit) before the new Czech wording is visible. This release does not regenerate the binary. ### 1.3.2 — 2025-10-18 - **Fixed:** `prod_var_type` column was empty in `prepare_to_printing_export.csv`. Variation attribute is now resolved through `postmeta.attribute_pa_format` joined against `wp_terms.name`. - **Fixed:** HPOS incompatibility warning. Plugin now explicitly declares `custom_order_tables` compatibility. - **Tested:** WordPress 6.8.3, WooCommerce 10.2.2 with HPOS enabled. ### 1.3.1 - Previous stable release. --- ## Support For bug reports, feature requests or commercial support, contact **[quadarax.com](https://www.quadarax.com)**. --- ## License Released under the **GNU General Public License v2.0 or later** — see [https://www.gnu.org/licenses/gpl-2.0.html](https://www.gnu.org/licenses/gpl-2.0.html).