PluginProbe
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant / 2.3.2
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant v2.3.2
2.3.2 2.3.1 2.3.0 2.2.8 2.2.7 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.11.0 1.11.1 1.11.2 1.6 1.7 1.8 1.8.1 1.8.2 1.8.3 1.9.0 1.9.1 1.9.10 1.9.11 All 60 releases
merchant / inc / abilities / class-merchant-recommendations-engine.php

class-merchant-recommendations-engine.php in Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant 2.3.2, at inc/abilities/class-merchant-recommendations-engine.php

566 lines 16.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Merchant Recommendations Engine.
4 *
5 * Rules-based heuristic engine that scans analytics data and returns
6 * structured optimization flags. No external AI or API dependencies.
7 *
8 * @package Merchant
9 * @since 2.3.0
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Merchant_Recommendations_Engine
18 *
19 * Evaluates heuristic rules against analytics data per active
20 * campaign-based module. Produces recommendation objects with
21 * flags, severity, metrics snapshots, and factual context strings.
22 *
23 * @since 2.3.0
24 */
25 class Merchant_Recommendations_Engine {
26
27 /**
28 * The abilities registry.
29 *
30 * @var Merchant_Abilities_Registry
31 */
32 private $registry;
33
34 /**
35 * Rules mapped to focus categories.
36 *
37 * @var array<string, string[]>
38 */
39 private static $focus_rules = array(
40 'conversion' => array( 'low_conversion', 'high_impressions_low_clicks', 'no_data' ),
41 'revenue' => array( 'underperforming', 'top_performer', 'inactive_with_history' ),
42 'engagement' => array( 'high_impressions_low_clicks', 'no_data' ),
43 );
44
45 /**
46 * Constructor.
47 *
48 * @param Merchant_Abilities_Registry $registry The abilities registry.
49 */
50 public function __construct( $registry ) {
51 $this->registry = $registry;
52 }
53
54 /**
55 * Generate optimization recommendations.
56 *
57 * @param string|null $module_id Optional. Limit to one module.
58 * @param string $focus 'conversion', 'revenue', 'engagement', or 'general'.
59 * @param array<string, mixed> $date_range Optional. { start: string, end: string }.
60 *
61 * @return array<int, array<string, mixed>> Array of recommendation objects.
62 */
63 public function generate( $module_id = null, $focus = 'general', $date_range = array() ) {
64 $recommendations = array();
65 $data_provider = new Merchant_Analytics_Data_Provider();
66 $dates = $this->resolve_dates( $date_range );
67
68 $modules = $this->get_target_modules( $module_id );
69
70 foreach ( $modules as $mod_id => $mod_data ) {
71 $adapter = $this->registry->get_adapter( $mod_id );
72 $campaign_field_id = $adapter->get_campaign_field_id();
73
74 if ( null === $campaign_field_id ) {
75 continue; // Settings-only module.
76 }
77
78 // Module-level flag: inactive module with historical data.
79 if ( ! $this->is_module_active( $mod_id ) ) {
80 if ( $this->is_rule_in_focus( 'module_not_active', $focus ) ) {
81 $data_provider->set_start_date( $dates['start'] );
82 $data_provider->set_end_date( $dates['end'] );
83 $historical = $data_provider->get_module_revenue( $mod_id );
84
85 if ( $historical > 0 ) {
86 $recommendations[] = $this->build_module_not_active( $mod_id );
87 }
88 }
89
90 continue;
91 }
92
93 // Get campaigns.
94 $settings = $this->get_module_settings( $mod_id );
95 $campaigns = isset( $settings[ $campaign_field_id ] ) ? $settings[ $campaign_field_id ] : array();
96
97 if ( empty( $campaigns ) || ! is_array( $campaigns ) ) {
98 continue;
99 }
100
101 $module_revenues = array();
102
103 // First pass: collect metrics.
104 foreach ( $campaigns as $index => $campaign ) {
105 $campaign_id = isset( $campaign['flexible_id'] ) ? $campaign['flexible_id'] : $index;
106
107 $data_provider->set_start_date( $dates['start'] );
108 $data_provider->set_end_date( $dates['end'] );
109
110 $metrics = $this->get_campaign_metrics( $data_provider, $mod_id, $campaign_id );
111 $module_revenues[] = $metrics['revenue'];
112
113 // Per-campaign rules (except underperforming/top_performer).
114 $per_campaign_rules = array(
115 'low_conversion',
116 'high_impressions_low_clicks',
117 'no_data',
118 'inactive_with_history',
119 );
120
121 foreach ( $per_campaign_rules as $rule ) {
122 if ( ! $this->is_rule_in_focus( $rule, $focus ) ) {
123 continue;
124 }
125
126 $result = $this->evaluate_rule( $rule, $campaign, $metrics, $mod_id, $index );
127
128 if ( null !== $result ) {
129 $recommendations[] = $result;
130 }
131 }
132 }
133
134 // Second pass: underperforming/top_performer (need average).
135 if ( count( $module_revenues ) > 1 ) {
136 $avg_revenue = array_sum( $module_revenues ) / count( $module_revenues );
137
138 foreach ( $campaigns as $index => $campaign ) {
139 $campaign_id = isset( $campaign['flexible_id'] ) ? $campaign['flexible_id'] : $index;
140
141 $data_provider->set_start_date( $dates['start'] );
142 $data_provider->set_end_date( $dates['end'] );
143
144 $metrics = $this->get_campaign_metrics( $data_provider, $mod_id, $campaign_id );
145
146 if ( $this->is_rule_in_focus( 'underperforming', $focus ) && $metrics['revenue'] < $avg_revenue * 0.5 ) {
147 $recommendations[] = $this->build_recommendation(
148 'underperforming',
149 'warning',
150 $mod_id,
151 $index,
152 $campaign_id,
153 $metrics,
154 sprintf(
155 'Campaign revenue ($%s) is less than half the module average ($%s). Consider adjusting targeting or discount value.',
156 number_format( $metrics['revenue'], 2 ),
157 number_format( $avg_revenue, 2 )
158 )
159 );
160 }
161
162 if ( $this->is_rule_in_focus( 'top_performer', $focus ) && $metrics['revenue'] > $avg_revenue * 2 ) {
163 $recommendations[] = $this->build_recommendation(
164 'top_performer',
165 'info',
166 $mod_id,
167 $index,
168 $campaign_id,
169 $metrics,
170 sprintf(
171 'Campaign revenue ($%s) is more than double the module average. This campaign is a standout performer.',
172 number_format( $metrics['revenue'], 2 )
173 )
174 );
175 }
176 }
177 }
178 }
179
180 return $recommendations;
181 }
182
183 /**
184 * Get target modules filtered by module_id.
185 *
186 * @param string|null $module_id Optional module filter.
187 *
188 * @return array<string, array<string, mixed>> Module data keyed by module ID.
189 */
190 private function get_target_modules( $module_id ) {
191 $all_modules = Merchant_Admin_Modules::$modules_data;
192 $filtered = array();
193
194 foreach ( $all_modules as $mod_id => $mod_data ) {
195 if ( Merchant_Abilities_Registry::is_excluded( $mod_id ) ) {
196 continue;
197 }
198
199 if ( null !== $module_id && $mod_id !== $module_id ) {
200 continue;
201 }
202
203 $filtered[ $mod_id ] = $mod_data;
204 }
205
206 return $filtered;
207 }
208
209 /**
210 * Check if a module is active via the options DB.
211 *
212 * Uses direct option check instead of Merchant_Modules::is_module_active()
213 * to avoid UI-specific side effects (preview mode, filters, etc.).
214 *
215 * @param string $module_id Module identifier.
216 *
217 * @return bool True if active.
218 */
219 private function is_module_active( $module_id ) {
220 $modules = get_option( 'merchant-modules', array() );
221
222 return ! empty( $modules[ $module_id ] );
223 }
224
225 /**
226 * Get module settings from the merchant option.
227 *
228 * @param string $module_id Module identifier.
229 *
230 * @return array<string, mixed> Module settings.
231 */
232 private function get_module_settings( $module_id ) {
233 $options = get_option( 'merchant', array() );
234
235 return isset( $options[ $module_id ] ) ? $options[ $module_id ] : array();
236 }
237
238 /**
239 * Get campaign metrics from the data provider.
240 *
241 * @param Merchant_Analytics_Data_Provider $data_provider Data provider instance.
242 * @param string $module_id Module identifier.
243 * @param string|int $campaign_id Campaign identifier.
244 *
245 * @return array<string, mixed> Metrics array.
246 */
247 private function get_campaign_metrics( $data_provider, $module_id, $campaign_id ) {
248 $impressions = $data_provider->get_campaign_impressions( $campaign_id, $module_id );
249 $clicks = $data_provider->get_campaign_clicks( $campaign_id, $module_id );
250 $orders = $data_provider->get_campaign_orders_count( $campaign_id, $module_id );
251 $revenue = $data_provider->get_campaign_revenue( $campaign_id, $module_id );
252 $ctr = $data_provider->get_campaign_ctr_percentage( $campaign_id, $module_id );
253
254 // Compute conversion rate inline (no provider method).
255 $conversion_rate = $impressions > 0 ? ( $orders / $impressions ) * 100 : 0;
256
257 return array(
258 'impressions' => $impressions,
259 'clicks' => $clicks,
260 'orders' => $orders,
261 'revenue' => (float) $revenue,
262 'ctr' => (float) $ctr,
263 'conversion_rate' => round( $conversion_rate, 2 ),
264 );
265 }
266
267 /**
268 * Evaluate a single rule against campaign data and metrics.
269 *
270 * @param string $rule Rule identifier.
271 * @param array<string, mixed> $campaign Campaign data.
272 * @param array<string, mixed> $metrics Campaign metrics.
273 * @param string $mod_id Module identifier.
274 * @param int|string $index Campaign index.
275 *
276 * @return array<string, mixed>|null Recommendation or null if rule not triggered.
277 */
278 private function evaluate_rule( $rule, $campaign, $metrics, $mod_id, $index ) {
279 $campaign_id = isset( $campaign['flexible_id'] ) ? $campaign['flexible_id'] : $index;
280
281 switch ( $rule ) {
282 case 'low_conversion':
283 return $this->evaluate_low_conversion( $campaign, $metrics, $mod_id, $index, $campaign_id );
284
285 case 'high_impressions_low_clicks':
286 return $this->evaluate_high_impressions_low_clicks( $campaign, $metrics, $mod_id, $index, $campaign_id );
287
288 case 'no_data':
289 return $this->evaluate_no_data( $campaign, $metrics, $mod_id, $index, $campaign_id );
290
291 case 'inactive_with_history':
292 return $this->evaluate_inactive_with_history( $campaign, $metrics, $mod_id, $index, $campaign_id );
293
294 default:
295 return null;
296 }
297 }
298
299 /**
300 * Evaluate low_conversion rule.
301 *
302 * @param array<string, mixed> $campaign Campaign data.
303 * @param array<string, mixed> $metrics Campaign metrics.
304 * @param string $mod_id Module identifier.
305 * @param int|string $index Campaign index.
306 * @param string|int $campaign_id Campaign identifier.
307 *
308 * @return array<string, mixed>|null Recommendation or null.
309 */
310 private function evaluate_low_conversion( $campaign, $metrics, $mod_id, $index, $campaign_id ) {
311 if ( $metrics['impressions'] <= 100 || $metrics['conversion_rate'] >= 1 ) {
312 return null;
313 }
314
315 return $this->build_recommendation(
316 'low_conversion',
317 'warning',
318 $mod_id,
319 $index,
320 $campaign_id,
321 $metrics,
322 sprintf(
323 '%d impressions but only %d orders (%s%% conversion). The offer reaches shoppers but isn\'t compelling enough to drive purchases.',
324 $metrics['impressions'],
325 $metrics['orders'],
326 number_format( $metrics['conversion_rate'], 1 )
327 )
328 );
329 }
330
331 /**
332 * Evaluate high_impressions_low_clicks rule.
333 *
334 * @param array<string, mixed> $campaign Campaign data.
335 * @param array<string, mixed> $metrics Campaign metrics.
336 * @param string $mod_id Module identifier.
337 * @param int|string $index Campaign index.
338 * @param string|int $campaign_id Campaign identifier.
339 *
340 * @return array<string, mixed>|null Recommendation or null.
341 */
342 private function evaluate_high_impressions_low_clicks( $campaign, $metrics, $mod_id, $index, $campaign_id ) {
343 if ( $metrics['impressions'] <= 100 || $metrics['ctr'] >= 2 ) {
344 return null;
345 }
346
347 return $this->build_recommendation(
348 'high_impressions_low_clicks',
349 'warning',
350 $mod_id,
351 $index,
352 $campaign_id,
353 $metrics,
354 sprintf(
355 '%d impressions but only %d clicks (%s%% CTR). The offer is seen but not compelling enough to trigger interaction.',
356 $metrics['impressions'],
357 $metrics['clicks'],
358 number_format( $metrics['ctr'], 1 )
359 )
360 );
361 }
362
363 /**
364 * Evaluate no_data rule.
365 *
366 * @param array<string, mixed> $campaign Campaign data.
367 * @param array<string, mixed> $metrics Campaign metrics.
368 * @param string $mod_id Module identifier.
369 * @param int|string $index Campaign index.
370 * @param string|int $campaign_id Campaign identifier.
371 *
372 * @return array<string, mixed>|null Recommendation or null.
373 */
374 private function evaluate_no_data( $campaign, $metrics, $mod_id, $index, $campaign_id ) {
375 if ( $metrics['impressions'] > 0 ) {
376 return null;
377 }
378
379 $days = $this->get_campaign_age_days( $campaign );
380
381 if ( $days <= 7 ) {
382 return null;
383 }
384
385 return $this->build_recommendation(
386 'no_data',
387 'info',
388 $mod_id,
389 $index,
390 $campaign_id,
391 $metrics,
392 sprintf(
393 'Campaign has been active for %d days but has zero impressions. Check if targeting rules are correct or if the campaign is visible on the storefront.',
394 $days
395 )
396 );
397 }
398
399 /**
400 * Evaluate inactive_with_history rule.
401 *
402 * @param array<string, mixed> $campaign Campaign data.
403 * @param array<string, mixed> $metrics Campaign metrics.
404 * @param string $mod_id Module identifier.
405 * @param int|string $index Campaign index.
406 * @param string|int $campaign_id Campaign identifier.
407 *
408 * @return array<string, mixed>|null Recommendation or null.
409 */
410 private function evaluate_inactive_with_history( $campaign, $metrics, $mod_id, $index, $campaign_id ) {
411 $status = isset( $campaign['status'] ) ? $campaign['status'] : 'active';
412
413 if ( 'inactive' !== $status ) {
414 return null;
415 }
416
417 if ( $metrics['revenue'] <= 0 ) {
418 return null;
419 }
420
421 return $this->build_recommendation(
422 'inactive_with_history',
423 'opportunity',
424 $mod_id,
425 $index,
426 $campaign_id,
427 $metrics,
428 sprintf(
429 'Campaign is currently inactive but generated $%s in revenue historically. Consider reactivating or creating a similar campaign.',
430 number_format( $metrics['revenue'], 2 )
431 )
432 );
433 }
434
435 /**
436 * Build a module_not_active recommendation.
437 *
438 * @param string $module_id Module identifier.
439 *
440 * @return array<string, mixed> Recommendation object.
441 */
442 private function build_module_not_active( $module_id ) {
443 return array(
444 'flag' => 'module_not_active',
445 'severity' => 'opportunity',
446 'module_id' => $module_id,
447 'context' => sprintf(
448 "Module '%s' is not active but has historical revenue.",
449 $module_id
450 ),
451 'metrics' => array(),
452 );
453 }
454
455 /**
456 * Build a recommendation object.
457 *
458 * @param string $flag Flag identifier.
459 * @param string $severity Severity level.
460 * @param string $module_id Module identifier.
461 * @param int|string $index Campaign index.
462 * @param string|int $campaign_id Campaign identifier.
463 * @param array<string, mixed> $metrics Metrics snapshot.
464 * @param string $context Context description.
465 *
466 * @return array<string, mixed> Recommendation object.
467 */
468 private function build_recommendation( $flag, $severity, $module_id, $index, $campaign_id, $metrics, $context ) {
469 return array(
470 'flag' => $flag,
471 'severity' => $severity,
472 'module_id' => $module_id,
473 'campaign_id' => $campaign_id,
474 'index' => $index,
475 'context' => $context,
476 'metrics' => $metrics,
477 );
478 }
479
480 /**
481 * Get the age of a campaign in days.
482 *
483 * @param array<string, mixed> $campaign Campaign data.
484 *
485 * @return int Age in days.
486 */
487 private function get_campaign_age_days( $campaign ) {
488 $created_at = isset( $campaign['created_at'] ) ? $campaign['created_at'] : '';
489
490 if ( empty( $created_at ) ) {
491 // No creation date — assume old enough to trigger.
492 return 30;
493 }
494
495 $created = strtotime( $created_at );
496
497 if ( false === $created ) {
498 return 30;
499 }
500
501 return max( 0, (int) floor( ( time() - $created ) / 86400 ) );
502 }
503
504 /**
505 * Check if a rule is included in the requested focus.
506 *
507 * @param string $rule Rule identifier.
508 * @param string $focus Focus category.
509 *
510 * @return bool True if rule is in focus.
511 */
512 private function is_rule_in_focus( $rule, $focus ) {
513 if ( 'general' === $focus ) {
514 return true;
515 }
516
517 if ( ! isset( self::$focus_rules[ $focus ] ) ) {
518 return true; // Unknown focus — include all.
519 }
520
521 return in_array( $rule, self::$focus_rules[ $focus ], true );
522 }
523
524 /**
525 * Resolve date range with defaults.
526 *
527 * @param array<string, mixed> $date_range Optional date range { start: string, end: string }.
528 *
529 * @return array{start: string, end: string} Resolved dates in m/d/y format.
530 */
531 private function resolve_dates( $date_range ) {
532 $start = isset( $date_range['start'] ) ? $date_range['start'] : '';
533 $end = isset( $date_range['end'] ) ? $date_range['end'] : '';
534
535 if ( empty( $start ) || empty( $end ) ) {
536 return array(
537 'start' => gmdate( 'm/d/y', strtotime( '-30 days' ) ),
538 'end' => gmdate( 'm/d/y' ),
539 );
540 }
541
542 return array(
543 'start' => $this->normalize_date( $start ),
544 'end' => $this->normalize_date( $end ),
545 );
546 }
547
548 /**
549 * Normalize a date string to m/d/y format.
550 *
551 * @param string $date Date string (Y-m-d or m/d/y).
552 *
553 * @return string Normalized date in m/d/y format.
554 */
555 private function normalize_date( $date ) {
556 // ISO 8601: Y-m-d.
557 if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
558 $d = DateTime::createFromFormat( 'Y-m-d', $date );
559 return $d ? $d->format( 'm/d/y' ) : gmdate( 'm/d/y' );
560 }
561
562 // Legacy m/d/y — pass through.
563 return $date;
564 }
565 }
566