class-db-manager.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  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. * Multiple product categories are concatenated into prod_cat instead of
  17. * multiplying the row.
  18. *
  19. * @param int[] $order_ids
  20. * @return array
  21. */
  22. public static function get_orders_for_printing($order_ids) {
  23. global $wpdb;
  24. $order_ids = array_filter(array_map('intval', (array) $order_ids));
  25. if (empty($order_ids)) {
  26. return array();
  27. }
  28. $placeholders = implode(',', array_fill(0, count($order_ids), '%d'));
  29. // Raise the GROUP_CONCAT byte cap (default 1024) so multi-category
  30. // products with long names are not silently truncated.
  31. $wpdb->query("SET SESSION group_concat_max_len = 65535");
  32. // Non-aggregated SELECT columns are wrapped in MIN() to remain
  33. // compliant with MySQL's ONLY_FULL_GROUP_BY mode (default in 5.7+).
  34. // Each (product_id, variation_id) maps 1:1 to title/qty/email/image,
  35. // so MIN() returns the same value a free pick would.
  36. $sql = "
  37. SELECT
  38. `orders`.`id` AS `order_no`,
  39. GROUP_CONCAT(DISTINCT `t`.`name` ORDER BY `t`.`name` SEPARATOR ', ') AS `prod_cat`,
  40. MIN(`products`.`post_title`) AS `prod_name`,
  41. MIN(`product_variations`.`post_title`) AS `prod_var`,
  42. MIN(`product_variations_name`.`name`) AS `prod_var_type`,
  43. MIN(`images`.`guid`) AS `prod_img_url`,
  44. MIN(`order_products`.`product_qty`) AS `qty`,
  45. MIN(`orders`.`billing_email`) AS `email`
  46. FROM `{$wpdb->prefix}wc_orders` `orders`
  47. JOIN `{$wpdb->prefix}wc_order_product_lookup` `order_products`
  48. ON `orders`.`id` = `order_products`.`order_id`
  49. JOIN `{$wpdb->posts}` `products`
  50. ON `products`.`ID` = `order_products`.`product_id`
  51. JOIN `{$wpdb->posts}` `product_variations`
  52. ON `product_variations`.`ID` = `order_products`.`variation_id`
  53. LEFT JOIN `{$wpdb->postmeta}` `product_variations_attr`
  54. ON `product_variations_attr`.`post_id` = `product_variations`.`ID`
  55. AND `product_variations_attr`.`meta_key` = 'attribute_pa_format'
  56. LEFT JOIN `{$wpdb->terms}` `product_variations_name`
  57. ON `product_variations_name`.`name` = `product_variations_attr`.`meta_value`
  58. LEFT JOIN `{$wpdb->postmeta}` `product_meta`
  59. ON `product_meta`.`post_id` = `products`.`ID`
  60. AND `product_meta`.`meta_key` = '_thumbnail_id'
  61. LEFT JOIN `{$wpdb->posts}` `images`
  62. ON `images`.`ID` = `product_meta`.`meta_value`
  63. AND `images`.`post_type` = 'attachment'
  64. LEFT JOIN `{$wpdb->term_relationships}` `tr`
  65. ON `tr`.`object_id` = `products`.`ID`
  66. LEFT JOIN `{$wpdb->term_taxonomy}` `tt`
  67. ON `tt`.`term_taxonomy_id` = `tr`.`term_taxonomy_id`
  68. AND `tt`.`taxonomy` = 'product_cat'
  69. LEFT JOIN `{$wpdb->terms}` `t`
  70. ON `t`.`term_id` = `tt`.`term_id`
  71. WHERE `orders`.`id` IN ($placeholders)
  72. GROUP BY
  73. `orders`.`id`,
  74. `order_products`.`product_id`,
  75. `order_products`.`variation_id`
  76. ORDER BY `orders`.`id`
  77. ";
  78. $prepared = $wpdb->prepare($sql, $order_ids);
  79. $results = $wpdb->get_results($prepared);
  80. if ($results === false) {
  81. UtilsLog::log('SQL error in get_orders_for_printing: ' . $wpdb->last_error);
  82. return array();
  83. }
  84. return $results;
  85. }
  86. /**
  87. * Bulk-set orders to "to-print", skipping orders in terminal states.
  88. *
  89. * @param int[] $order_ids
  90. * @return int Number of orders actually updated.
  91. */
  92. public static function set_orders_to_prepare_printing($order_ids) {
  93. return self::bulk_set_print_status($order_ids, 'to-print', 'to_print_date');
  94. }
  95. /**
  96. * Bulk-set orders to "in-print", skipping orders in terminal states.
  97. *
  98. * @param int[] $order_ids
  99. * @return int Number of orders actually updated.
  100. */
  101. public static function set_orders_to_in_printing($order_ids) {
  102. return self::bulk_set_print_status($order_ids, 'in-print', 'in_print_date');
  103. }
  104. private static function bulk_set_print_status($order_ids, $status_slug, $meta_key) {
  105. $updated_count = 0;
  106. $skipped = 0;
  107. foreach ((array) $order_ids as $order_id) {
  108. $order_id = (int) $order_id;
  109. $order = wc_get_order($order_id);
  110. if (!$order) {
  111. UtilsLog::log('Order #' . $order_id . ' not found');
  112. continue;
  113. }
  114. if ($order->has_status(self::TERMINAL_STATUSES)) {
  115. UtilsLog::log('Order #' . $order_id . ' skipped (terminal status: ' . $order->get_status() . ')');
  116. $skipped++;
  117. continue;
  118. }
  119. $order->update_status($status_slug);
  120. $order->update_meta_data($meta_key, current_time('mysql'));
  121. $order->save();
  122. $updated_count++;
  123. UtilsLog::log('Order #' . $order_id . ' marked as "' . $status_slug . '"');
  124. }
  125. if ($skipped > 0) {
  126. UtilsLog::message(
  127. sprintf(
  128. _n(
  129. '%d order skipped because its status is final (cancelled, refunded, failed, or completed).',
  130. '%d orders skipped because their status is final (cancelled, refunded, failed, or completed).',
  131. $skipped,
  132. 'studiou-wc-ord-print-statuses'
  133. ),
  134. $skipped
  135. ),
  136. 'warning'
  137. );
  138. }
  139. return $updated_count;
  140. }
  141. /**
  142. * Import an InPrint protocol CSV.
  143. *
  144. * @param array $csv_data Rows produced by parse_csv().
  145. * @return array{updated_count:int,errors:string[]}
  146. */
  147. public static function import_inprint_protocol($csv_data) {
  148. $updated_count = 0;
  149. $errors = array();
  150. foreach (self::dedupe_by_order_no($csv_data) as $row) {
  151. if (!isset($row['order_no']) || !isset($row['externalorder']) || !isset($row['externalorderdate'])) {
  152. $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
  153. continue;
  154. }
  155. $order = wc_get_order($row['order_no']);
  156. if (!$order) {
  157. $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
  158. continue;
  159. }
  160. if ($order->has_status(self::TERMINAL_STATUSES)) {
  161. $errors[] = sprintf(
  162. __('Order %s is in a final state and was not updated.', 'studiou-wc-ord-print-statuses'),
  163. $row['order_no']
  164. );
  165. continue;
  166. }
  167. $order->update_status('in-print');
  168. $order->update_meta_data('in_print_date', current_time('mysql'));
  169. $order->update_meta_data('external_ref_ord_no', sanitize_text_field($row['externalorder']));
  170. $order->update_meta_data('external_ref_ord_date', self::normalise_csv_date($row['externalorderdate']));
  171. $order->save();
  172. $updated_count++;
  173. }
  174. return array('updated_count' => $updated_count, 'errors' => $errors);
  175. }
  176. /**
  177. * Import a Delivered 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_delivered_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'])) {
  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. $order->update_status('completed');
  196. $order->update_meta_data('external_ref_ord_delivered', current_time('mysql'));
  197. $order->save();
  198. $updated_count++;
  199. }
  200. return array('updated_count' => $updated_count, 'errors' => $errors);
  201. }
  202. /**
  203. * Parse a CSV file into an array of associative rows.
  204. * Headers are normalised (lowercased, whitespace stripped) so files prepared
  205. * to either the legacy spec casing ("Order No") or the documented lowercase
  206. * form ("order_no") both work.
  207. *
  208. * @param string $file_path
  209. * @return array Empty array on failure.
  210. */
  211. public static function parse_csv($file_path) {
  212. $csv_data = array();
  213. $handle = @fopen($file_path, 'r');
  214. if ($handle === false) {
  215. UtilsLog::log('parse_csv: failed to open ' . $file_path);
  216. return $csv_data;
  217. }
  218. try {
  219. // Strip a leading UTF-8 BOM if present so the first header parses
  220. // cleanly. The plugin's own exports include a BOM (for Excel),
  221. // and Notepad-saved CSVs frequently include one.
  222. $first_bytes = fread($handle, 3);
  223. if ($first_bytes !== "\xEF\xBB\xBF") {
  224. rewind($handle);
  225. }
  226. // Explicit empty-string escape suppresses the PHP 8.4 deprecation
  227. // notice for the implicit '\\' default.
  228. $headers = fgetcsv($handle, 0, ',', '"', '');
  229. if (!is_array($headers) || empty(array_filter($headers))) {
  230. UtilsLog::log('parse_csv: header row missing or empty');
  231. return $csv_data;
  232. }
  233. $headers = array_map(array(__CLASS__, 'normalise_header'), $headers);
  234. while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
  235. if (count($data) !== count($headers)) {
  236. continue;
  237. }
  238. $csv_data[] = array_combine($headers, $data);
  239. }
  240. } finally {
  241. fclose($handle);
  242. }
  243. return $csv_data;
  244. }
  245. private static function normalise_header($header) {
  246. $header = strtolower(trim((string) $header));
  247. // Strip any UTF-8 BOM that survived the file-level guard.
  248. $header = preg_replace('/^\xEF\xBB\xBF/', '', $header);
  249. // Map "order no" → "order_no" by collapsing internal whitespace to underscore.
  250. $header = preg_replace('/\s+/', '_', $header);
  251. return $header;
  252. }
  253. private static function dedupe_by_order_no($rows) {
  254. $seen = array();
  255. $unique = array();
  256. foreach ($rows as $row) {
  257. $raw_key = isset($row['order_no']) ? trim((string) $row['order_no']) : '';
  258. if ($raw_key === '') {
  259. continue;
  260. }
  261. // Case-insensitive dedup so alphabetic order numbers
  262. // ("ord-12345" vs "ORD-12345") collapse to a single row.
  263. $key = strtolower($raw_key);
  264. if (isset($seen[$key])) {
  265. continue;
  266. }
  267. $seen[$key] = true;
  268. $unique[] = $row;
  269. }
  270. return $unique;
  271. }
  272. /**
  273. * Normalise a free-form date string into a MySQL datetime.
  274. * Returns '' (and logs) on parse failure so the UI does not silently
  275. * carry unparseable raw values through the round-trip.
  276. */
  277. private static function normalise_csv_date($raw) {
  278. $raw = sanitize_text_field((string) $raw);
  279. if ($raw === '') {
  280. return '';
  281. }
  282. $timestamp = strtotime($raw);
  283. if (!$timestamp) {
  284. UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
  285. return '';
  286. }
  287. return date('Y-m-d H:i:s', $timestamp);
  288. }
  289. }