Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
71.28% |
67 / 94 |
|
52.38% |
11 / 21 |
CRAP | |
0.00% |
0 / 1 |
| Abstract_Csv_Report_Controller | |
72.04% |
67 / 93 |
|
52.38% |
11 / 21 |
107.83 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| format_row_with_comparison | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| get_report_key | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| get_report_label | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| get_data_endpoint | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| get_column_headers | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| format_row_for_csv | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| get_default_values | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| get_batch_limit | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_additional_params | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| get_fields | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| get_matching_field | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| get_identifying_fields | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| use_array_filter_format | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| should_include_empty_rows_by_default | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| should_include_empty_rows | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| get_empty_row_label | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_empty_row_check_field | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| format_time_interval | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| format_amount | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| get_interval_label | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| extract_data_by_prefix | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
6 | |||
| format_row_with_empty_handling | |
52.94% |
9 / 17 |
|
0.00% |
0 / 1 |
20.42 | |||
| is_row_empty | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| apply_empty_row_label | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| add_comparison_fields | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Abstract CSV Report Controller |
| 4 | * |
| 5 | * Base class for CSV export report controllers. Provides common functionality |
| 6 | * for formatting and exporting report data to CSV via the new Report_Registry system. |
| 7 | * |
| 8 | * @package Automattic\Jetpack\PremiumAnalytics\Reports\Export |
| 9 | */ |
| 10 | |
| 11 | declare( strict_types=1 ); |
| 12 | |
| 13 | namespace Automattic\Jetpack\PremiumAnalytics\Reports\Export; |
| 14 | |
| 15 | defined( 'ABSPATH' ) || exit; |
| 16 | |
| 17 | use DateTime; |
| 18 | use Exception; |
| 19 | |
| 20 | /** |
| 21 | * Abstract base class for CSV report controllers. |
| 22 | * |
| 23 | * All report controllers extend this base, which centralizes comparison-period |
| 24 | * handling, empty-row logic, requested-field selection, and value formatting. |
| 25 | * |
| 26 | * Each concrete controller should implement: |
| 27 | * - get_report_key(): Unique identifier for the report |
| 28 | * - get_report_label(): Human-readable name |
| 29 | * - get_data_endpoint(): API endpoint to fetch data from |
| 30 | * - get_column_headers(): CSV column headers |
| 31 | * - format_row_for_csv(): Transform raw data row to CSV format |
| 32 | * |
| 33 | * Optional overrides: |
| 34 | * - get_batch_limit(): Maximum number of items per batch (defaults to DEFAULT_BATCH_LIMIT) |
| 35 | * - get_additional_params(): Additional parameters to include in data requests (e.g., filters) |
| 36 | * |
| 37 | * The parent class handles: |
| 38 | * - Auto-registration with Report_Registry in constructor |
| 39 | * - Automatic comparison field handling via format_row_with_comparison() |
| 40 | * - Helper methods for common formatting tasks |
| 41 | * |
| 42 | * @since $$next-version$$ |
| 43 | */ |
| 44 | abstract class Abstract_Csv_Report_Controller implements Csv_Report_Controller_Interface { |
| 45 | |
| 46 | /** |
| 47 | * Default batch limit for time-based reports (1000 items). |
| 48 | */ |
| 49 | protected const DEFAULT_BATCH_LIMIT = 1000; |
| 50 | |
| 51 | /** |
| 52 | * Default date type for order-based reports (creation date). |
| 53 | */ |
| 54 | protected const DEFAULT_DATE_TYPE = 'created'; |
| 55 | |
| 56 | /** |
| 57 | * Report registry instance. |
| 58 | * |
| 59 | * @var Report_Registry |
| 60 | */ |
| 61 | protected $registry; |
| 62 | |
| 63 | /** |
| 64 | * Cached result of should_include_empty_rows() to avoid repeated filter calls. |
| 65 | * |
| 66 | * @var bool|null Null if not yet determined, otherwise the cached boolean result. |
| 67 | */ |
| 68 | private $cached_include_empty_rows = null; |
| 69 | |
| 70 | /** |
| 71 | * Constructor. |
| 72 | * |
| 73 | * @param Report_Registry $registry Registry instance (injected by DI). |
| 74 | */ |
| 75 | public function __construct( Report_Registry $registry ) { |
| 76 | $this->registry = $registry; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Register this controller with the report registry. |
| 81 | * |
| 82 | * @return void |
| 83 | */ |
| 84 | public function register(): void { |
| 85 | $this->registry->register_controller( $this ); |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * Format a row with automatic comparison field handling. |
| 90 | * |
| 91 | * This method wraps format_row_for_csv() and automatically adds |
| 92 | * comparison fields if present in the data. |
| 93 | * |
| 94 | * @param array $item The raw data item. |
| 95 | * @param string|null $interval Optional time interval for formatting. |
| 96 | * @return array The formatted row with comparison fields. |
| 97 | */ |
| 98 | public function format_row_with_comparison( array $item, ?string $interval = null ): array { |
| 99 | $prefix = Report_Data_Fetcher::COMPARISON_INDEX_PREFIX; |
| 100 | |
| 101 | // Extract original data (fields without comparison_ prefix) to check for empty values. |
| 102 | // Note: We format the full $item, not the extracted subset, to preserve all data. |
| 103 | $original_data = $this->extract_data_by_prefix( $item, $prefix, false ); |
| 104 | $row = $this->format_row_with_empty_handling( $original_data, $item, $interval ); |
| 105 | |
| 106 | return $this->add_comparison_fields( $row, $item, $interval ); |
| 107 | } |
| 108 | |
| 109 | // ============================================================================ |
| 110 | // Abstract Methods - Must be implemented by child classes |
| 111 | // ============================================================================ |
| 112 | |
| 113 | /** |
| 114 | * Get the report key (unique identifier). |
| 115 | * |
| 116 | * @return string |
| 117 | */ |
| 118 | abstract public function get_report_key(): string; |
| 119 | |
| 120 | /** |
| 121 | * Get the report label (human-readable name). |
| 122 | * |
| 123 | * @return string |
| 124 | */ |
| 125 | abstract public function get_report_label(): string; |
| 126 | |
| 127 | /** |
| 128 | * Get the data endpoint (API route). |
| 129 | * |
| 130 | * @return string |
| 131 | */ |
| 132 | abstract public function get_data_endpoint(): string; |
| 133 | |
| 134 | /** |
| 135 | * Get the column headers for CSV export. |
| 136 | * |
| 137 | * @param string|null $interval Optional time interval for dynamic headers. |
| 138 | * @return array |
| 139 | */ |
| 140 | abstract public function get_column_headers( ?string $interval = null ): array; |
| 141 | |
| 142 | /** |
| 143 | * Format a single data item for CSV export. |
| 144 | * |
| 145 | * This method should return the base row data without comparison fields. |
| 146 | * Comparison fields are automatically added by format_row_with_comparison(). |
| 147 | * |
| 148 | * @param array $item The raw data item. |
| 149 | * @param string|null $interval Optional time interval for formatting. |
| 150 | * @return array The formatted row for CSV. |
| 151 | */ |
| 152 | abstract public function format_row_for_csv( array $item, ?string $interval = null ): array; |
| 153 | |
| 154 | /** |
| 155 | * Get default values for missing data fields. |
| 156 | * |
| 157 | * This method should return an array of default values for all possible fields |
| 158 | * in this report. Used when creating empty items for missing comparison data. |
| 159 | * |
| 160 | * @return array Array of field_name => default_value pairs. |
| 161 | */ |
| 162 | abstract public function get_default_values(): array; |
| 163 | |
| 164 | /** |
| 165 | * Get the batch limit (max items per request). |
| 166 | * |
| 167 | * Override this method in child classes if a different batch limit is needed. |
| 168 | * |
| 169 | * @return int |
| 170 | */ |
| 171 | public function get_batch_limit(): int { |
| 172 | return self::DEFAULT_BATCH_LIMIT; |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Get additional request parameters for data fetching. |
| 177 | * |
| 178 | * Override this method in child classes to add controller-specific parameters |
| 179 | * (e.g., filters) that should be included in every data fetch request. |
| 180 | * |
| 181 | * @return array Additional parameters to include in data requests. |
| 182 | */ |
| 183 | public function get_additional_params(): array { |
| 184 | return array(); |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Get the list of fields to request from the API. |
| 189 | * |
| 190 | * Override in subclasses to request only the fields needed for |
| 191 | * this report, reducing API response payload size. |
| 192 | * Return an empty array to request all fields (default behavior). |
| 193 | * |
| 194 | * @return array Field names to request, or empty array for all fields. |
| 195 | */ |
| 196 | public function get_fields(): array { |
| 197 | return array(); |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Get the matching field for comparison data alignment. |
| 202 | * |
| 203 | * Default: null (index-based matching for time-series reports). |
| 204 | * Override in child classes for ID-based matching (ranked reports). |
| 205 | * |
| 206 | * @return string|null |
| 207 | */ |
| 208 | public function get_matching_field(): ?string { |
| 209 | return null; |
| 210 | } |
| 211 | |
| 212 | /** |
| 213 | * Get the identifying fields that should be preserved in comparison data. |
| 214 | * |
| 215 | * Default: empty array (no fields preserved for time-series reports). |
| 216 | * Override in ranked report controllers to specify identifying fields like |
| 217 | * 'product_name' or 'coupon_code' that should be copied when comparison data is missing. |
| 218 | * |
| 219 | * @return array Array of field names to preserve. |
| 220 | */ |
| 221 | public function get_identifying_fields(): array { |
| 222 | return array(); |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Whether to use array format for filter values in IN filters. |
| 227 | * |
| 228 | * Default: false (comma-separated format for URL efficiency). |
| 229 | * Override in controllers that require array format (e.g., order-attribution). |
| 230 | * |
| 231 | * @return bool True to use array format, false for comma-separated (default). |
| 232 | */ |
| 233 | public function use_array_filter_format(): bool { |
| 234 | return false; |
| 235 | } |
| 236 | |
| 237 | /** |
| 238 | * Whether to include empty rows by default. |
| 239 | * |
| 240 | * Default: true (include rows with empty identifying fields). |
| 241 | * Override in child controllers to return false if empty rows should be excluded by default. |
| 242 | * |
| 243 | * @return bool True to include empty rows, false to exclude them. |
| 244 | */ |
| 245 | protected function should_include_empty_rows_by_default(): bool { |
| 246 | return true; |
| 247 | } |
| 248 | |
| 249 | /** |
| 250 | * Whether to include rows with empty identifying fields in the export. |
| 251 | * |
| 252 | * Calls should_include_empty_rows_by_default() and applies a filter for global control. |
| 253 | * Filterable via 'jetpack_premium_analytics_csv_include_empty_rows' to globally |
| 254 | * control empty row inclusion across all reports or specific reports. |
| 255 | * |
| 256 | * The result is cached per controller instance to avoid repeated filter calls |
| 257 | * when processing large datasets. |
| 258 | * |
| 259 | * @return bool True to include empty rows with custom label, false to skip them. |
| 260 | */ |
| 261 | public function should_include_empty_rows(): bool { |
| 262 | // Return cached value if already determined. |
| 263 | if ( null !== $this->cached_include_empty_rows ) { |
| 264 | return $this->cached_include_empty_rows; |
| 265 | } |
| 266 | |
| 267 | $default = $this->should_include_empty_rows_by_default(); |
| 268 | |
| 269 | /** |
| 270 | * Filter whether to include empty rows in CSV exports. |
| 271 | * |
| 272 | * This filter takes precedence over controller defaults, allowing global control. |
| 273 | * |
| 274 | * @param bool $include_empty Whether to include empty rows (controller default). |
| 275 | * @param string $report_key The report key for this controller. |
| 276 | * @param object $controller The controller instance. |
| 277 | */ |
| 278 | $this->cached_include_empty_rows = apply_filters( 'jetpack_premium_analytics_csv_include_empty_rows', $default, $this->get_report_key(), $this ); |
| 279 | |
| 280 | return $this->cached_include_empty_rows; |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Get the label to use for rows with empty identifying fields. |
| 285 | * |
| 286 | * Only used when should_include_empty_rows() returns true. |
| 287 | * Override in child controllers to customize the label for empty rows. |
| 288 | * |
| 289 | * @return string The label to use for empty rows. |
| 290 | */ |
| 291 | public function get_empty_row_label(): string { |
| 292 | return __( 'Unassigned', 'jetpack-premium-analytics' ); |
| 293 | } |
| 294 | |
| 295 | /** |
| 296 | * Get the field name(s) to check for emptiness when determining if a row should be skipped. |
| 297 | * |
| 298 | * Default: null (no empty row checking). |
| 299 | * Override in ranked report controllers to specify the identifying field(s) to check. |
| 300 | * Returns an array of field names (all must be empty to skip the row). |
| 301 | * |
| 302 | * @return array|null Array of field name(s) to check, or null to skip empty checking. |
| 303 | */ |
| 304 | public function get_empty_row_check_field() { |
| 305 | return null; |
| 306 | } |
| 307 | |
| 308 | // ============================================================================ |
| 309 | // Helper Methods |
| 310 | // ============================================================================ |
| 311 | |
| 312 | /** |
| 313 | * Format a time interval for display in CSV. |
| 314 | * |
| 315 | * If 'hour', formats as 'Y-m-d H:00', otherwise 'Y-m-d'. |
| 316 | * |
| 317 | * @param array $item The data item containing date_start. |
| 318 | * @param string|null $interval Optional time interval. If 'hour', formats as 'Y-m-d H:00', otherwise 'Y-m-d'. |
| 319 | * @return string The formatted date string. |
| 320 | */ |
| 321 | protected function format_time_interval( array $item, ?string $interval = null ): string { |
| 322 | if ( ! isset( $item['date_start'] ) || empty( $item['date_start'] ) ) { |
| 323 | return ''; |
| 324 | } |
| 325 | |
| 326 | try { |
| 327 | $datetime = new DateTime( $item['date_start'] ); |
| 328 | $format = ( 'hour' === $interval ) ? 'Y-m-d H:00' : 'Y-m-d'; |
| 329 | return $datetime->format( $format ); |
| 330 | } catch ( Exception $e ) { |
| 331 | return ''; |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Format a monetary amount for display in CSV. |
| 337 | * |
| 338 | * @param mixed $amount The amount to format. |
| 339 | * @return string The formatted amount. |
| 340 | */ |
| 341 | protected static function format_amount( $amount ): string { |
| 342 | if ( is_numeric( $amount ) ) { |
| 343 | return number_format( (float) $amount, 2, '.', '' ); |
| 344 | } |
| 345 | return '0.00'; |
| 346 | } |
| 347 | |
| 348 | /** |
| 349 | * Get the label for a time interval. |
| 350 | * |
| 351 | * @param string|null $interval The time interval (hour, day, week, month, quarter, year). |
| 352 | * @return string The translated interval label. |
| 353 | */ |
| 354 | protected function get_interval_label( ?string $interval = null ): string { |
| 355 | $labels = array( |
| 356 | 'hour' => __( 'Hour', 'jetpack-premium-analytics' ), |
| 357 | 'day' => __( 'Day', 'jetpack-premium-analytics' ), |
| 358 | 'week' => __( 'Week', 'jetpack-premium-analytics' ), |
| 359 | 'month' => __( 'Month', 'jetpack-premium-analytics' ), |
| 360 | 'quarter' => __( 'Quarter', 'jetpack-premium-analytics' ), |
| 361 | 'year' => __( 'Year', 'jetpack-premium-analytics' ), |
| 362 | ); |
| 363 | |
| 364 | return $labels[ $interval ?? '' ] ?? __( 'Date', 'jetpack-premium-analytics' ); |
| 365 | } |
| 366 | |
| 367 | /** |
| 368 | * Extract data from an item based on prefix matching. |
| 369 | * |
| 370 | * @param array $item The raw data item. |
| 371 | * @param string $prefix The prefix to match against. |
| 372 | * @param bool $match_prefix If true, include keys with prefix; if false, exclude keys with prefix. |
| 373 | * @param bool $strip_prefix If true, strip the prefix from extracted keys. |
| 374 | * @return array Extracted data item and a flag indicating if all values are empty. |
| 375 | */ |
| 376 | protected function extract_data_by_prefix( array $item, string $prefix, bool $match_prefix, bool $strip_prefix = false ): array { |
| 377 | $extracted_item = array(); |
| 378 | $all_empty = true; |
| 379 | $prefix_length = strlen( $prefix ); |
| 380 | |
| 381 | foreach ( $item as $key => $value ) { |
| 382 | $has_prefix = ( strpos( $key, $prefix ) === 0 ); |
| 383 | |
| 384 | // Include key if it matches our criteria (has prefix when match_prefix is true, or doesn't have prefix when match_prefix is false). |
| 385 | if ( $has_prefix === $match_prefix ) { |
| 386 | $extracted_key = $strip_prefix && $has_prefix ? substr( $key, $prefix_length ) : $key; |
| 387 | $extracted_item[ $extracted_key ] = $value; |
| 388 | |
| 389 | // Check if all values are empty strings. |
| 390 | if ( '' !== $value ) { |
| 391 | $all_empty = false; |
| 392 | } |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | return array( |
| 397 | 'item' => $extracted_item, |
| 398 | 'all_empty' => $all_empty, |
| 399 | ); |
| 400 | } |
| 401 | |
| 402 | /** |
| 403 | * Format a row with empty value handling. |
| 404 | * |
| 405 | * Handles empty row checking based on controller configuration: |
| 406 | * - Checks configured field(s) for emptiness |
| 407 | * - Skips row if configured and empty (unless should_include_empty_rows() is true) |
| 408 | * - Includes row with custom label if configured |
| 409 | * |
| 410 | * If all values in the extracted item are empty strings, returns a row |
| 411 | * with all fields set to empty strings instead of using default values. |
| 412 | * |
| 413 | * @param array $extracted_data The extracted data item and empty flag. |
| 414 | * @param array $item_to_format The item to use for formatting (may differ from extracted data). |
| 415 | * @param string|null $interval Optional time interval for formatting. |
| 416 | * @return array The formatted row, or empty array if row should be skipped. |
| 417 | */ |
| 418 | protected function format_row_with_empty_handling( array $extracted_data, array $item_to_format, ?string $interval = null ): array { |
| 419 | $item = $extracted_data['item']; |
| 420 | $all_empty = $extracted_data['all_empty']; |
| 421 | |
| 422 | // Check if this row should be skipped due to empty identifying fields. |
| 423 | $check_fields = $this->get_empty_row_check_field(); |
| 424 | $is_empty = ( null !== $check_fields ) ? $this->is_row_empty( $item, $check_fields ) : false; |
| 425 | |
| 426 | if ( $is_empty ) { |
| 427 | // Row has empty identifying field(s). |
| 428 | if ( ! $this->should_include_empty_rows() ) { |
| 429 | // Skip this row entirely. |
| 430 | return array(); |
| 431 | } |
| 432 | // Include the row but we'll replace empty identifying field with custom label later. |
| 433 | } |
| 434 | |
| 435 | // If all values are empty strings, return empty strings for all fields |
| 436 | // instead of using default values from format_row_for_csv(). |
| 437 | if ( $all_empty && ! empty( $item ) ) { |
| 438 | // Get the expected field structure by formatting an empty item. |
| 439 | // This gives us the field names, but we'll set all values to empty strings. |
| 440 | $row_structure = $this->format_row_for_csv( array(), $interval ); |
| 441 | $row = array(); |
| 442 | foreach ( array_keys( $row_structure ) as $key ) { |
| 443 | $row[ $key ] = ''; |
| 444 | } |
| 445 | return $row; |
| 446 | } |
| 447 | |
| 448 | // Format the row normally. |
| 449 | $row = $this->format_row_for_csv( $item_to_format, $interval ); |
| 450 | |
| 451 | // If we determined the row is empty but should be included, replace empty identifying field with custom label. |
| 452 | // $is_empty implies $check_fields is non-null (see above), but check explicitly for the type checker. |
| 453 | if ( $is_empty && null !== $check_fields && $this->should_include_empty_rows() ) { |
| 454 | $row = $this->apply_empty_row_label( $row, $check_fields ); |
| 455 | } |
| 456 | |
| 457 | return $row; |
| 458 | } |
| 459 | |
| 460 | /** |
| 461 | * Check if a row is considered empty based on the configured check field(s). |
| 462 | * |
| 463 | * @param array $item The data item to check. |
| 464 | * @param array $check_fields Array of field names to check for emptiness. |
| 465 | * @return bool True if the row is empty, false otherwise. |
| 466 | */ |
| 467 | protected function is_row_empty( array $item, array $check_fields ): bool { |
| 468 | // All specified fields must be empty for the row to be considered empty. |
| 469 | foreach ( $check_fields as $field ) { |
| 470 | // Use strict comparison to avoid treating '0' as empty. |
| 471 | if ( isset( $item[ $field ] ) && '' !== $item[ $field ] ) { |
| 472 | return false; |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | return true; |
| 477 | } |
| 478 | |
| 479 | /** |
| 480 | * Apply the custom empty row label to the formatted row. |
| 481 | * |
| 482 | * Replaces the value of the identifying field(s) with the custom label. |
| 483 | * If the field name changed during formatting, child classes should override this method. |
| 484 | * |
| 485 | * Note: When multiple check fields are configured (e.g., ['campaign', 'label']), |
| 486 | * only the first matching empty field receives the label. This is intentional |
| 487 | * because the first field is typically the primary identifier (index/label column) |
| 488 | * that should be displayed to users. Applying the label to multiple columns would |
| 489 | * be redundant and could confuse users. |
| 490 | * |
| 491 | * @param array $row The formatted row data. |
| 492 | * @param array $check_fields The field names that were empty. |
| 493 | * @return array The row with custom label applied. |
| 494 | */ |
| 495 | protected function apply_empty_row_label( array $row, array $check_fields ): array { |
| 496 | $custom_label = $this->get_empty_row_label(); |
| 497 | |
| 498 | // Update the specified check field(s) if they exist in the formatted row. |
| 499 | foreach ( $check_fields as $field_name ) { |
| 500 | if ( isset( $row[ $field_name ] ) && '' === $row[ $field_name ] ) { |
| 501 | $row[ $field_name ] = $custom_label; |
| 502 | return $row; // Only update the first matching empty field. |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | return $row; |
| 507 | } |
| 508 | |
| 509 | /** |
| 510 | * Automatically add comparison fields to a formatted row. |
| 511 | * |
| 512 | * This helper method dynamically adds comparison data by: |
| 513 | * 1. Checking if comparison data exists (any comparison_ prefixed field) |
| 514 | * 2. Creating a synthetic item with comparison_ prefixes stripped |
| 515 | * 3. Calling format_row_for_csv() on the synthetic item (applying all field mapping) |
| 516 | * 4. Adding comparison_ prefix back to the formatted keys |
| 517 | * 5. Merging into the original row |
| 518 | * |
| 519 | * This ensures that field name mapping (e.g., orders_value_net → net_sales) |
| 520 | * is applied consistently to both base and comparison data. |
| 521 | * |
| 522 | * @param array $row The formatted row with original data. |
| 523 | * @param array $item The raw item with both original and comparison data. |
| 524 | * @param string|null $interval Optional time interval for formatting. |
| 525 | * @return array The row with comparison fields added. |
| 526 | */ |
| 527 | protected function add_comparison_fields( array $row, array $item, ?string $interval = null ): array { |
| 528 | $prefix = Report_Data_Fetcher::COMPARISON_INDEX_PREFIX; |
| 529 | |
| 530 | // Check if comparison data exists by looking for any comparison_ prefixed field. |
| 531 | $has_comparison_data = false; |
| 532 | foreach ( array_keys( $item ) as $key ) { |
| 533 | if ( strpos( $key, $prefix ) === 0 ) { |
| 534 | $has_comparison_data = true; |
| 535 | break; |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | if ( ! $has_comparison_data ) { |
| 540 | return $row; |
| 541 | } |
| 542 | |
| 543 | // Extract comparison data with prefix stripped. |
| 544 | // Note: We format the extracted comparison item (with prefix stripped), not the full item. |
| 545 | $comparison_data = $this->extract_data_by_prefix( $item, $prefix, true, true ); |
| 546 | $comparison_row = $this->format_row_with_empty_handling( $comparison_data, $comparison_data['item'], $interval ); |
| 547 | |
| 548 | // Add comparison_ prefix back to the formatted keys and merge into main row. |
| 549 | foreach ( $comparison_row as $key => $value ) { |
| 550 | $row[ $prefix . $key ] = $value; |
| 551 | } |
| 552 | |
| 553 | return $row; |
| 554 | } |
| 555 | } |