loreal-global-kickoff/CSVTransformer.php
DJP 80b170a735 Add Global to Local CSV transformation backend
Services Created:
- OMGService.php: OMG API integration with detailed error handling
- CSVTransformer.php: CSV parsing and transformation logic
- EmailService.php: Mailgun email notifications
- process-csv.php: Multi-stage CSV processing with progress tracking
- upload-to-box.php: Box upload with approval workflow

Features:
- Comprehensive validation at each stage (upload, parse, campaign, API, transform)
- Detailed error reporting with actionable messages
- Warning system for non-critical issues
- Progress tracking through all stages
- Session-based CSV storage for preview before upload
- Date transformation (parse + add 1 month per blueprint)
- 16x market multiplication per ISO codes
- Business unit mapping per Make.com blueprint logic

Dependencies Added:
- league/csv for CSV parsing
- nesbot/carbon for date manipulation

Configuration:
- Added global_to_local settings (ISO codes, business unit map)
- Added omg_api settings (placeholder for API key)
- Added email settings (Mailgun placeholders)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 16:44:48 -05:00

355 lines
12 KiB
PHP

<?php
/**
* CSV Transformer
* Transforms global campaign CSV into regional CSVs per the Make.com blueprint logic
*/
use League\Csv\Reader;
use League\Csv\Writer;
use Carbon\Carbon;
class CSVTransformer {
private $config;
private $isoCodes;
private $errors = [];
private $warnings = [];
public function __construct() {
$appConfig = require __DIR__ . '/config.php';
$this->config = $appConfig['global_to_local'];
$this->isoCodes = $this->config['iso_codes'];
}
/**
* Validate uploaded CSV file
*/
public function validateUpload($file) {
$errors = [];
// Check if file was uploaded
if (!isset($file['tmp_name']) || empty($file['tmp_name'])) {
$errors[] = 'No file was uploaded';
return ['valid' => false, 'errors' => $errors];
}
// Check for upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
$errors[] = 'File upload error: ' . $this->getUploadErrorMessage($file['error']);
return ['valid' => false, 'errors' => $errors];
}
// Check file extension
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'csv') {
$errors[] = 'Invalid file type. Expected .csv, got .' . $ext;
return ['valid' => false, 'errors' => $errors];
}
// Check file size
if ($file['size'] > $this->config['max_file_size']) {
$maxMB = round($this->config['max_file_size'] / 1048576, 2);
$sizeMB = round($file['size'] / 1048576, 2);
$errors[] = "File too large. Maximum size: {$maxMB}MB, uploaded: {$sizeMB}MB";
return ['valid' => false, 'errors' => $errors];
}
// Check if file is empty
if ($file['size'] === 0) {
$errors[] = 'File is empty';
return ['valid' => false, 'errors' => $errors];
}
return ['valid' => true, 'filename' => $file['name']];
}
/**
* Get upload error message
*/
private function getUploadErrorMessage($code) {
switch ($code) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return 'File exceeds maximum allowed size';
case UPLOAD_ERR_PARTIAL:
return 'File was only partially uploaded';
case UPLOAD_ERR_NO_FILE:
return 'No file was uploaded';
case UPLOAD_ERR_NO_TMP_DIR:
return 'Missing temporary folder';
case UPLOAD_ERR_CANT_WRITE:
return 'Failed to write file to disk';
default:
return 'Unknown upload error';
}
}
/**
* Parse CSV and validate structure
*/
public function parseCSV($filePath) {
try {
$csv = Reader::createFromPath($filePath, 'r');
$csv->setHeaderOffset(0); // First row is headers
$headers = $csv->getHeader();
$records = iterator_to_array($csv->getRecords());
// Validate column count (should have at least 12 columns based on blueprint)
if (count($headers) < 12) {
return [
'success' => false,
'error' => 'Invalid CSV structure',
'details' => 'Expected at least 12 columns, found ' . count($headers),
'action' => 'Verify you uploaded the correct CSV file'
];
}
// Check if CSV has data rows
if (count($records) === 0) {
return [
'success' => false,
'error' => 'CSV has no data rows',
'details' => 'CSV contains only headers, no data to process',
'action' => 'Upload a CSV file with data rows'
];
}
return [
'success' => true,
'headers' => $headers,
'rows' => $records,
'rowCount' => count($records)
];
} catch (Exception $e) {
return [
'success' => false,
'error' => 'CSV parsing failed',
'details' => $e->getMessage(),
'action' => 'Check if the file is a valid CSV with comma delimiters'
];
}
}
/**
* Extract campaign number from filename
* Expected pattern: *_CAMPAIGNNUM_*.csv
*/
public function extractCampaignNumber($filename) {
// Remove .csv extension
$nameWithoutExt = preg_replace('/\.csv$/i', '', $filename);
// Split by underscore
$parts = explode('_', $nameWithoutExt);
// Try to find campaign number (should be in part 2 based on blueprint)
if (isset($parts[2]) && is_numeric($parts[2])) {
return [
'success' => true,
'campaignNumber' => $parts[2]
];
}
// Try to find any numeric part
foreach ($parts as $part) {
if (is_numeric($part) && strlen($part) >= 4) {
return [
'success' => true,
'campaignNumber' => $part,
'warning' => 'Campaign number found but not in expected position'
];
}
}
return [
'success' => false,
'error' => 'Campaign number not found in filename',
'details' => "Expected pattern: *_CAMPAIGN#_*.csv, got: $filename",
'action' => 'Rename file to include campaign number (e.g., Global_123456_Assets.csv)'
];
}
/**
* Transform CSV data per Make.com blueprint logic
*/
public function transformData($rows, $campaignNumber, $businessUnit) {
$transformedRows = [];
$this->errors = [];
$this->warnings = [];
$rowNumber = 1; // Start from 1 (after header)
foreach ($rows as $inputRow) {
$rowNumber++;
// Get columns by position (0-indexed)
$cols = array_values($inputRow);
// Validate required columns exist
if (count($cols) < 12) {
$this->errors[] = [
'row' => $rowNumber,
'error' => 'Insufficient columns',
'details' => 'Row has ' . count($cols) . ' columns, expected at least 12'
];
continue;
}
// Extract data from columns
$productId = $cols[1] ?? ''; // col2
$category = $cols[3] ?? ''; // col4
$media = $cols[4] ?? ''; // col5
$subMedia = $cols[5] ?? ''; // col6
$destination = $cols[6] ?? ''; // col7
$format = $cols[7] ?? ''; // col8
$supplyDate = $cols[8] ?? ''; // col9
$liveDate = $cols[9] ?? ''; // col10
$endDate = $cols[10] ?? ''; // col11
$specialInstructions = $cols[11] ?? ''; // col12
// Validate required fields
if (empty($productId)) {
$this->warnings[] = [
'row' => $rowNumber,
'warning' => 'Missing Product ID',
'severity' => 'high'
];
}
// Transform dates (parse and add 1 month per blueprint)
$transformedSupplyDate = $this->transformDate($supplyDate, $rowNumber, 'Supply date');
$transformedLiveDate = $this->transformDate($liveDate, $rowNumber, 'Live date');
$transformedEndDate = $this->transformDate($endDate, $rowNumber, 'End date');
// Create one output row for each ISO code (16 markets)
foreach ($this->isoCodes as $isoCode) {
$isoCode = trim($isoCode);
// Extract country code (2nd part after dash)
$parts = explode('-', $isoCode);
$country = isset($parts[1]) ? $parts[1] : '';
// Build output row per blueprint mapping
$transformedRow = [
'Title' => $productId . '_' . $isoCode,
'Status' => 'Booked',
'Category' => $category,
'Media' => $media,
'Sub media' => $subMedia,
'Destination' => $destination,
'Format' => $format,
'Supply date' => $transformedSupplyDate,
'Live date' => $transformedLiveDate,
'End date' => $transformedEndDate,
'Special instructions' => $specialInstructions,
'Language' => $isoCode,
'Country' => $country,
'Quantity' => '1',
'Pos Code' => '',
'Duplicate' => '',
'Product ID' => $productId,
'Creative Execution' => $productId
];
$transformedRows[] = $transformedRow;
}
}
return [
'success' => count($this->errors) === 0,
'rows' => $transformedRows,
'inputRowCount' => count($rows),
'outputRowCount' => count($transformedRows),
'errors' => $this->errors,
'warnings' => $this->warnings
];
}
/**
* Transform date: parse, add 1 month, format DD/MM/YYYY
*/
private function transformDate($dateString, $rowNumber, $fieldName) {
if (empty($dateString)) {
$this->warnings[] = [
'row' => $rowNumber,
'warning' => "Empty $fieldName",
'severity' => 'medium'
];
return '';
}
try {
// Try parsing with different formats per blueprint logic
// Format 1: "DD MMM YYYY" (length check in blueprint: length = 17)
if (strlen($dateString) >= 11 && preg_match('/\d{1,2}\s+[A-Za-z]{3}\s+\d{4}/', $dateString)) {
$date = Carbon::createFromFormat('d M Y', $dateString);
}
// Format 2: "DD/MM/YYYY"
else if (preg_match('/\d{1,2}\/\d{1,2}\/\d{4}/', $dateString)) {
$date = Carbon::createFromFormat('d/m/Y', $dateString);
}
// Try ISO format
else {
$date = Carbon::parse($dateString);
}
// Add 1 month per blueprint
$date->addMonth();
// Format as DD/MM/YYYY
return $date->format('d/m/Y');
} catch (Exception $e) {
$this->errors[] = [
'row' => $rowNumber,
'error' => "Invalid $fieldName format",
'details' => "Unable to parse '$dateString' - " . $e->getMessage(),
'action' => 'Use format DD/MM/YYYY or DD MMM YYYY'
];
return $dateString; // Return original if can't parse
}
}
/**
* Generate output filename per blueprint
*/
public function generateFilename($campaignNumber, $businessUnit, $isoCode) {
$timestamp = time();
// Extract country from ISO code
$parts = explode('-', $isoCode);
$country = isset($parts[1]) ? $parts[1] : '';
// Format: OMG{campaign}_GlobalACIngest_{BU}-{Country}_{timestamp}.csv
return "OMG{$campaignNumber}_GlobalACIngest_{$businessUnit}-{country}_{$timestamp}.csv";
}
/**
* Get errors
*/
public function getErrors() {
return $this->errors;
}
/**
* Get warnings
*/
public function getWarnings() {
return $this->warnings;
}
/**
* Has errors
*/
public function hasErrors() {
return count($this->errors) > 0;
}
/**
* Has warnings
*/
public function hasWarnings() {
return count($this->warnings) > 0;
}
}