commit f4a4a8b1e00d1426f6425790f272c0ac3c0a284e Author: Dorian Contal Date: Wed Apr 23 08:49:08 2025 +0200 Initialisation du package diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a6b7eed --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "sudalys/import-service", + "description": "Service Symfony pour l'importation et la validation de fichiers CSV et Excel (XLS/XLSX), avec transformation automatisée.", + "keywords": ["import","csv","excel","xls","xlsx","validation","sudalys"], + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Dorian Contal", + "email": "d.contal@sudalys.fr" + } + ], + "require": { + "php": "^8.1", + "league/csv": "^9.11", + "phpoffice/phpspreadsheet": "^1.18", + "box/spout": "^3.3", + "psr/log": "^3.0" + }, + "autoload": { + "psr-4": { + "App\\Service\\Import\\": "src/Service/Import/" + } + }, + "minimum-stability": "stable", + "prefer-stable": true + } \ No newline at end of file diff --git a/src/Service/Import/CsvImportService.php b/src/Service/Import/CsvImportService.php new file mode 100644 index 0000000..de1a852 --- /dev/null +++ b/src/Service/Import/CsvImportService.php @@ -0,0 +1,64 @@ +header = $header; + $this->regexListe = $regexListe; + + $headerSpec = new ExactHeaderSpecification($this->header); + + $columnSpecs = []; + + foreach ($header as $col) { + // Si regex définie → priorité + if (isset($regexListe[$col])) { + $columnSpecs[$col] = new RegexColumnSpecification( + $regexListe[$col], + "Le format de la colonne '$col' est invalide." + ); + } + elseif (in_array($col, $requiredColumns)) { + $columnSpecs[$col] = new RequiredColumnSpecification("La colonne '$col' est obligatoire."); + } + } + + + $fileProcessor = new BasicCsvFileProcessor(); + $validator = new CsvValidator($headerSpec, $columnSpecs); + $errorHandler = new LoggingErrorHandler($logger); + $mainProcessor = new MainProcessor(); + + $this->importer = new CsvImporter($fileProcessor, $validator,$errorHandler, + $mainProcessor, + ); + + $result = $this->importer->import($filePath); + + return [ + 'success' => $result->isSuccess(), + 'errors' => $result->getErrors(), + 'processedCount' => $result->getProcessedCount(), + 'message' => $result->getMessage(), + ]; + } + +} \ No newline at end of file diff --git a/src/Service/Import/CsvImporter.php b/src/Service/Import/CsvImporter.php new file mode 100644 index 0000000..8063309 --- /dev/null +++ b/src/Service/Import/CsvImporter.php @@ -0,0 +1,53 @@ +fileProcessor = $fileProcessor; + $this->validator = $validator; + $this->errorHandler = $errorHandler; + $this->dataProcessor = $dataProcessor; + } + + public function import(string $filePath): ImportResult + { + $rows = $this->fileProcessor->processfile($filePath); + $header = $this->fileProcessor->getHeader($filePath); + + $validationResult = $this->validator->validate($header, $rows); + + if ($validationResult->hasErrors()) { + $this->errorHandler->handle($validationResult->getErrors()); + } + + $this->dataProcessor->process($rows); + + + return new ImportResult( + $validationResult->getErrors(), + !$validationResult->hasErrors(), + $validationResult->hasErrors() ? 'Import terminé avec erreurs.' : ' Import réussi.', + count($rows) + ); + } +} diff --git a/src/Service/Import/CsvValidator.php b/src/Service/Import/CsvValidator.php new file mode 100644 index 0000000..94b0e38 --- /dev/null +++ b/src/Service/Import/CsvValidator.php @@ -0,0 +1,38 @@ +headerSpecification->isSatisfiedBy($header)) { + $errors[] = $this->headerSpecification->getErrorMessage(); + $errors = array_merge($errors, $this->headerSpecification->getErrorsHeader($header)); + } + $rowIndex = 2; + foreach ($records as $row){ + $rowErrors = []; + foreach ($this->columnSpecification as $column => $specification) { + if (!isset($row[$column]) || !$specification->isSatisfiedBy($row[$column])) { + $rowErrors[] = "Ligne $rowIndex: " . $specification->getErrorMessage(); + } + } + if (!empty($rowErrors)) { + $errors = array_merge($errors, $rowErrors); + } else { + $validData[] = $row; + } + $rowIndex++; + } + return new ValidationResult( $errors, $validData); + } + +} \ No newline at end of file diff --git a/src/Service/Import/ErrorHandler/LoggingErrorHandler.php b/src/Service/Import/ErrorHandler/LoggingErrorHandler.php new file mode 100644 index 0000000..fef2bc7 --- /dev/null +++ b/src/Service/Import/ErrorHandler/LoggingErrorHandler.php @@ -0,0 +1,24 @@ +logger = $logger; + } + + + public function handle(array $errors): void + { + foreach ($errors as $error) { + $this->logger->error($error); + } + + } + } \ No newline at end of file diff --git a/src/Service/Import/ExcelFileTransformer.php b/src/Service/Import/ExcelFileTransformer.php new file mode 100644 index 0000000..3458675 --- /dev/null +++ b/src/Service/Import/ExcelFileTransformer.php @@ -0,0 +1,102 @@ +convertXlsToXlsx($filePath); + } elseif ($extension === 'xlsx') { + $filePath = $this->purgeXlsx($filePath); + } else { + throw new \RuntimeException("Format non pris en charge : .$extension"); + } + + $reader = ReaderEntityFactory::createXLSXReader(); + $reader->open($filePath); + + $tmpCsvPath = tempnam(sys_get_temp_dir(), 'converted_') . '.' . $type; + $handle = fopen($tmpCsvPath, 'w'); + + $rowIndex = 1; + foreach ($reader->getSheetIterator() as $sheet) { + if ($sheet->getName() !== '__ACTIVE_SHEET__') { + continue; + } + foreach ($sheet->getRowIterator() as $row) { + if ($rowIndex++ < $headerPosition) { + continue; + } + + $cells = array_map(function($cell) { + $value = $cell->getValue(); + if ($value instanceof \DateTime) { + return $value->format('Y-m-d H:i'); + } + return $value; + }, $row->getCells()); + + fputcsv($handle, $cells); + } + break; + } + $reader->close(); + fclose($handle); + + return $tmpCsvPath; + } + + private function convertXlsToXlsx(string $xlsFilePath): string + { + if (!file_exists($xlsFilePath)) { + throw new \RuntimeException("Le fichier XLS n'existe pas : $xlsFilePath"); + } + $reader = IOFactory::createReader('Xls'); + $reader->setReadDataOnly(false); + $spreadsheet = $reader->load($xlsFilePath); + $activeSheet = $spreadsheet->getSheet($spreadsheet->getActiveSheetIndex()); + $originalTitle = $activeSheet->getTitle(); + $activeSheet->setTitle('__ACTIVE_SHEET__'); + $xlsxFilePath = tempnam(sys_get_temp_dir(), 'converted_') . '.xlsx'; + $writer = new Xlsx($spreadsheet); + $writer->setPreCalculateFormulas(false); + $writer->save($xlsxFilePath); + $activeSheet->setTitle($originalTitle); + return $xlsxFilePath; + } + + private function purgeXlsx(string $filePath): string + { + $reader = IOFactory::createReader('Xlsx'); + $reader->setReadDataOnly(false); + $spreadsheet = $reader->load($filePath); + + $activeSheet = $spreadsheet->getSheet($spreadsheet->getActiveSheetIndex()); + $originalTitle = $activeSheet->getTitle(); + $activeSheet->setTitle('__ACTIVE_SHEET__'); + + $purgedPath = tempnam(sys_get_temp_dir(), 'purged_') . '.xlsx'; + $writer = new Xlsx($spreadsheet); + $writer->setPreCalculateFormulas(false); + $writer->save($purgedPath); + + // Optionally restore original title in memory + $activeSheet->setTitle($originalTitle); + + return $purgedPath; + } +} diff --git a/src/Service/Import/Interfaces/ColumnSpecificationInterface.php b/src/Service/Import/Interfaces/ColumnSpecificationInterface.php new file mode 100644 index 0000000..50a167d --- /dev/null +++ b/src/Service/Import/Interfaces/ColumnSpecificationInterface.php @@ -0,0 +1,8 @@ +setHeaderOffset(0); + return iterator_to_array($csv->getRecords(), false); + } + + public function getHeader(string $path): array + { + $csv = Reader::createFromPath($path, 'r'); + $csv->setHeaderOffset(0); + + return $csv->getHeader(); + } +} \ No newline at end of file diff --git a/src/Service/Import/Processor/DatabaseDataProcessor.php b/src/Service/Import/Processor/DatabaseDataProcessor.php new file mode 100644 index 0000000..0ec5b75 --- /dev/null +++ b/src/Service/Import/Processor/DatabaseDataProcessor.php @@ -0,0 +1,16 @@ +processors[] = new DatabaseDataProcessor(); + // $this->processors[] = new AutreProcessor(); // Ajoute d'autres ici au besoin + } + + public function process(array $row): void + { + foreach ($this->processors as $processor) { + $processor->process($row); + } + } +} diff --git a/src/Service/Import/Result/ImportResult.php b/src/Service/Import/Result/ImportResult.php new file mode 100644 index 0000000..415e292 --- /dev/null +++ b/src/Service/Import/Result/ImportResult.php @@ -0,0 +1,39 @@ +errors = $errors; + $this->success = $success; + $this->message = $message; + $this->processedCount = $processedCount; + } + public function getErrors(): array + { + return $this->errors; + } + public function isSuccess(): bool + { + return $this->success; + } + public function getMessage(): string + { + return $this->message; + } + public function getProcessedCount(): int + { + return $this->processedCount; + } + public function hasErrors(): bool + { + return !empty($this->errors); + } +} \ No newline at end of file diff --git a/src/Service/Import/Result/ValidationResult.php b/src/Service/Import/Result/ValidationResult.php new file mode 100644 index 0000000..84ce1f4 --- /dev/null +++ b/src/Service/Import/Result/ValidationResult.php @@ -0,0 +1,27 @@ +errors = $errors; + $this->validData = $validData; + } + public function getErrors(): array + { + return $this->errors; + } + public function getValidData(): array + { + return $this->validData; + } + public function hasErrors(): bool + { + return !empty($this->errors); + } +} \ No newline at end of file diff --git a/src/Service/Import/Specification/ExactHeaderSpecification.php b/src/Service/Import/Specification/ExactHeaderSpecification.php new file mode 100644 index 0000000..22cec2b --- /dev/null +++ b/src/Service/Import/Specification/ExactHeaderSpecification.php @@ -0,0 +1,37 @@ +expectedHeader = $expectedHeader; + } + + public function isSatisfiedBy(array $header): bool + { + return $header === $this->expectedHeader; + } + + public function getErrorMessage(): string + { + return $this->errorMessage; + } + public function getErrorsHeader(array $header): array + { + $errorsHeader = []; + foreach ($this->expectedHeader as $key => $value) { + if (!isset($header[$key]) || $header[$key] !== $value) { + $errorsHeader[] = "La colonne $value est manquante ou ne correspond pas à la valeur attendue [ $header[$key] ]"; + + } + } + return $errorsHeader; + } + +} \ No newline at end of file diff --git a/src/Service/Import/Specification/NumericSpecification.php b/src/Service/Import/Specification/NumericSpecification.php new file mode 100644 index 0000000..d152a71 --- /dev/null +++ b/src/Service/Import/Specification/NumericSpecification.php @@ -0,0 +1,26 @@ +pattern = $pattern; + $this->errorMessage = $errorMessage; + } + + public function isSatisfiedBy(string $value): bool + { + return preg_match($this->pattern, $value) === 1; + } + + public function getErrorMessage(): string + { + return $this->errorMessage; + } + } + diff --git a/src/Service/Import/Specification/RegexColumnSpecification.php b/src/Service/Import/Specification/RegexColumnSpecification.php new file mode 100644 index 0000000..0a36f0b --- /dev/null +++ b/src/Service/Import/Specification/RegexColumnSpecification.php @@ -0,0 +1,25 @@ +pattern = $pattern; + $this->errorMessage = $errorMessage; + } + + public function isSatisfiedBy(string $value): bool + { + return preg_match($this->pattern, $value) === 1; + } + + public function getErrorMessage(): string + { + return $this->errorMessage; + } +} \ No newline at end of file diff --git a/src/Service/Import/Specification/RequiredColumnSpecification.php b/src/Service/Import/Specification/RequiredColumnSpecification.php new file mode 100644 index 0000000..1cf7568 --- /dev/null +++ b/src/Service/Import/Specification/RequiredColumnSpecification.php @@ -0,0 +1,25 @@ +errorMessage = $errorMessage; + } + + public function isSatisfiedBy(mixed $value): bool + { + return !empty($value) && trim($value) !== ''; + } + + public function getErrorMessage(): string + { + return $this->errorMessage; + } +}