PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.0
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.0
1.4.15 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 All 178 releases
disco / app / Analytics / Queries / OrderQuery.php

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

461 lines 17.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
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. Totals/discounts are
210 * cast to DECIMAL so sorting is numeric, not lexicographic. The order_stats
211 * and discount-meta JOINs are emitted only when the chosen sort actually
212 * references them — display amounts are resolved separately in PHP.
213 *
214 * @param array $args orderby, order.
215 * @param array $context From build_list_context().
216 * @return array { column: string, direction: 'ASC'|'DESC', joins: string }
217 */
218 private function resolve_order_sort( array $args, array $context ): array {
219 global $wpdb;
220
221 $tables = $context['tables'];
222 $clauses = $context['clauses'];
223 $id_col = $context['id_col'];
224 $sort = $this->resolve_sort( $args, array( 'order_date', 'order_total', 'discount_amount' ), 'order_date' );
225
226 $orderby_column_map = array(
227 'order_date' => $clauses['date_col'],
228 'order_total' => "CAST(({$clauses['total_expr']} - COALESCE(order_stats.shipping_total, 0)) AS DECIMAL(10,2))",
229 'discount_amount' => "CAST({$clauses['discount_expr']} AS DECIMAL(10,2))",
230 );
231
232 $sort_join_map = array(
233 'order_date' => '',
234 'order_total' => "LEFT JOIN {$wpdb->prefix}wc_order_stats order_stats ON order_stats.order_id = order_meta.{$id_col}",
235 'discount_amount' => $clauses['discount_join'],
236 );
237
238 return array(
239 'column' => $orderby_column_map[ $sort['orderby'] ],
240 'direction' => $sort['direction'],
241 'joins' => $sort_join_map[ $sort['orderby'] ],
242 );
243 }
244
245 /**
246 * Builds the COUNT(DISTINCT order) SQL for the current filters.
247 *
248 * @param array $context From build_list_context().
249 * @return string COUNT SQL with placeholders matching context params.
250 */
251 private function build_count_sql( array $context ): string {
252 $tables = $context['tables'];
253 $clauses = $context['clauses'];
254
255 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']}";
256 }
257
258 /**
259 * Fetches the paginated order rows — order scalars only.
260 *
261 * Customer identity, totals, discount, and item counts are resolved
262 * afterwards in resolve_order_amounts() / resolve_order_customers(), so
263 * the only JOINs left are the anchor, the orders table, any filter JOINs,
264 * and the JOIN the active sort expression needs.
265 *
266 * @param array $context From build_list_context().
267 * @param array $sort From resolve_order_sort().
268 * @param array $pagination From resolve_pagination().
269 * @return array Order row objects.
270 */
271 private function fetch_order_rows( array $context, array $sort, array $pagination ): array {
272 $tables = $context['tables'];
273 $clauses = $context['clauses'];
274 $order_id_column = $context['id_col'];
275
276 $rows_sql = "SELECT
277 order_meta.{$order_id_column} AS order_id,
278 {$clauses['date_col']} AS order_date,
279 {$clauses['status_col']} AS order_status,
280 {$clauses['customer_expr']} AS customer_id,
281 {$clauses['total_expr']} AS total_amount
282 FROM {$tables['order_meta']} order_meta
283 JOIN {$tables['orders']} o ON o.ID = order_meta.{$order_id_column} AND {$clauses['status_where']}
284 {$clauses['total_join']}
285 {$clauses['customer_join']}
286 {$sort['joins']}
287 {$context['joins']}
288 {$context['where']}
289 GROUP BY order_meta.{$order_id_column}
290 ORDER BY {$sort['column']} {$sort['direction']}
291 LIMIT %d OFFSET %d";
292
293 $params = array_merge( $context['params'], array( $pagination['per_page'], $pagination['offset'] ) );
294
295 return $this->run_rows( $rows_sql, $params );
296 }
297
298 /**
299 * Resolves order_total, discount_amount, and items_count via three batch lookups.
300 *
301 * Replaces the order_stats, discount-meta, and order_items LEFT JOINs that
302 * were previously inline in the SQL: order_total = total_amount minus the
303 * order's shipping_total, discount defaults to 0, items_count counts the
304 * order's line items.
305 *
306 * @param array $rows Raw query result rows (stdClass objects).
307 * @param array $tables Table name map from get_tables().
308 * @return array Rows with order_total, discount_amount, items_count set.
309 */
310 private function resolve_order_amounts( array $rows, array $tables ): array {
311 $order_ids = array_map(
312 function ( $row ) {
313 return (int) $row->order_id;
314 },
315 $rows
316 );
317
318 if ( empty( $order_ids ) ) {
319 return $rows;
320 }
321
322 $shipping_by_oid = $this->fetch_shipping_totals( $order_ids, $tables );
323 $discount_by_oid = $this->fetch_discount_totals( $order_ids, $tables );
324 $items_by_oid = $this->fetch_item_counts( $order_ids, $tables );
325
326 foreach ( $rows as &$row ) {
327 $order_id = (int) $row->order_id;
328
329 $row->order_total = (float) $row->total_amount - ( $shipping_by_oid[ $order_id ] ?? 0.0 );
330 $row->discount_amount = $discount_by_oid[ $order_id ] ?? 0;
331 $row->items_count = $items_by_oid[ $order_id ] ?? 0;
332 }
333
334 unset( $row );
335
336 return $rows;
337 }
338
339 /**
340 * Fetches shipping_total from wc_order_stats for a set of order IDs in one query.
341 *
342 * @param array<int> $order_ids Order IDs.
343 * @param array $tables Table name map from get_tables().
344 * @return array Map of order_id => shipping total (float).
345 */
346 private function fetch_shipping_totals( array $order_ids, array $tables ): array {
347 global $wpdb;
348
349 $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
350
351 $stat_rows = $this->run_rows(
352 "SELECT order_id, shipping_total FROM {$wpdb->prefix}wc_order_stats WHERE order_id IN ({$placeholders})",
353 $order_ids
354 );
355
356 $shipping_by_oid = array();
357
358 foreach ( $stat_rows as $stat_row ) {
359 $shipping_by_oid[ (int) $stat_row->order_id ] = (float) $stat_row->shipping_total;
360 }
361
362 return $shipping_by_oid;
363 }
364
365 /**
366 * Fetches _discount_total order meta for a set of order IDs in one query.
367 *
368 * @param array<int> $order_ids Order IDs.
369 * @param array $tables Table name map from get_tables().
370 * @return array Map of order_id => discount meta value (string).
371 */
372 private function fetch_discount_totals( array $order_ids, array $tables ): array {
373 $order_id_column = $tables['order_id_col'];
374 $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
375
376 $discount_rows = $this->run_rows(
377 "SELECT {$order_id_column} AS order_id, meta_value FROM {$tables['order_meta']} WHERE {$order_id_column} IN ({$placeholders}) AND meta_key = '_discount_total'",
378 $order_ids
379 );
380
381 $discount_by_oid = array();
382
383 foreach ( $discount_rows as $discount_row ) {
384 $discount_by_oid[ (int) $discount_row->order_id ] = $discount_row->meta_value;
385 }
386
387 return $discount_by_oid;
388 }
389
390 /**
391 * Fetches line-item counts for a set of order IDs in one query.
392 *
393 * @param array<int> $order_ids Order IDs.
394 * @param array $tables Table name map from get_tables().
395 * @return array Map of order_id => line item count (int).
396 */
397 private function fetch_item_counts( array $order_ids, array $tables ): array {
398 $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
399
400 $item_rows = $this->run_rows(
401 "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",
402 $order_ids
403 );
404
405 $items_by_oid = array();
406
407 foreach ( $item_rows as $item_row ) {
408 $items_by_oid[ (int) $item_row->order_id ] = (int) $item_row->items_count;
409 }
410
411 return $items_by_oid;
412 }
413
414 /**
415 * Resolves customer_name / customer_email via two batch lookups.
416 *
417 * Replaces the users and order_addresses LEFT JOINs that were previously
418 * inline in the SQL. Mirrors the old COALESCE semantics: account fields
419 * win, the order's own billing address is the fallback; CONCAT of
420 * first/last name is null when either part is null.
421 *
422 * @param array $rows Raw query result rows (stdClass objects).
423 * @param array $tables Table name map from get_tables().
424 * @return array Rows with customer_name and customer_email set.
425 */
426 private function resolve_order_customers( array $rows, array $tables ): array {
427 $user_ids = array();
428 $order_ids = array();
429
430 foreach ( $rows as $row ) {
431 if ( (int) $row->customer_id > 0 ) {
432 $user_ids[ (int) $row->customer_id ] = true;
433 }
434
435 $order_ids[ (int) $row->order_id ] = true;
436 }
437
438 $user_by_id = $this->fetch_users( array_keys( $user_ids ), $tables );
439 $address_by_oid = $this->fetch_billing_addresses( array_keys( $order_ids ), $tables );
440
441 foreach ( $rows as &$row ) {
442 $user = $user_by_id[ (int) $row->customer_id ] ?? null;
443 $address = $address_by_oid[ (int) $row->order_id ] ?? null;
444
445 $billing_name = null;
446
447 if ( $address && null !== $address->first_name && null !== $address->last_name ) {
448 $billing_name = $address->first_name . ' ' . $address->last_name;
449 }
450
451 $row->customer_name = $user->display_name ?? $billing_name;
452 $row->customer_email = $user->user_email ?? ( $address->email ?? null );
453 }
454
455 unset( $row );
456
457 return $rows;
458 }
459
460 }
461