PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.14
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.14
1.4.14 1.4.13 1.4.12 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.54 1.3.53 1.3.52 1.3.51 1.3.50 1.3.49 1.3.48 1.3.47 1.3.46 1.3.45 All 177 releases
disco / app / Analytics / Queries / OrderQuery.php

OrderQuery.php in Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO 1.4.14, at app/Analytics/Queries/OrderQuery.php

468 lines 18.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php //phpcs:disable
2
3 /**
4 * OrderQuery — focused queries for order data.
5 *
6 * @package Disco
7 * @subpackage Disco\App\Analytics\Queries
8 * @since 1.3.37
9 */
10
11 namespace Disco\App\Analytics\Queries;
12
13 /**
14 * Handles all single-purpose SQL queries related to WooCommerce orders.
15 *
16 * Each method does exactly ONE job.
17 * No business logic — raw DB results only.
18 */
19 class OrderQuery extends BaseQuery {
20
21 // =========================================================================
22 // List / paginated queries (used by OrderService for REST list endpoints)
23 // =========================================================================
24
25 /**
26 * Returns a paginated list of campaign-linked orders.
27 *
28 * Builds its own WHERE clauses from $args.
29 * Max per_page = 20 per spec.
30 *
31 * @param array $args campaign_id, product_id, customer_id, date_from, date_to,
32 * orderby, order, page, per_page.
33 * @return array { total: int, pages: int, rows: array }
34 */
35 public function get_order_list( array $args ): array {
36 $context = $this->build_list_context( $args );
37 $pagination = $this->resolve_pagination( $args );
38 $sort = $this->resolve_order_sort( $args, $context );
39
40 $total = $this->run_count( $this->build_count_sql( $context ), $context['params'] );
41
42 if ( 0 === $total ) {
43 return $this->format_list_result( 0, $pagination['per_page'], array() );
44 }
45
46 $rows = $this->fetch_order_rows( $context, $sort, $pagination );
47 $rows = $this->resolve_order_amounts( $rows, $context['tables'] );
48 $rows = $this->resolve_order_customers( $rows, $context['tables'] );
49
50 return $this->format_list_result( $total, $pagination['per_page'], $rows );
51 }
52
53 // =========================================================================
54 // Single-order queries
55 // =========================================================================
56
57 /**
58 * Returns a single order header row (no line items).
59 *
60 * @param int $order_id WooCommerce order ID.
61 * @return object|null
62 */
63 public function get_order( int $order_id ) {
64 $tables = $this->get_tables();
65 $clauses = $this->get_order_clauses( $tables, 'o', 'o' );
66 $discount_join = "LEFT JOIN {$tables['order_meta']} discount_meta ON discount_meta.order_id = o.ID AND discount_meta.meta_key = '_discount_total'";
67
68 $sql = "SELECT
69 o.ID AS order_id,
70 {$clauses['date_col']} AS order_date,
71 {$clauses['status_col']} AS order_status,
72 {$clauses['customer_expr']} AS customer_id,
73 COALESCE(u.display_name, CONCAT(billing_address.first_name, ' ', billing_address.last_name)) AS customer_name,
74 COALESCE(u.user_email, billing_address.email) AS customer_email,
75 {$clauses['total_expr']} AS order_total,
76 COALESCE(discount_meta.meta_value, 0) AS discount_amount
77 FROM {$tables['orders']} o
78 {$discount_join}
79 LEFT JOIN {$tables['users']} u ON u.ID = {$clauses['customer_expr']}
80 LEFT JOIN {$tables['order_addresses']} billing_address ON billing_address.order_id = o.ID AND billing_address.address_type = 'billing'
81 WHERE o.ID = %d AND {$clauses['status_where']}";
82
83 return $this->run_row( $sql, array( $order_id ) );
84 }
85
86 /**
87 * Returns line items for a single order.
88 *
89 * @param int $order_id WooCommerce order ID.
90 */
91 public function get_line_items( int $order_id ): array {
92 $tables = $this->get_tables();
93
94 $sql = "SELECT
95 order_item.order_item_id AS item_id,
96 item_product_meta.meta_value AS product_id,
97 product.post_title AS product_name,
98 item_qty_meta.meta_value AS qty,
99 product_price_meta.meta_value AS unit_price,
100 item_line_total_meta.meta_value AS line_total,
101 item_subtotal_meta.meta_value AS line_subtotal
102 FROM {$tables['order_items']} order_item
103 JOIN {$tables['order_itemmeta']} item_product_meta ON item_product_meta.order_item_id = order_item.order_item_id AND item_product_meta.meta_key = '_product_id'
104 JOIN {$tables['order_itemmeta']} item_qty_meta ON item_qty_meta.order_item_id = order_item.order_item_id AND item_qty_meta.meta_key = '_qty'
105 JOIN {$tables['order_itemmeta']} item_line_total_meta ON item_line_total_meta.order_item_id = order_item.order_item_id AND item_line_total_meta.meta_key = '_line_total'
106 JOIN {$tables['order_itemmeta']} item_subtotal_meta ON item_subtotal_meta.order_item_id = order_item.order_item_id AND item_subtotal_meta.meta_key = '_line_subtotal'
107 LEFT JOIN {$tables['posts']} product ON product.ID = item_product_meta.meta_value
108 LEFT JOIN {$tables['postmeta']} product_price_meta ON product_price_meta.post_id = product.ID AND product_price_meta.meta_key = '_regular_price'
109 WHERE order_item.order_id = %d AND order_item.order_item_type = 'line_item'";
110
111 return $this->run_rows( $sql, array( $order_id ) );
112 }
113
114 /**
115 * Returns campaigns attached to a single order.
116 *
117 * @param int $order_id WooCommerce order ID.
118 */
119 public function get_campaigns_for_order( int $order_id ): array {
120 $tables = $this->get_tables();
121 $order_id_column = $tables['order_id_col'];
122
123 $dedup = $this->get_campaign_dedup_condition( $tables );
124
125 $sql = "SELECT
126 CAST(order_meta.meta_value AS UNSIGNED) AS campaign_id,
127 COALESCE(JSON_UNQUOTE(JSON_EXTRACT(campaign.data, '$.name')), 'Unknown') AS campaign_name,
128 COALESCE(campaign.intent, '') AS campaign_intent,
129 CASE WHEN campaign.id IS NULL THEN 1 ELSE 0 END AS is_deleted
130 FROM {$tables['order_meta']} order_meta
131 LEFT JOIN {$tables['campaigns']} campaign ON campaign.id = CAST(order_meta.meta_value AS UNSIGNED)
132 WHERE order_meta.{$order_id_column} = %d AND order_meta.meta_key = %s
133 AND {$dedup}";
134
135 $rows = $this->run_rows( $sql, array( $order_id, 'disco_campaign' ) );
136
137 return array_map(
138 function ( $row ) {
139 return array(
140 'campaign_id' => (int) $row->campaign_id,
141 'campaign_name' => $row->campaign_name,
142 'campaign_intent' => $row->campaign_intent,
143 'is_deleted' => (bool) $row->is_deleted,
144 );
145 },
146 $rows
147 );
148 }
149
150 /**
151 * Builds WHERE conditions, params, and filter JOINs for the order list.
152 *
153 * The product_id filter adds line-item JOINs; numeric search matches the
154 * order ID, text search matches customer name/email via an extra users JOIN.
155 *
156 * @param array $args campaign_id, product_id, customer_id, date_from, date_to, search.
157 * @return array { tables, clauses, id_col, where, params, joins }
158 */
159 private function build_list_context( array $args ): array {
160 global $wpdb;
161
162 $tables = $this->get_tables();
163 $clauses = $this->get_order_clauses( $tables );
164 $order_id_column = $tables['order_id_col'];
165 $common = $this->build_common_conditions( $args, $clauses, $tables );
166 $conditions = array_merge(
167 array( 'order_meta.meta_key = %s', $this->get_campaign_dedup_condition( $tables ) ),
168 $common['conditions']
169 );
170
171 // JOIN placeholders precede WHERE placeholders in the final SQL, so their
172 // params must be collected separately and merged join-params-first.
173 $where_params = array_merge( array( 'disco_campaign' ), $common['params'] );
174 $join_params = array();
175 $extra_joins = array();
176
177 if ( ! empty( $args['product_id'] ) ) {
178 $extra_joins[] = "JOIN {$tables['order_items']} filter_order_item ON filter_order_item.order_id = order_meta.{$order_id_column} AND filter_order_item.order_item_type = 'line_item'";
179 $extra_joins[] = "JOIN {$tables['order_itemmeta']} filter_product_meta ON filter_product_meta.order_item_id = filter_order_item.order_item_id AND filter_product_meta.meta_key = '_product_id' AND filter_product_meta.meta_value = %d";
180 $join_params[] = (int) $args['product_id'];
181 }
182
183 $search = $args['search'] ?? '';
184
185 if ( is_numeric( $search ) && '' !== $search ) {
186 $conditions[] = "order_meta.{$order_id_column} = %d";
187 $where_params[] = (int) $search;
188 } elseif ( ! empty( $search ) ) {
189 $extra_joins[] = "LEFT JOIN {$tables['users']} search_user ON search_user.ID = {$clauses['customer_expr']}";
190 $like = '%' . $wpdb->esc_like( $search ) . '%';
191 $conditions[] = '( LOWER(search_user.display_name) LIKE LOWER(%s) OR LOWER(search_user.user_email) LIKE LOWER(%s) )';
192 array_push( $where_params, $like, $like );
193 }
194
195 return array(
196 'tables' => $tables,
197 'clauses' => $clauses,
198 'id_col' => $order_id_column,
199 'where' => 'WHERE ' . implode( ' AND ', $conditions ),
200 'params' => array_merge( $join_params, $where_params ),
201 'joins' => implode( ' ', $extra_joins ),
202 );
203 }
204
205 /**
206 * Resolves the SQL sort column expression, direction, and the JOINs the
207 * sort expression needs.
208 *
209 * Whitelist: order_date, order_total, discount_amount, quantity. Totals/discounts
210 * are cast to DECIMAL so sorting is numeric, not lexicographic. Quantity sums the
211 * order's line-item quantities from wc_order_product_lookup. The order_stats,
212 * discount-meta, and product-lookup JOINs are emitted only when the chosen sort
213 * actually references them — display amounts are resolved separately in PHP.
214 *
215 * The product-lookup JOIN is safe against SUM inflation because the WHERE clause
216 * already restricts order_meta to a single disco_campaign row per order via
217 * get_campaign_dedup_condition().
218 *
219 * @param array $args orderby, order.
220 * @param array $context From build_list_context().
221 * @return array { column: string, direction: 'ASC'|'DESC', joins: string }
222 */
223 private function resolve_order_sort( array $args, array $context ): array {
224 global $wpdb;
225
226 $tables = $context['tables'];
227 $clauses = $context['clauses'];
228 $id_col = $context['id_col'];
229 $sort = $this->resolve_sort( $args, array( 'order_date', 'order_total', 'discount_amount', 'quantity' ), 'order_date' );
230
231 $orderby_column_map = array(
232 'order_date' => $clauses['date_col'],
233 'order_total' => "CAST(({$clauses['total_expr']} - COALESCE(order_stats.shipping_total, 0)) AS DECIMAL(10,2))",
234 'discount_amount' => "CAST({$clauses['discount_expr']} AS DECIMAL(10,2))",
235 'quantity' => 'COALESCE(SUM(sort_qty_lookup.product_qty), 0)',
236 );
237
238 $sort_join_map = array(
239 'order_date' => '',
240 'order_total' => "LEFT JOIN {$wpdb->prefix}wc_order_stats order_stats ON order_stats.order_id = order_meta.{$id_col}",
241 'discount_amount' => $clauses['discount_join'],
242 'quantity' => "LEFT JOIN {$tables['product_lookup']} sort_qty_lookup ON sort_qty_lookup.order_id = order_meta.{$id_col}",
243 );
244
245 return array(
246 'column' => $orderby_column_map[ $sort['orderby'] ],
247 'direction' => $sort['direction'],
248 'joins' => $sort_join_map[ $sort['orderby'] ],
249 );
250 }
251
252 /**
253 * Builds the COUNT(DISTINCT order) SQL for the current filters.
254 *
255 * @param array $context From build_list_context().
256 * @return string COUNT SQL with placeholders matching context params.
257 */
258 private function build_count_sql( array $context ): string {
259 $tables = $context['tables'];
260 $clauses = $context['clauses'];
261
262 return "SELECT COUNT(DISTINCT order_meta.{$context['id_col']}) FROM {$tables['order_meta']} order_meta JOIN {$tables['orders']} o ON o.ID = order_meta.{$context['id_col']} AND {$clauses['status_where']} {$clauses['customer_join']} {$context['joins']} {$context['where']}";
263 }
264
265 /**
266 * Fetches the paginated order rows — order scalars only.
267 *
268 * Customer identity, totals, discount, and item counts are resolved
269 * afterwards in resolve_order_amounts() / resolve_order_customers(), so
270 * the only JOINs left are the anchor, the orders table, any filter JOINs,
271 * and the JOIN the active sort expression needs.
272 *
273 * @param array $context From build_list_context().
274 * @param array $sort From resolve_order_sort().
275 * @param array $pagination From resolve_pagination().
276 * @return array Order row objects.
277 */
278 private function fetch_order_rows( array $context, array $sort, array $pagination ): array {
279 $tables = $context['tables'];
280 $clauses = $context['clauses'];
281 $order_id_column = $context['id_col'];
282
283 $rows_sql = "SELECT
284 order_meta.{$order_id_column} AS order_id,
285 {$clauses['date_col']} AS order_date,
286 {$clauses['status_col']} AS order_status,
287 {$clauses['customer_expr']} AS customer_id,
288 {$clauses['total_expr']} AS total_amount
289 FROM {$tables['order_meta']} order_meta
290 JOIN {$tables['orders']} o ON o.ID = order_meta.{$order_id_column} AND {$clauses['status_where']}
291 {$clauses['total_join']}
292 {$clauses['customer_join']}
293 {$sort['joins']}
294 {$context['joins']}
295 {$context['where']}
296 GROUP BY order_meta.{$order_id_column}
297 ORDER BY {$sort['column']} {$sort['direction']}
298 LIMIT %d OFFSET %d";
299
300 $params = array_merge( $context['params'], array( $pagination['per_page'], $pagination['offset'] ) );
301
302 return $this->run_rows( $rows_sql, $params );
303 }
304
305 /**
306 * Resolves order_total, discount_amount, and items_count via three batch lookups.
307 *
308 * Replaces the order_stats, discount-meta, and order_items LEFT JOINs that
309 * were previously inline in the SQL: order_total = total_amount minus the
310 * order's shipping_total, discount defaults to 0, items_count counts the
311 * order's line items.
312 *
313 * @param array $rows Raw query result rows (stdClass objects).
314 * @param array $tables Table name map from get_tables().
315 * @return array Rows with order_total, discount_amount, items_count set.
316 */
317 private function resolve_order_amounts( array $rows, array $tables ): array {
318 $order_ids = array_map(
319 function ( $row ) {
320 return (int) $row->order_id;
321 },
322 $rows
323 );
324
325 if ( empty( $order_ids ) ) {
326 return $rows;
327 }
328
329 $shipping_by_oid = $this->fetch_shipping_totals( $order_ids, $tables );
330 $discount_by_oid = $this->fetch_discount_totals( $order_ids, $tables );
331 $items_by_oid = $this->fetch_item_counts( $order_ids, $tables );
332
333 foreach ( $rows as &$row ) {
334 $order_id = (int) $row->order_id;
335
336 $row->order_total = (float) $row->total_amount - ( $shipping_by_oid[ $order_id ] ?? 0.0 );
337 $row->discount_amount = $discount_by_oid[ $order_id ] ?? 0;
338 $row->items_count = $items_by_oid[ $order_id ] ?? 0;
339 }
340
341 unset( $row );
342
343 return $rows;
344 }
345
346 /**
347 * Fetches shipping_total from wc_order_stats for a set of order IDs in one query.
348 *
349 * @param array<int> $order_ids Order IDs.
350 * @param array $tables Table name map from get_tables().
351 * @return array Map of order_id => shipping total (float).
352 */
353 private function fetch_shipping_totals( array $order_ids, array $tables ): array {
354 global $wpdb;
355
356 $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
357
358 $stat_rows = $this->run_rows(
359 "SELECT order_id, shipping_total FROM {$wpdb->prefix}wc_order_stats WHERE order_id IN ({$placeholders})",
360 $order_ids
361 );
362
363 $shipping_by_oid = array();
364
365 foreach ( $stat_rows as $stat_row ) {
366 $shipping_by_oid[ (int) $stat_row->order_id ] = (float) $stat_row->shipping_total;
367 }
368
369 return $shipping_by_oid;
370 }
371
372 /**
373 * Fetches _discount_total order meta for a set of order IDs in one query.
374 *
375 * @param array<int> $order_ids Order IDs.
376 * @param array $tables Table name map from get_tables().
377 * @return array Map of order_id => discount meta value (string).
378 */
379 private function fetch_discount_totals( array $order_ids, array $tables ): array {
380 $order_id_column = $tables['order_id_col'];
381 $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
382
383 $discount_rows = $this->run_rows(
384 "SELECT {$order_id_column} AS order_id, meta_value FROM {$tables['order_meta']} WHERE {$order_id_column} IN ({$placeholders}) AND meta_key = '_discount_total'",
385 $order_ids
386 );
387
388 $discount_by_oid = array();
389
390 foreach ( $discount_rows as $discount_row ) {
391 $discount_by_oid[ (int) $discount_row->order_id ] = $discount_row->meta_value;
392 }
393
394 return $discount_by_oid;
395 }
396
397 /**
398 * Fetches line-item counts for a set of order IDs in one query.
399 *
400 * @param array<int> $order_ids Order IDs.
401 * @param array $tables Table name map from get_tables().
402 * @return array Map of order_id => line item count (int).
403 */
404 private function fetch_item_counts( array $order_ids, array $tables ): array {
405 $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
406
407 $item_rows = $this->run_rows(
408 "SELECT order_id, COUNT(*) AS items_count FROM {$tables['order_items']} WHERE order_id IN ({$placeholders}) AND order_item_type = 'line_item' GROUP BY order_id",
409 $order_ids
410 );
411
412 $items_by_oid = array();
413
414 foreach ( $item_rows as $item_row ) {
415 $items_by_oid[ (int) $item_row->order_id ] = (int) $item_row->items_count;
416 }
417
418 return $items_by_oid;
419 }
420
421 /**
422 * Resolves customer_name / customer_email via two batch lookups.
423 *
424 * Replaces the users and order_addresses LEFT JOINs that were previously
425 * inline in the SQL. Mirrors the old COALESCE semantics: account fields
426 * win, the order's own billing address is the fallback; CONCAT of
427 * first/last name is null when either part is null.
428 *
429 * @param array $rows Raw query result rows (stdClass objects).
430 * @param array $tables Table name map from get_tables().
431 * @return array Rows with customer_name and customer_email set.
432 */
433 private function resolve_order_customers( array $rows, array $tables ): array {
434 $user_ids = array();
435 $order_ids = array();
436
437 foreach ( $rows as $row ) {
438 if ( (int) $row->customer_id > 0 ) {
439 $user_ids[ (int) $row->customer_id ] = true;
440 }
441
442 $order_ids[ (int) $row->order_id ] = true;
443 }
444
445 $user_by_id = $this->fetch_users( array_keys( $user_ids ), $tables );
446 $address_by_oid = $this->fetch_billing_addresses( array_keys( $order_ids ), $tables );
447
448 foreach ( $rows as &$row ) {
449 $user = $user_by_id[ (int) $row->customer_id ] ?? null;
450 $address = $address_by_oid[ (int) $row->order_id ] ?? null;
451
452 $billing_name = null;
453
454 if ( $address && null !== $address->first_name && null !== $address->last_name ) {
455 $billing_name = $address->first_name . ' ' . $address->last_name;
456 }
457
458 $row->customer_name = $user->display_name ?? $billing_name;
459 $row->customer_email = $user->user_email ?? ( $address->email ?? null );
460 }
461
462 unset( $row );
463
464 return $rows;
465 }
466
467 }
468