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>
355 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|