class-db-manager.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. <?php
  2. /**
  3. * Database operations and CSV-protocol processing for the
  4. * Studiou WC Order Print Statuses plugin.
  5. */
  6. defined('ABSPATH') || exit;
  7. class Studiou_DB_Manager {
  8. /**
  9. * Order statuses that block automated print-pipeline transitions.
  10. * Cancelled / refunded orders should not be silently reactivated by a bulk
  11. * action; completed orders are already finished.
  12. */
  13. const TERMINAL_STATUSES = array('cancelled', 'refunded', 'failed', 'completed');
  14. /**
  15. * Return one row per (order × line-item) for the print-shop export.
  16. *
  17. * Design notes:
  18. * - Multiple product categories are concatenated into prod_cat instead of
  19. * multiplying the row.
  20. * - Simple (non-variable) products are included via LEFT JOIN; their
  21. * prod_var / prod_var_type fields will be NULL.
  22. * - The image URL is resolved in PHP via wp_get_attachment_url() rather
  23. * than reading wp_posts.guid, which the codex warns is not guaranteed
  24. * to be a URL (breaks under offloaded media / multisite uploads paths).
  25. * - The variation attribute is joined on terms.slug — WC stores the slug
  26. * in `attribute_pa_format` postmeta, not the term name.
  27. * - qty is summed across duplicate line items (a customer adding the
  28. * same variation twice without merging produces two rows in the
  29. * lookup table; MIN() would silently undercount).
  30. *
  31. * @param int[] $order_ids
  32. * @return array Each element is an stdClass with fields:
  33. * order_no, prod_cat, prod_name, prod_var, prod_var_type,
  34. * prod_img_url, qty, email.
  35. */
  36. public static function get_orders_for_printing($order_ids) {
  37. global $wpdb;
  38. $order_ids = array_filter(array_map('intval', (array) $order_ids));
  39. if (empty($order_ids)) {
  40. return array();
  41. }
  42. $lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
  43. if (!self::table_exists($lookup_table)) {
  44. UtilsLog::log("get_orders_for_printing: required table {$lookup_table} is missing. Is WC analytics enabled?");
  45. UtilsLog::message(
  46. __('Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support.', 'studiou-wc-ord-print-statuses'),
  47. 'error'
  48. );
  49. return array();
  50. }
  51. $placeholders = implode(',', array_fill(0, count($order_ids), '%d'));
  52. // Raise the GROUP_CONCAT byte cap (default 1024) so multi-category
  53. // products with long names are not silently truncated. Use GREATEST
  54. // so we never *lower* a setting another piece of code may rely on.
  55. $wpdb->query("SET SESSION group_concat_max_len = GREATEST(@@SESSION.group_concat_max_len, 65535)");
  56. // Non-aggregated SELECT columns are wrapped in MIN()/SUM() to remain
  57. // compliant with MySQL's ONLY_FULL_GROUP_BY mode (default in 5.7+).
  58. // qty uses SUM() because a single (order, product, variation) group
  59. // may contain multiple lookup rows when a customer added the same
  60. // variation twice without cart-merge.
  61. $sql = "
  62. SELECT
  63. `orders`.`id` AS `order_no`,
  64. GROUP_CONCAT(DISTINCT `t`.`name` ORDER BY `t`.`name` SEPARATOR ', ') AS `prod_cat`,
  65. MIN(`products`.`post_title`) AS `prod_name`,
  66. MIN(`product_variations`.`post_title`) AS `prod_var`,
  67. MIN(`product_variations_name`.`name`) AS `prod_var_type`,
  68. MIN(`product_meta`.`meta_value`) AS `prod_img_id`,
  69. SUM(`order_products`.`product_qty`) AS `qty`,
  70. MIN(`orders`.`billing_email`) AS `email`
  71. FROM `{$wpdb->prefix}wc_orders` `orders`
  72. JOIN `{$wpdb->prefix}wc_order_product_lookup` `order_products`
  73. ON `orders`.`id` = `order_products`.`order_id`
  74. JOIN `{$wpdb->posts}` `products`
  75. ON `products`.`ID` = `order_products`.`product_id`
  76. LEFT JOIN `{$wpdb->posts}` `product_variations`
  77. ON `product_variations`.`ID` = `order_products`.`variation_id`
  78. LEFT JOIN `{$wpdb->postmeta}` `product_variations_attr`
  79. ON `product_variations_attr`.`post_id` = `product_variations`.`ID`
  80. AND `product_variations_attr`.`meta_key` = 'attribute_pa_format'
  81. LEFT JOIN `{$wpdb->terms}` `product_variations_name`
  82. ON `product_variations_name`.`slug` = `product_variations_attr`.`meta_value`
  83. LEFT JOIN `{$wpdb->postmeta}` `product_meta`
  84. ON `product_meta`.`post_id` = `products`.`ID`
  85. AND `product_meta`.`meta_key` = '_thumbnail_id'
  86. LEFT JOIN `{$wpdb->term_relationships}` `tr`
  87. ON `tr`.`object_id` = `products`.`ID`
  88. LEFT JOIN `{$wpdb->term_taxonomy}` `tt`
  89. ON `tt`.`term_taxonomy_id` = `tr`.`term_taxonomy_id`
  90. AND `tt`.`taxonomy` = 'product_cat'
  91. LEFT JOIN `{$wpdb->terms}` `t`
  92. ON `t`.`term_id` = `tt`.`term_id`
  93. WHERE `orders`.`id` IN ($placeholders)
  94. GROUP BY
  95. `orders`.`id`,
  96. `order_products`.`product_id`,
  97. `order_products`.`variation_id`
  98. ORDER BY `orders`.`id`
  99. ";
  100. $prepared = $wpdb->prepare($sql, $order_ids);
  101. $results = $wpdb->get_results($prepared);
  102. if ($results === false) {
  103. UtilsLog::log('SQL error in get_orders_for_printing: ' . $wpdb->last_error);
  104. return array();
  105. }
  106. // Resolve the attachment ID to a URL via WP's API — respects
  107. // offloaded-media plugins, multisite upload paths, and the
  108. // wp_get_attachment_url filter chain.
  109. foreach ($results as $row) {
  110. $attachment_id = isset($row->prod_img_id) ? (int) $row->prod_img_id : 0;
  111. $row->prod_img_url = $attachment_id ? (string) wp_get_attachment_url($attachment_id) : '';
  112. unset($row->prod_img_id);
  113. }
  114. return $results;
  115. }
  116. private static function table_exists($table) {
  117. global $wpdb;
  118. $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
  119. return $found === $table;
  120. }
  121. /**
  122. * Bulk-set orders to "to-print", skipping orders in terminal states.
  123. *
  124. * @param int[] $order_ids
  125. * @return int Number of orders actually updated.
  126. */
  127. public static function set_orders_to_prepare_printing($order_ids) {
  128. return self::bulk_set_print_status($order_ids, 'to-print', 'to_print_date');
  129. }
  130. /**
  131. * Bulk-set orders to "in-print", skipping orders in terminal states.
  132. *
  133. * @param int[] $order_ids
  134. * @return int Number of orders actually updated.
  135. */
  136. public static function set_orders_to_in_printing($order_ids) {
  137. return self::bulk_set_print_status($order_ids, 'in-print', 'in_print_date');
  138. }
  139. private static function bulk_set_print_status($order_ids, $status_slug, $meta_key) {
  140. $updated_count = 0;
  141. $skipped = 0;
  142. foreach ((array) $order_ids as $order_id) {
  143. $order_id = (int) $order_id;
  144. $order = wc_get_order($order_id);
  145. if (!$order) {
  146. UtilsLog::log('Order #' . $order_id . ' not found');
  147. continue;
  148. }
  149. if ($order->has_status(self::TERMINAL_STATUSES)) {
  150. UtilsLog::log('Order #' . $order_id . ' skipped (terminal status: ' . $order->get_status() . ')');
  151. $skipped++;
  152. continue;
  153. }
  154. $order->update_status($status_slug);
  155. $order->update_meta_data($meta_key, current_time('mysql'));
  156. $order->save();
  157. $updated_count++;
  158. UtilsLog::log('Order #' . $order_id . ' marked as "' . $status_slug . '"');
  159. }
  160. if ($skipped > 0) {
  161. UtilsLog::message(
  162. sprintf(
  163. _n(
  164. '%d order skipped because its status is final (cancelled, refunded, failed, or completed).',
  165. '%d orders skipped because their status is final (cancelled, refunded, failed, or completed).',
  166. $skipped,
  167. 'studiou-wc-ord-print-statuses'
  168. ),
  169. $skipped
  170. ),
  171. 'warning'
  172. );
  173. }
  174. return $updated_count;
  175. }
  176. /**
  177. * Import an InPrint protocol CSV.
  178. *
  179. * @param array $csv_data Rows produced by parse_csv().
  180. * @return array{updated_count:int,errors:string[]}
  181. */
  182. public static function import_inprint_protocol($csv_data) {
  183. $updated_count = 0;
  184. $errors = array();
  185. foreach (self::dedupe_by_order_no($csv_data) as $row) {
  186. if (!isset($row['order_no']) || !isset($row['externalorder']) || !isset($row['externalorderdate'])) {
  187. $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
  188. continue;
  189. }
  190. $order = wc_get_order($row['order_no']);
  191. if (!$order) {
  192. $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
  193. continue;
  194. }
  195. if ($order->has_status(self::TERMINAL_STATUSES)) {
  196. $errors[] = sprintf(
  197. __('Order %s is in a final state and was not updated.', 'studiou-wc-ord-print-statuses'),
  198. $row['order_no']
  199. );
  200. continue;
  201. }
  202. $order->update_status('in-print');
  203. $order->update_meta_data('in_print_date', current_time('mysql'));
  204. $order->update_meta_data('external_ref_ord_no', sanitize_text_field($row['externalorder']));
  205. $order->update_meta_data('external_ref_ord_date', self::normalise_csv_date($row['externalorderdate']));
  206. $order->save();
  207. $updated_count++;
  208. }
  209. return array('updated_count' => $updated_count, 'errors' => $errors);
  210. }
  211. /**
  212. * Import a Delivered protocol CSV.
  213. *
  214. * @param array $csv_data Rows produced by parse_csv().
  215. * @return array{updated_count:int,errors:string[]}
  216. */
  217. public static function import_delivered_protocol($csv_data) {
  218. $updated_count = 0;
  219. $errors = array();
  220. foreach (self::dedupe_by_order_no($csv_data) as $row) {
  221. if (!isset($row['order_no'])) {
  222. $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
  223. continue;
  224. }
  225. $order = wc_get_order($row['order_no']);
  226. if (!$order) {
  227. $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
  228. continue;
  229. }
  230. $order->update_status('completed');
  231. $order->update_meta_data('external_ref_ord_delivered', current_time('mysql'));
  232. $order->save();
  233. $updated_count++;
  234. }
  235. return array('updated_count' => $updated_count, 'errors' => $errors);
  236. }
  237. /**
  238. * Parse a CSV file into an array of associative rows.
  239. * Headers are normalised (lowercased, whitespace stripped) so files prepared
  240. * to either the legacy spec casing ("Order No") or the documented lowercase
  241. * form ("order_no") both work. A leading UTF-8 BOM is stripped.
  242. *
  243. * @param string $file_path
  244. * @return array Empty array on failure.
  245. */
  246. public static function parse_csv($file_path) {
  247. $csv_data = array();
  248. $handle = fopen($file_path, 'r');
  249. if ($handle === false) {
  250. UtilsLog::log('parse_csv: failed to open ' . $file_path);
  251. return $csv_data;
  252. }
  253. try {
  254. // Strip a leading UTF-8 BOM if present so the first header parses
  255. // cleanly. The plugin's own exports include a BOM (for Excel),
  256. // and Notepad-saved CSVs frequently include one.
  257. $first_bytes = fread($handle, 3);
  258. if ($first_bytes !== "\xEF\xBB\xBF") {
  259. rewind($handle);
  260. }
  261. // Explicit empty-string escape suppresses the PHP 8.4 deprecation
  262. // notice for the implicit '\\' default.
  263. $headers = fgetcsv($handle, 0, ',', '"', '');
  264. if (!is_array($headers) || empty(array_filter($headers))) {
  265. UtilsLog::log('parse_csv: header row missing or empty');
  266. return $csv_data;
  267. }
  268. $headers = array_map(array(__CLASS__, 'normalise_header'), $headers);
  269. while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
  270. if (count($data) !== count($headers)) {
  271. continue;
  272. }
  273. $csv_data[] = array_combine($headers, $data);
  274. }
  275. } finally {
  276. fclose($handle);
  277. }
  278. return $csv_data;
  279. }
  280. private static function normalise_header($header) {
  281. $header = strtolower(trim((string) $header));
  282. // Strip any UTF-8 BOM that survived the file-level guard.
  283. $header = preg_replace('/^\xEF\xBB\xBF/', '', $header);
  284. // Map "order no" → "order_no" by collapsing internal whitespace to underscore.
  285. $header = preg_replace('/\s+/', '_', $header);
  286. return $header;
  287. }
  288. private static function dedupe_by_order_no($rows) {
  289. $seen = array();
  290. $unique = array();
  291. foreach ($rows as $row) {
  292. $raw_key = isset($row['order_no']) ? trim((string) $row['order_no']) : '';
  293. if ($raw_key === '') {
  294. continue;
  295. }
  296. // Case-insensitive dedup so alphabetic order numbers
  297. // ("ord-12345" vs "ORD-12345") collapse to a single row.
  298. $key = strtolower($raw_key);
  299. if (isset($seen[$key])) {
  300. continue;
  301. }
  302. $seen[$key] = true;
  303. $unique[] = $row;
  304. }
  305. return $unique;
  306. }
  307. /**
  308. * Normalise a free-form date string into a MySQL datetime in WP local time.
  309. * Returns '' (and logs) on parse failure so the UI does not silently
  310. * carry unparseable raw values through the round-trip.
  311. *
  312. * Uses wp_date() so the resulting string is consistent with
  313. * current_time('mysql') — both reflect WP local time, not server PHP
  314. * timezone. Previously this used date(), which interpreted the timestamp
  315. * in server timezone and produced an asymmetry between import-side
  316. * external_ref_ord_date and every other meta key.
  317. */
  318. private static function normalise_csv_date($raw) {
  319. $raw = sanitize_text_field((string) $raw);
  320. if ($raw === '') {
  321. return '';
  322. }
  323. $timestamp = strtotime($raw);
  324. if (!$timestamp) {
  325. UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
  326. return '';
  327. }
  328. return wp_date('Y-m-d H:i:s', $timestamp);
  329. }
  330. }