nhance/app/Libraries/RuleImportService.php
2025-12-11 18:42:45 +05:30

1410 lines
45 KiB
PHP

<?php
// declare(strict_types=1);
namespace App\Libraries;
use CodeIgniter\CLI\CLI;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use RuntimeException;
use Exception;
/**
* RuleImportService - Refactored Version
*
* Handles motor policy rule imports from Excel/CSV with:
* - Type-safe validation
* - Extensible architecture
* - Clear separation of concerns
* - Comprehensive error handling
*/
class RuleImportService
{
// File format constants
private const SUPPORTED_EXTENSIONS = ['xlsx', 'xls', 'csv'];
// Commission type constants
private const COMMISSION_PERCENTAGE = 'percentage';
private const COMMISSION_FLAT = 'flat';
private const COMMISSION_COMPOSITE = 'composite';
private const COMMISSION_TIERED = 'tiered';
private const COMMISSION_TYPES = [
self::COMMISSION_PERCENTAGE,
self::COMMISSION_FLAT,
self::COMMISSION_COMPOSITE,
self::COMMISSION_TIERED
];
// Column name constants
private const COL_SNO = 'S.No';
private const COL_RULE_NAME = 'Rule Name';
private const COL_POLICY_BUSINESS_TYPE = 'Policy Business Type';
private const COL_POLICY_NAME = 'Policy Name';
private const COL_PREMIUM_TYPE = 'Premium Type';
private const COL_VEHICLE_TYPE = 'Vehicle Type';
private const COL_VEHICLE_SUB_TYPE = 'Vehicle Sub Type';
private const COL_MAKE = 'Make';
private const COL_MODEL = 'Model';
private const COL_CC_MIN = 'CC Min';
private const COL_CC_MAX = 'CC Max';
private const COL_FUEL_TYPE = 'Fuel Type';
private const COL_VEHICLE_AGE_MIN = 'Vehicle Age Min';
private const COL_VEHICLE_AGE_MAX = 'Vehicle Age Max';
private const COL_VEHICLE_WEIGHT_MIN = 'Vehicle Weight Min';
private const COL_VEHICLE_WEIGHT_MAX = 'Vehicle Weight Max';
private const COL_RTO_STATE = 'RTO State';
private const COL_RTO_CITY = 'RTO Code';
private const COL_RENEWAL_TYPE = 'Renewal Type';
private const COL_RENEWAL_SUB_TYPE = 'Renewal Sub Type';
private const COL_COMMISSION_TYPE = 'Commission Type';
private const COL_COMMISSION_VALUE = 'Commission Value';
private const COL_COMMISSION_PARAMS = 'Commission Params(TP:OD:PA)';
private const COL_COMMISSION_PARAMS_OD = 'Commission Params (OD)';
private const COL_COMMISSION_PARAMS_PA = 'Commission Params (PA)';
private const COL_COMMISSION_PARAMS_TP = 'Commission Params (TP)';
private const COL_NOTES = 'Notes';
protected array $expectedColumns;
protected array $columnValidators;
protected array $incomingData;
protected string $annotatedDir;
protected string $department;
protected string $uploadedCommissionFileID;
private ValidationResult $validationResult;
public function __construct()
{
$this->initializeExpectedColumns();
$this->initializeValidators();
$this->setupAnnotatedDirectory();
}
/**
* Initialize expected column headers
*/
private function initializeExpectedColumns(): void
{
$this->expectedColumns = [
// self::COL_RULE_NAME,
// self::COL_POLICY_BUSINESS_TYPE,
// self::COL_POLICY_NAME,
self::COL_SNO,
self::COL_PREMIUM_TYPE,
self::COL_VEHICLE_TYPE,
self::COL_VEHICLE_SUB_TYPE,
self::COL_MAKE,
self::COL_MODEL,
self::COL_CC_MIN,
self::COL_CC_MAX,
self::COL_FUEL_TYPE,
self::COL_VEHICLE_AGE_MIN,
self::COL_VEHICLE_AGE_MAX,
self::COL_VEHICLE_WEIGHT_MIN,
self::COL_VEHICLE_WEIGHT_MAX,
self::COL_RTO_STATE,
self::COL_RTO_CITY,
self::COL_RENEWAL_TYPE,
// self::COL_RENEWAL_SUB_TYPE,
self::COL_COMMISSION_TYPE,
self::COL_COMMISSION_VALUE,
self::COL_COMMISSION_PARAMS_TP,
self::COL_COMMISSION_PARAMS_OD,
self::COL_COMMISSION_PARAMS_PA,
// self::COL_NOTES
];
}
/**
* Initialize column validators
*/
private function initializeValidators(): void
{
$this->columnValidators = [
self::COL_CC_MIN => 'validateNumeric',
self::COL_CC_MAX => 'validateNumeric',
self::COL_VEHICLE_AGE_MIN => 'validateNumeric',
self::COL_VEHICLE_AGE_MAX => 'validateNumeric',
self::COL_VEHICLE_WEIGHT_MIN => 'validateNumeric',
self::COL_VEHICLE_WEIGHT_MAX => 'validateNumeric',
self::COL_FUEL_TYPE => 'validateCommaList',
self::COL_COMMISSION_TYPE => 'validateCommissionType',
self::COL_COMMISSION_VALUE => 'validateNumeric',
// self::COL_COMMISSION_PARAMS => 'validateCompositeParams',
];
}
/**
* Setup annotated directory for error files
*/
private function setupAnnotatedDirectory(): void
{
$this->annotatedDir = WRITEPATH . 'uploads/commission/files/';
if (!is_dir($this->annotatedDir)) {
mkdir($this->annotatedDir, 0755, true);
}
}
/**
* Process uploaded file
*
* @param string $tempFilePath Temporary file path
* @param string $originalName Original filename
* @return array Processing result
*/
public function processUpload(array $params): array
{
// dd($params);
$startTime = microtime(true);
$this->incomingData = $params;
try {
$this->department = $params['department'];
$this->uploadedCommissionFileID = $params['id'];
$tempFilePath = WRITEPATH.'uploads/commission/files/'.$params['file_name'];
$originalName = $params['file_name'];
// Validate file extension
$extension = $this->getFileExtension($params['file_name']);
$this->validateFileExtension($extension);
// Load spreadsheet
$spreadsheet = $this->loadSpreadsheet($tempFilePath, $extension);
$sheet = $spreadsheet->getActiveSheet();
// Extract and validate headers
$headerMap = $this->extractHeaders($sheet);
$this->validateHeaders($headerMap);
// dd($headerMap);
// Process rows
$this->validationResult = new ValidationResult();
$rules = $this->processRows($sheet, $headerMap);
// Generate annotated file if errors exist
$annotatedPath = null;
if ($this->validationResult->hasErrors()) {
$annotatedPath = $this->createAnnotatedFile(
$spreadsheet,
$headerMap,
$this->validationResult->getErrors(),
$originalName
);
}
$duration = round(microtime(true) - $startTime, 2);
return $this->buildSuccessResponse($rules, $annotatedPath, $duration);
} catch (Exception $e) {
log_message('error', "RuleImportService failed: " . $e->getMessage());
return $this->buildErrorResponse($e);
}
}
/**
* Get file extension
*/
private function getFileExtension(string $filename): string
{
return strtolower(pathinfo($filename, PATHINFO_EXTENSION));
}
/**
* Validate file extension
*/
private function validateFileExtension(string $extension): void
{
if (!in_array($extension, self::SUPPORTED_EXTENSIONS, true)) {
throw new RuntimeException(
"Unsupported file type: {$extension}. " .
"Supported types: " . implode(', ', self::SUPPORTED_EXTENSIONS)
);
}
}
/**
* Load spreadsheet based on file type
*/
private function loadSpreadsheet(string $filePath, string $extension): Spreadsheet
{
if ($extension === 'csv') {
$reader = IOFactory::createReader('Csv');
$reader->setDelimiter(',');
$reader->setEnclosure('"');
return $reader->load($filePath);
}
return IOFactory::load($filePath);
}
/**
* Extract header row and create column mapping
*/
private function extractHeaders($sheet): array
{
$highestCol = $sheet->getHighestColumn();
$headerRowData = $sheet->rangeToArray("A1:{$highestCol}1", null, true, true, true);
$headerRow = array_values($headerRowData[1]);
$headerMap = [];
foreach ($headerRow as $index => $header) {
$label = trim((string)$header);
if ($label !== '') {
$headerMap[$label] = $index + 1;
}
}
return $headerMap;
}
/**
* Validate all required headers are present
*/
private function validateHeaders(array $headerMap): void
{
$missingColumns = array_diff($this->expectedColumns, array_keys($headerMap));
if (!empty($missingColumns)) {
throw new RuntimeException(
"Missing required columns: " . implode(', ', $missingColumns)
);
}
}
/**
* Process all data rows
*/
private function processRows($sheet, array $headerMap): array
{
$rules = [];
$highestRow = $sheet->getHighestRow();
for ($rowNum = 2; $rowNum <= $highestRow; $rowNum++) {
$rowData = $this->extractRowData($sheet, $headerMap, $rowNum);
// Skip empty rows
if ($this->isEmptyRow($rowData)) {
continue;
}
// Validate row
$this->validateRow($rowData, $rowNum);
// Convert to rule if no errors
if (!$this->validationResult->hasRowErrors($rowNum)) {
$rules[] = $this->convertRowToRule($rowData);
}
}
return $rules;
}
/**
* Extract data from a single row
// */
// private function extractRowData($sheet, array $headerMap, int $rowNum): array
// {
// $rowData = [];
// foreach ($headerMap as $colName => $colIndex) {
// $cell = $sheet->getCellByColumnAndRow($colIndex, $rowNum);
// $rowData[$colName] = trim((string)$cell->getValue());
// }
// return $rowData;
// }
/**
* Extract data from a single row
*
* @param \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet
* @param array $headerMap Column name => column index mapping
* @param int $rowNum Row number to extract
* @return array Associative array of column name => value
*/
private function extractRowData($sheet, array $headerMap, int $rowNum): array
{
$rowData = [];
foreach ($headerMap as $colName => $colIndex) {
// Convert column index to letter (A, B, C, etc.)
$colLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
// Get cell value using coordinate (e.g., "A2", "B2")
$cellCoordinate = $colLetter . $rowNum;
$value = $sheet->getCell($cellCoordinate)->getValue();
// Handle different data types
if ($value instanceof \PhpOffice\PhpSpreadsheet\RichText\RichText) {
$value = $value->getPlainText();
}
$rowData[$colName] = trim((string)$value);
}
return $rowData;
}
/**
* Check if row is empty
*/
private function isEmptyRow(array $rowData): bool
{
foreach ($rowData as $value) {
if ($value !== '') {
return false;
}
}
return true;
}
/**
* Validate a single row
*/
private function validateRow(array $rowData, int $rowNum): void
{
// Field-level validation
foreach ($this->columnValidators as $colName => $validatorMethod) {
$value = $rowData[$colName] ?? '';
if (!$this->$validatorMethod($value)) {
$errorMessage = $this->buildValidationMessage($value, $colName, $validatorMethod);
$this->validationResult->addError($rowNum, $colName, $errorMessage);
}
}
// Range validations
$this->validateRangePair($rowData, self::COL_CC_MIN, self::COL_CC_MAX, $rowNum);
$this->validateRangePair($rowData, self::COL_VEHICLE_AGE_MIN, self::COL_VEHICLE_AGE_MAX, $rowNum);
$this->validateRangePair($rowData, self::COL_VEHICLE_WEIGHT_MIN, self::COL_VEHICLE_WEIGHT_MAX, $rowNum);
// Business rule validations
$this->validateBusinessRules($rowData, $rowNum);
}
/**
* Validate range pairs (min <= max)
*/
private function validateRangePair(
array $rowData,
string $minCol,
string $maxCol,
int $rowNum
): void {
$min = $rowData[$minCol] ?? '';
$max = $rowData[$maxCol] ?? '';
if ($min !== '' && $max !== '' && is_numeric($min) && is_numeric($max)) {
if ((float)$min > (float)$max) {
$this->validationResult->addError(
$rowNum,
$minCol,
"{$min} : ERROR - {$minCol} cannot be greater than {$maxCol}"
);
}
}
}
/**
* Validate business logic rules
*/
private function validateBusinessRules(array $rowData, int $rowNum): void
{
$commissionType = strtolower($rowData[self::COL_COMMISSION_TYPE] ?? '');
$commissionValue = $rowData[self::COL_COMMISSION_VALUE] ?? '';
// $commissionParams = $rowData[self::COL_COMMISSION_PARAMS] ?? '';
$commissionParams_od = $rowData[self::COL_COMMISSION_PARAMS_OD] ?? '';
$commissionParams_pa = $rowData[self::COL_COMMISSION_PARAMS_PA] ?? '';
$commissionParams_tp = $rowData[self::COL_COMMISSION_PARAMS_TP] ?? '';
// Composite commission requires params
if ($commissionType === self::COMMISSION_COMPOSITE && empty($commissionParams_od) && empty($commissionParams_pa) && empty($commissionParams_tp)) {
$this->validationResult->addError(
$rowNum,
self::COL_COMMISSION_PARAMS_OD,
"Required when commission type is composite"
);
}
// Flat commission requires value
if ($commissionType === self::COMMISSION_FLAT && empty($commissionValue)) {
$this->validationResult->addError(
$rowNum,
self::COL_COMMISSION_VALUE,
"Required when commission type is flat"
);
}
// Percentage commission requires value
if ($commissionType === self::COMMISSION_PERCENTAGE && empty($commissionValue)) {
$this->validationResult->addError(
$rowNum,
self::COL_COMMISSION_VALUE,
"Required when commission type is percentage"
);
}
}
// ==================== VALIDATORS ====================
/**
* Validate numeric values
*/
protected function validateNumeric(string $value): bool
{
if ($value === '') {
return true;
}
return filter_var($value, FILTER_VALIDATE_FLOAT) !== false;
}
/**
* Validate comma-separated list
*/
protected function validateCommaList(string $value): bool
{
if ($value === '') {
return true;
}
return preg_match("/^[a-zA-Z0-9\s,\-]+$/", $value) === 1;
}
/**
* Validate commission type
*/
protected function validateCommissionType(string $value): bool
{
if (trim($value) === '') {
return false; // Required field
}
return in_array(strtolower(trim($value)), self::COMMISSION_TYPES, true);
}
/**
* Validate composite parameters (TP:OD:PA format)
*/
protected function validateCompositeParams(string $value): bool
{
if ($value === '') {
return true;
}
// Format: number:number or number:number:number
if (!preg_match('/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)(?::(\d+(?:\.\d+)?))?$/', $value, $matches)) {
return false;
}
// Validate percentage ranges (0-100)
$values = array_filter($matches, 'is_numeric');
foreach ($values as $val) {
if ($val < 0 || $val > 100) {
return false;
}
}
return true;
}
/**
* Build validation error message
*/
protected function buildValidationMessage(
string $value,
string $colName,
string $validatorMethod
): string {
$messages = [
'validateNumeric' => 'expecting numeric value only',
'validateCommaList' => 'expecting comma-separated values',
'validateCommissionType' => 'expecting one of: ' . implode(', ', self::COMMISSION_TYPES),
'validateCompositeParams' => 'expecting format TP:OD:PA (e.g., 10:25:0)'
];
$suffix = $messages[$validatorMethod] ?? 'invalid value';
return empty($value) ? "ERROR - {$suffix}" : "{$value} : ERROR - {$suffix}";
}
// ==================== CONVERSION ====================
/**
* Convert row data to rule JSON structure
*/
protected function convertRowToRuleV1(array $rowData): array
{
$rule = [
'id' => $this->generateRuleId($rowData),
'name' => $this->sanitize($rowData[self::COL_RULE_NAME] ?? ''),
'policy_business_type' => $this->sanitize($rowData[self::COL_POLICY_BUSINESS_TYPE] ?? ''),
'policy_name' => $this->sanitize($rowData[self::COL_POLICY_NAME] ?? ''),
'premium_type' => $this->sanitize($rowData[self::COL_PREMIUM_TYPE] ?? ''),
'vehicle' => $this->buildVehicleData($rowData),
'fuel_type' => $this->parseFuelTypes($rowData[self::COL_FUEL_TYPE] ?? ''),
'vehicle_age' => $this->buildRangeData(
self::COL_VEHICLE_AGE_MIN,
self::COL_VEHICLE_AGE_MAX,
$rowData
),
'vehicle_weight' => $this->buildRangeData(
self::COL_VEHICLE_WEIGHT_MIN,
self::COL_VEHICLE_WEIGHT_MAX,
$rowData
),
'rto' => $this->buildRtoData($rowData),
'renewal_type' => $this->sanitize($rowData[self::COL_RENEWAL_TYPE] ?? ''),
'commission' => $this->buildCommissionData($rowData),
'notes' => $this->sanitize($rowData[self::COL_NOTES] ?? '')
];
return $this->removeEmptyValues($rule);
}
/**
* Generate unique rule ID
*/
private function generateRuleId(array $rowData): string
{
return 'rule_' . substr(md5(json_encode($rowData) . time()), 0, 13);
}
/**
* Build vehicle data structure
*/
private function buildVehicleData(array $rowData): array
{
return [
'type' => $this->sanitize($rowData[self::COL_VEHICLE_TYPE] ?? ''),
'sub_type' => $this->sanitize($rowData[self::COL_VEHICLE_SUB_TYPE] ?? ''),
'make' => $this->sanitize($rowData[self::COL_MAKE] ?? ''),
'model' => $this->sanitize($rowData[self::COL_MODEL] ?? ''),
'cc' => [
'min' => $this->parseFloat($rowData[self::COL_CC_MIN] ?? ''),
'max' => $this->parseFloat($rowData[self::COL_CC_MAX] ?? '')
]
];
}
/**
* Build range data (min/max)
*/
private function buildRangeData(string $minCol, string $maxCol, array $rowData): array
{
return [
'min' => $this->parseFloat($rowData[$minCol] ?? ''),
'max' => $this->parseFloat($rowData[$maxCol] ?? '')
];
}
/**
* Build RTO data structure
*/
private function buildRtoData(array $rowData): array
{
return [
'state' => $this->sanitize($rowData[self::COL_RTO_STATE] ?? ''),
'city' => $this->sanitize($rowData[self::COL_RTO_CITY] ?? '')
];
}
/**
* Build commission data structure
*/
private function buildCommissionData(array $rowData): array
{
$commissionType = strtolower($this->sanitize($rowData[self::COL_COMMISSION_TYPE] ?? ''));
$commission = [
'type' => $commissionType,
'value' => $this->parseFloat($rowData[self::COL_COMMISSION_VALUE] ?? '')
];
if ($commissionType === self::COMMISSION_COMPOSITE) {
$commission['components'] = $this->parseCompositeParams(
$rowData[self::COL_COMMISSION_PARAMS] ?? ''
);
}
return $commission;
}
/**
* Parse composite commission parameters
*/
private function parseCompositeParams(string $params): array
{
if (empty($params)) {
return [];
}
$parts = explode(':', $params);
$components = [];
$premiumTypes = ['tp_premium', 'od_premium', 'pa_premium'];
foreach ($premiumTypes as $index => $premiumType) {
if (isset($parts[$index]) && $parts[$index] !== '') {
$percentage = (float)$parts[$index];
if ($percentage > 0) {
$components[] = [
'on' => $premiumType,
'percentage' => $percentage
];
}
}
}
return $components;
}
/**
* Parse fuel types from comma-separated string
*/
private function parseFuelTypes(string $fuelTypeString): array
{
if (empty($fuelTypeString)) {
return [];
}
return array_values(array_filter(
array_map('trim', explode(',', $fuelTypeString)),
fn($value) => $value !== ''
));
}
/**
* Sanitize string value
*/
private function sanitize(string $value): string
{
return trim($value);
}
/**
* Parse float value
*/
private function parseFloat(string $value): ?float
{
if ($value === '' || !is_numeric($value)) {
return null;
}
return (float)$value;
}
/**
* Remove null and empty values from array recursively
*/
private function removeEmptyValues(array $data): array
{
return array_filter($data, function($value) {
if (is_array($value)) {
$filtered = $this->removeEmptyValues($value);
return !empty($filtered);
}
return $value !== null && $value !== '';
});
}
// ==================== ANNOTATION ====================
/**
* Create annotated file with errors
*/
// private function createAnnotatedFile(
// Spreadsheet $spreadsheet,
// array $headerMap,
// array $errors,
// string $originalName
// ): string {
// $sheet = $spreadsheet->getActiveSheet();
// // Add error column header
// $lastCol = $sheet->getHighestColumn();
// $lastColIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($lastCol);
// $errorColIndex = $lastColIndex + 1;
// $sheet->setCellValueByColumnAndRow($errorColIndex, 1, 'Validation Errors');
// // Annotate errors
// foreach ($errors as $rowNum => $columns) {
// $errorMessages = [];
// foreach ($columns as $colName => $message) {
// $colIndex = $headerMap[$colName];
// $cell = $sheet->getCellByColumnAndRow($colIndex, $rowNum);
// // Set error value in original column
// $cell->setValueExplicit(
// $message,
// \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING
// );
// $errorMessages[] = "{$colName}: {$message}";
// }
// // Set combined error message
// $sheet->setCellValueByColumnAndRow(
// $errorColIndex,
// $rowNum,
// implode(' | ', $errorMessages)
// );
// }
// return $this->saveAnnotatedFile($spreadsheet, $originalName);
// }
/**
* Create annotated file with errors (Enhanced with styling)
*/
private function createAnnotatedFile(
Spreadsheet $spreadsheet,
array $headerMap,
array $errors,
string $originalName
): string {
$sheet = $spreadsheet->getActiveSheet();
// Add error column header
$lastCol = $sheet->getHighestColumn();
$lastColIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($lastCol);
$errorColIndex = $lastColIndex + 1;
$errorColLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($errorColIndex);
// Set header for validation errors column
$headerCoordinate = $errorColLetter . '1';
$sheet->setCellValue($headerCoordinate, 'Validation Errors');
// Optional: Style the header
$sheet->getStyle($headerCoordinate)->applyFromArray([
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => 'FFD700']
]
]);
// Annotate errors
foreach ($errors as $rowNum => $columns) {
$errorMessages = [];
foreach ($columns as $colName => $message) {
if (!isset($headerMap[$colName])) {
continue; // Skip if column not found
}
$colIndex = $headerMap[$colName];
$colLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
$cellCoordinate = $colLetter . $rowNum;
// Set error value in original column
$sheet->setCellValueExplicit(
$cellCoordinate,
$message,
\PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING
);
// Optional: Highlight error cells in red
$sheet->getStyle($cellCoordinate)->applyFromArray([
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => 'FFB6C1'] // Light red
],
'font' => ['color' => ['rgb' => 'FF0000']] // Red text
]);
$errorMessages[] = "{$colName}: {$message}";
}
// Set combined error message in the validation errors column
$errorCellCoordinate = $errorColLetter . $rowNum;
$sheet->setCellValue($errorCellCoordinate, implode(' | ', $errorMessages));
// Optional: Style the summary error cell
$sheet->getStyle($errorCellCoordinate)->applyFromArray([
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => 'FFA500'] // Orange
]
]);
}
// Auto-size the error column for better readability
$sheet->getColumnDimension($errorColLetter)->setAutoSize(true);
return $this->saveAnnotatedFile($spreadsheet, $originalName);
}
/**
* Save annotated spreadsheet
*/
private function saveAnnotatedFile(Spreadsheet $spreadsheet, string $originalName): string
{
$safeName = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $originalName);
$extension = $this->getFileExtension($originalName);
$timestamp = time();
$filename = "annotated_{$timestamp}_{$safeName}";
$filename = "annotated_{$safeName}";
$filepath = $this->annotatedDir . $filename;
if ($extension === 'csv') {
$writer = IOFactory::createWriter($spreadsheet, 'Csv');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->save($filepath);
} else {
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
if (!str_ends_with($filepath, '.xlsx')) {
$filepath .= '.xlsx';
}
$writer->save($filepath);
}
return $filepath;
}
// ==================== RESPONSE BUILDERS ====================
/**
* Build success response
*/
private function buildSuccessResponse(
array $rules,
?string $annotatedPath,
float $duration
): array {
$hasErrors = $this->validationResult->hasErrors();
return [
'status' => $hasErrors ? 'error' : 'success',
'rules' => $rules,
'errors' => $hasErrors ? $this->validationResult->getErrors() : [],
'annotated_file' => $annotatedPath,
'statistics' => [
'total_rules' => count($rules),
'error_count' => $this->validationResult->getErrorCount(),
'duration_seconds' => $duration
]
];
}
/**
* Build error response
*/
private function buildErrorResponse(Exception $e): array
{
return [
'status' => 'exception',
'message' => $e->getMessage(),
'error_type' => get_class($e)
];
}
/**
* Convert row data to rule engine JSON structure
*
* This function transforms Excel row data into a rule engine format with:
* - Conditions: Field-based criteria with operators (==, >=, <=, >, <, in)
* - Calculation: Commission calculation logic (percentage, flat/fixed, composite)
*/
protected function convertRowToRule(array $rowData): array
{
$temp_rule_id = $this->generateRuleId($rowData);
//rule_e07f52d4366a2_32_oct2025_3_com
$conditions = $this->buildConditions($rowData);
// $temp_rule_id .= '_'.$this->incomingData['id'].'_'. strtolower(date('MY')).'_'.(count($conditions));
$monthFormatted = strtoupper(date('M', strtotime($this->incomingData['commission_month'])))
. date('Y', strtotime($this->incomingData['commission_month']));
$temp_rule_id .= '_'.$this->incomingData['id'].'_'.$monthFormatted;
$calculation = $this->buildCalculation($rowData);
// $temp_rule_id .= '_'.substr($calculation['type'], 0, 3);
$rule = [
'id' => $temp_rule_id,
'name' => $this->sanitize($rowData[self::COL_RULE_NAME] ?? ''),
'department' => $this->department,
'is_deleted' => false,
'file_id' => $this->uploadedCommissionFileID,
'conditions' => $conditions,
'calculation' => $calculation,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $this->incomingData['created_by'],
'updated_by' => $this->incomingData['created_by'],
];
return $rule;
}
/**
* Build conditions array from row data
* Converts Excel columns into rule engine conditions with operators
*/
private function buildConditions(array $rowData): array
{
$conditions = [];
// Vehicle Type - exact match
if (!empty($rowData[self::COL_VEHICLE_TYPE])) {
$vehicleTypes = $this->parseCommaList($rowData[self::COL_VEHICLE_TYPE]);
if (count($vehicleTypes) > 1) {
// Multiple values: use 'in' operator
$conditions[] = [
'field' => 'vehicle_type',
'operator' => 'in',
'value' => $vehicleTypes
];
} else {
// Single value: use '==' operator
$conditions[] = [
'field' => 'vehicle_type',
'operator' => '==',
'value' => $vehicleTypes[0]
];
}
}
// Vehicle Sub Type
if (!empty($rowData[self::COL_VEHICLE_SUB_TYPE])) {
$conditions[] = [
'field' => 'vehicle_sub_type',
'operator' => '==',
'value' => $this->sanitize($rowData[self::COL_VEHICLE_SUB_TYPE])
];
}
// Make
if (!empty($rowData[self::COL_MAKE])) {
$makes = $this->parseCommaList($rowData[self::COL_MAKE]);
if (count($makes) > 1) {
$conditions[] = [
'field' => 'make',
'operator' => 'in',
'value' => $makes
];
} else {
$conditions[] = [
'field' => 'make',
'operator' => '==',
'value' => $makes[0]
];
}
}
// Model
if (!empty($rowData[self::COL_MODEL])) {
$models = $this->parseCommaList($rowData[self::COL_MODEL]);
if (count($models) > 1) {
$conditions[] = [
'field' => 'model',
'operator' => 'in',
'value' => $models
];
} else {
$conditions[] = [
'field' => 'model',
'operator' => '==',
'value' => $models[0]
];
}
}
// CC (Cubic Capacity) - Range handling
$ccMin = $rowData[self::COL_CC_MIN] ?? '';
$ccMax = $rowData[self::COL_CC_MAX] ?? '';
if ($ccMin !== '' && $ccMax !== '') {
if ($ccMin === $ccMax) {
// Exact value
$conditions[] = [
'field' => 'cubic_capacity',
'operator' => '==',
'value' => (float)$ccMin
];
} else {
// Range: min and max
$conditions[] = [
'field' => 'cubic_capacity',
'operator' => '>=',
'value' => (float)$ccMin
];
$conditions[] = [
'field' => 'cubic_capacity',
'operator' => '<=',
'value' => (float)$ccMax
];
}
} elseif ($ccMin !== '') {
// Only minimum specified
$conditions[] = [
'field' => 'cubic_capacity',
'operator' => '>=',
'value' => (float)$ccMin
];
} elseif ($ccMax !== '') {
// Only maximum specified
$conditions[] = [
'field' => 'cubic_capacity',
'operator' => '<=',
'value' => (float)$ccMax
];
}
// Fuel Type
if (!empty($rowData[self::COL_FUEL_TYPE])) {
$fuelTypes = $this->parseCommaList($rowData[self::COL_FUEL_TYPE]);
if (count($fuelTypes) > 1) {
$conditions[] = [
'field' => 'fuel_type',
'operator' => 'in',
'value' => $fuelTypes
];
} else {
$conditions[] = [
'field' => 'fuel_type',
'operator' => '==',
'value' => $fuelTypes[0]
];
}
}
// Vehicle Age - Range handling
$ageMin = $rowData[self::COL_VEHICLE_AGE_MIN] ?? '';
$ageMax = $rowData[self::COL_VEHICLE_AGE_MAX] ?? '';
if ($ageMin !== '' && $ageMax !== '') {
if ($ageMin === $ageMax) {
$conditions[] = [
'field' => 'vehicle_age',
'operator' => '==',
'value' => (int)$ageMin
];
} else {
$conditions[] = [
'field' => 'vehicle_age',
'operator' => '>=',
'value' => (int)$ageMin
];
$conditions[] = [
'field' => 'vehicle_age',
'operator' => '<=',
'value' => (int)$ageMax
];
}
} elseif ($ageMin !== '') {
$conditions[] = [
'field' => 'vehicle_age',
'operator' => '>=',
'value' => (int)$ageMin
];
} elseif ($ageMax !== '') {
$conditions[] = [
'field' => 'vehicle_age',
'operator' => '<=',
'value' => (int)$ageMax
];
}
// Vehicle Weight - Range handling
$weightMin = $rowData[self::COL_VEHICLE_WEIGHT_MIN] ?? '';
$weightMax = $rowData[self::COL_VEHICLE_WEIGHT_MAX] ?? '';
if ($weightMin !== '' && $weightMax !== '') {
if ($weightMin === $weightMax) {
$conditions[] = [
'field' => 'weight',
'operator' => '==',
'value' => (float)$weightMin
];
} else {
$conditions[] = [
'field' => 'weight',
'operator' => '>=',
'value' => (float)$weightMin
];
$conditions[] = [
'field' => 'weight',
'operator' => '<=',
'value' => (float)$weightMax
];
}
} elseif ($weightMin !== '') {
$conditions[] = [
'field' => 'weight',
'operator' => '>=',
'value' => (float)$weightMin
];
} elseif ($weightMax !== '') {
$conditions[] = [
'field' => 'weight',
'operator' => '<=',
'value' => (float)$weightMax
];
}
// RTO State
if (!empty($rowData[self::COL_RTO_STATE])) {
$states = $this->parseCommaList($rowData[self::COL_RTO_STATE]);
if (count($states) > 1) {
$conditions[] = [
'field' => 'geo_rto_state',
'operator' => 'in',
'value' => $states
];
} else {
$conditions[] = [
'field' => 'geo_rto_state',
'operator' => '==',
'value' => $states[0]
];
}
}
// RTO City
if (!empty($rowData[self::COL_RTO_CITY])) {
$cities = $this->parseCommaList($rowData[self::COL_RTO_CITY]);
if (count($cities) > 1) {
$conditions[] = [
'field' => 'geo_rto_city',
'operator' => 'in',
'value' => $cities
];
} else {
$conditions[] = [
'field' => 'geo_rto_city',
'operator' => '==',
'value' => $cities[0]
];
}
}
// Policy Business Type
if (!empty($rowData[self::COL_POLICY_BUSINESS_TYPE])) {
$conditions[] = [
'field' => 'policy_business_type',
'operator' => '==',
'value' => $this->sanitize($rowData[self::COL_POLICY_BUSINESS_TYPE])
];
}
// Policy Name
if (!empty($rowData[self::COL_POLICY_NAME])) {
$conditions[] = [
'field' => 'product',
'operator' => '==',
'value' => $this->sanitize($rowData[self::COL_POLICY_NAME])
];
}
// Premium Type (could be TP, OD, Comprehensive, etc.)
if (!empty($rowData[self::COL_PREMIUM_TYPE])) {
$premiumTypes = $this->parseCommaList($rowData[self::COL_PREMIUM_TYPE]);
if (count($premiumTypes) > 1) {
$conditions[] = [
'field' => 'policy_type',
'operator' => 'in',
'value' => $premiumTypes
];
} else {
$conditions[] = [
'field' => 'policy_type',
'operator' => '==',
'value' => $premiumTypes[0]
];
}
}
// Renewal Type
if (!empty($rowData[self::COL_RENEWAL_TYPE])) {
$renewalTypes = $this->parseCommaList($rowData[self::COL_RENEWAL_TYPE]);
if (count($renewalTypes) > 1) {
$conditions[] = [
'field' => 'renewal_type',
'operator' => 'in',
'value' => $renewalTypes
];
} else {
$conditions[] = [
'field' => 'renewal_type',
'operator' => '==',
'value' => $renewalTypes[0]
];
}
}
// Renewal Sub Type
if (!empty($rowData[self::COL_RENEWAL_SUB_TYPE])) {
$conditions[] = [
'field' => 'renewal_sub_type',
'operator' => '==',
'value' => $this->sanitize($rowData[self::COL_RENEWAL_SUB_TYPE])
];
}
return $conditions;
}
/**
* Build calculation object based on commission type
*/
private function buildCalculation(array $rowData): array
{
$commissionType = strtolower(trim($rowData[self::COL_COMMISSION_TYPE] ?? ''));
$commissionValue = $rowData[self::COL_COMMISSION_VALUE] ?? '';
$commissionParams = $rowData[self::COL_COMMISSION_PARAMS] ?? '';
$commissionParams_od = $rowData[self::COL_COMMISSION_PARAMS_OD] ?? '';
$commissionParams_pa = $rowData[self::COL_COMMISSION_PARAMS_PA] ?? '';
$commissionParams_tp = $rowData[self::COL_COMMISSION_PARAMS_TP] ?? '';
$commission = $commissionParams_tp.':'.$commissionParams_od.':'. $commissionParams_pa;
switch ($commissionType) {
case 'composite':
return $this->buildCompositeCalculation($commission);
case 'percentage':
return $this->buildPercentageCalculation($commissionValue);
case 'flat':
case 'fixed':
return $this->buildFixedCalculation($commissionValue);
case 'tiered':
// For future implementation
return [
'type' => 'tiered',
'tiers' => [] // To be implemented based on requirements
];
default:
// Default to percentage if not specified
return $this->buildPercentageCalculation($commissionValue);
}
}
/**
* Build composite calculation (multiple premium components)
*/
private function buildCompositeCalculation(string $params): array
{
$components = [];
if (!empty($params)) {
$parts = explode(':', $params);
$premiumTypes = ['tp_premium', 'od_premium', 'pa_premium'];
foreach ($premiumTypes as $index => $premiumType) {
if (isset($parts[$index]) && trim($parts[$index]) !== '') {
$percentage = (float)$parts[$index];
if ($percentage > 0) {
$components[] = [
'percentage' => $percentage,
'on' => $premiumType
];
}
}
}
}
return [
'type' => 'composite',
'components' => $components
];
}
/**
* Build percentage calculation (single percentage on total/specific premium)
*/
private function buildPercentageCalculation(string $value): array
{
$percentage = !empty($value) ? (float)$value : 0;
return [
'type' => 'percentage',
'value' => $percentage,
'on' => 'premium' // Default to total premium
];
}
/**
* Build fixed/flat calculation (absolute amount)
*/
private function buildFixedCalculation(string $value): array
{
$amount = !empty($value) ? (float)$value : 0;
return [
'type' => 'fixed',
'value' => $amount,
'on' => 'premium' // Default to total premium
];
}
/**
* Parse comma-separated list into array
*/
private function parseCommaList(string $value): array
{
if (empty($value)) {
return [];
}
return array_values(array_filter(
array_map('trim', explode(',', $value)),
fn($item) => $item !== ''
));
}
}
/**
* ValidationResult - Helper class for managing validation errors
*/
class ValidationResult
{
private array $errors = [];
public function addError(int $row, string $column, string $message): void
{
$this->errors[$row][$column] = $message;
}
public function hasErrors(): bool
{
return !empty($this->errors);
}
public function hasRowErrors(int $row): bool
{
return isset($this->errors[$row]) && !empty($this->errors[$row]);
}
public function getErrors(): array
{
return $this->errors;
}
public function getErrorCount(): int
{
return array_sum(array_map('count', $this->errors));
}
public function getRowErrors(int $row): array
{
return $this->errors[$row] ?? [];
}
}