Implement excel based import/export

This commit is contained in:
barisgit 2025-08-01 19:32:49 +02:00 committed by Jan Böhmer
parent c5751b2aa6
commit facfb37383
7 changed files with 690 additions and 12 deletions

View file

@ -117,9 +117,29 @@
"symfony/stopwatch": "7.3.*", "symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*" "symfony/web-profiler-bundle": "7.3.*"
}, },
"suggest": { "sort-packages": true,
"ext-bcmath": "Used to improve price calculation performance", "allow-plugins": {
"ext-gmp": "Used to improve price calculation performanice" "composer/package-versions-deprecated": true,
"symfony/flex": true,
"phpstan/extension-installer": true,
"symfony/runtime": true,
"php-http/discovery": true
}
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
}, },
"config": { "config": {
"preferred-install": { "preferred-install": {
@ -170,4 +190,5 @@
"docker": true "docker": true
} }
} }
}
} }

373
composer.lock generated
View file

@ -2500,6 +2500,85 @@
], ],
"time": "2022-01-17T14:14:24+00:00" "time": "2022-01-17T14:14:24+00:00"
}, },
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{ {
"name": "daverandom/libdns", "name": "daverandom/libdns",
"version": "v2.1.0", "version": "v2.1.0",
@ -6514,6 +6593,190 @@
}, },
"time": "2023-07-31T13:36:50+00:00" "time": "2023-07-31T13:36:50+00:00"
}, },
{
"name": "maennchen/zipstream-php",
"version": "3.1.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "6187e9cc4493da94b9b63eb2315821552015fca9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/6187e9cc4493da94b9b63eb2315821552015fca9",
"reference": "6187e9cc4493da94b9b63eb2315821552015fca9",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.1"
},
"require-dev": {
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^10.0",
"vimeo/psalm": "^5.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.1"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2024-10-10T12:33:01+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{ {
"name": "masterminds/html5", "name": "masterminds/html5",
"version": "2.10.0", "version": "2.10.0",
@ -8034,6 +8297,112 @@
}, },
"time": "2024-11-09T15:12:26+00:00" "time": "2024-11-09T15:12:26+00:00"
}, },
{
"name": "phpoffice/phpspreadsheet",
"version": "4.5.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "2ea9786632e6fac1aee601b6e426bcc723d8ce13"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2ea9786632e6fac1aee601b6e426bcc723d8ce13",
"reference": "2ea9786632e6fac1aee601b6e426bcc723d8ce13",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/4.5.0"
},
"time": "2025-07-24T05:15:59+00:00"
},
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "2.3.0", "version": "2.3.0",
@ -20974,9 +21343,9 @@
"ext-json": "*", "ext-json": "*",
"ext-mbstring": "*" "ext-mbstring": "*"
}, },
"platform-dev": [], "platform-dev": {},
"platform-overrides": { "platform-overrides": {
"php": "8.2.0" "php": "8.2.0"
}, },
"plugin-api-version": "2.3.0" "plugin-api-version": "2.6.0"
} }

View file

@ -59,6 +59,8 @@ class ImportType extends AbstractType
'XML' => 'xml', 'XML' => 'xml',
'CSV' => 'csv', 'CSV' => 'csv',
'YAML' => 'yaml', 'YAML' => 'yaml',
'XLSX' => 'xlsx',
'XLS' => 'xls',
], ],
'label' => 'export.format', 'label' => 'export.format',
'disabled' => $disabled, 'disabled' => $disabled,

View file

@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use function Symfony\Component\String\u; use function Symfony\Component\String\u;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Writer\Xls;
/** /**
* Use this class to export an entity to multiple file formats. * Use this class to export an entity to multiple file formats.
@ -52,7 +55,7 @@ class EntityExporter
protected function configureOptions(OptionsResolver $resolver): void protected function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefault('format', 'csv'); $resolver->setDefault('format', 'csv');
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']); $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
$resolver->setDefault('csv_delimiter', ';'); $resolver->setDefault('csv_delimiter', ';');
$resolver->setAllowedTypes('csv_delimiter', 'string'); $resolver->setAllowedTypes('csv_delimiter', 'string');
@ -88,6 +91,11 @@ class EntityExporter
$options = $resolver->resolve($options); $options = $resolver->resolve($options);
//Handle Excel formats by converting from CSV
if (in_array($options['format'], ['xlsx', 'xls'])) {
return $this->exportToExcel($entities, $options);
}
//If include children is set, then we need to add the include_children group //If include children is set, then we need to add the include_children group
$groups = [$options['level']]; $groups = [$options['level']];
if ($options['include_children']) { if ($options['include_children']) {
@ -122,6 +130,73 @@ class EntityExporter
throw new CircularReferenceException('Circular reference detected for object of type '.get_class($object)); throw new CircularReferenceException('Circular reference detected for object of type '.get_class($object));
} }
/**
* Exports entities to Excel format (xlsx or xls).
*
* @param AbstractNamedDBElement[] $entities The entities to export
* @param array $options The export options
*
* @return string The Excel file content as binary string
*/
protected function exportToExcel(array $entities, array $options): string
{
//First get CSV data using existing serializer
$csvOptions = $options;
$csvOptions['format'] = 'csv';
$groups = [$options['level']];
if ($options['include_children']) {
$groups[] = 'include_children';
}
$csvData = $this->serializer->serialize($entities, 'csv',
[
'groups' => $groups,
'as_collection' => true,
'csv_delimiter' => $options['csv_delimiter'],
'partdb_export' => true,
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...),
]
);
//Convert CSV to Excel
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
$rows = explode("\n", $csvData);
$rowIndex = 1;
foreach ($rows as $row) {
if (trim($row) === '') {
continue;
}
$columns = str_getcsv($row, $options['csv_delimiter']);
$colIndex = 1;
foreach ($columns as $column) {
$cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
$worksheet->setCellValue($cellCoordinate, $column);
$colIndex++;
}
$rowIndex++;
}
//Save to memory stream
if ($options['format'] === 'xlsx') {
$writer = new Xlsx($spreadsheet);
} else {
$writer = new Xls($spreadsheet);
}
ob_start();
$writer->save('php://output');
$content = ob_get_contents();
ob_end_clean();
return $content;
}
/** /**
* Exports an Entity or an array of entities to multiple file formats. * Exports an Entity or an array of entities to multiple file formats.
* *
@ -168,6 +243,12 @@ class EntityExporter
case 'json': case 'json':
$content_type = 'application/json'; $content_type = 'application/json';
break; break;
case 'xlsx':
$content_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
break;
case 'xls':
$content_type = 'application/vnd.ms-excel';
break;
} }
$response->headers->set('Content-Type', $content_type); $response->headers->set('Content-Type', $content_type);

View file

@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Psr\Log\LoggerInterface;
/** /**
* @see \App\Tests\Services\ImportExportSystem\EntityImporterTest * @see \App\Tests\Services\ImportExportSystem\EntityImporterTest
@ -50,7 +53,7 @@ class EntityImporter
*/ */
private const ENCODINGS = ["ASCII", "UTF-8", "ISO-8859-1", "ISO-8859-15", "Windows-1252", "UTF-16", "UTF-32"]; private const ENCODINGS = ["ASCII", "UTF-8", "ISO-8859-1", "ISO-8859-15", "Windows-1252", "UTF-16", "UTF-32"];
public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator) public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator, protected LoggerInterface $logger)
{ {
} }
@ -102,7 +105,7 @@ class EntityImporter
foreach ($names as $name) { foreach ($names as $name) {
//Count indentation level (whitespace characters at the beginning of the line) //Count indentation level (whitespace characters at the beginning of the line)
$identSize = strlen($name)-strlen(ltrim($name)); $identSize = strlen($name) - strlen(ltrim($name));
//If the line is intended more than the last line, we have a new parent element //If the line is intended more than the last line, we have a new parent element
if ($identSize > end($indentations)) { if ($identSize > end($indentations)) {
@ -195,7 +198,10 @@ class EntityImporter
} }
//The [] behind class_name denotes that we expect an array. //The [] behind class_name denotes that we expect an array.
$entities = $this->serializer->deserialize($data, $options['class'].'[]', $options['format'], $entities = $this->serializer->deserialize(
$data,
$options['class'] . '[]',
$options['format'],
[ [
'groups' => $groups, 'groups' => $groups,
'csv_delimiter' => $options['csv_delimiter'], 'csv_delimiter' => $options['csv_delimiter'],
@ -204,7 +210,8 @@ class EntityImporter
'partdb_import' => true, 'partdb_import' => true,
//Disable API Platform normalizer, as we don't want to use it here //Disable API Platform normalizer, as we don't want to use it here
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true, SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
]); ]
);
//Ensure we have an array of entity elements. //Ensure we have an array of entity elements.
if (!is_array($entities)) { if (!is_array($entities)) {
@ -279,7 +286,7 @@ class EntityImporter
'path_delimiter' => '->', //The delimiter used to separate the path elements in the name of a structural element 'path_delimiter' => '->', //The delimiter used to separate the path elements in the name of a structural element
]); ]);
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']); $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
$resolver->setAllowedTypes('csv_delimiter', 'string'); $resolver->setAllowedTypes('csv_delimiter', 'string');
$resolver->setAllowedTypes('preserve_children', 'bool'); $resolver->setAllowedTypes('preserve_children', 'bool');
$resolver->setAllowedTypes('class', 'string'); $resolver->setAllowedTypes('class', 'string');
@ -335,6 +342,33 @@ class EntityImporter
*/ */
public function importFile(File $file, array $options = [], array &$errors = []): array public function importFile(File $file, array $options = [], array &$errors = []): array
{ {
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
if (in_array($options['format'], ['xlsx', 'xls'])) {
$this->logger->info('Converting Excel file to CSV', [
'filename' => $file->getFilename(),
'format' => $options['format'],
'delimiter' => $options['csv_delimiter']
]);
$csvData = $this->convertExcelToCsv($file, $options['csv_delimiter']);
$options['format'] = 'csv';
$this->logger->debug('Excel to CSV conversion completed', [
'csv_length' => strlen($csvData),
'csv_lines' => substr_count($csvData, "\n") + 1
]);
// Log the converted CSV for debugging (first 1000 characters)
$this->logger->debug('Converted CSV preview', [
'csv_preview' => substr($csvData, 0, 1000) . (strlen($csvData) > 1000 ? '...' : '')
]);
return $this->importString($csvData, $options, $errors);
}
return $this->importString($file->getContent(), $options, $errors); return $this->importString($file->getContent(), $options, $errors);
} }
@ -354,10 +388,103 @@ class EntityImporter
'xml' => 'xml', 'xml' => 'xml',
'csv', 'tsv' => 'csv', 'csv', 'tsv' => 'csv',
'yaml', 'yml' => 'yaml', 'yaml', 'yml' => 'yaml',
'xlsx' => 'xlsx',
'xls' => 'xls',
default => null, default => null,
}; };
} }
/**
* Converts Excel file to CSV format using PhpSpreadsheet.
*
* @param File $file The Excel file to convert
* @param string $delimiter The CSV delimiter to use
*
* @return string The CSV data as string
*/
protected function convertExcelToCsv(File $file, string $delimiter = ';'): string
{
try {
$this->logger->debug('Loading Excel file', ['path' => $file->getPathname()]);
$spreadsheet = IOFactory::load($file->getPathname());
$worksheet = $spreadsheet->getActiveSheet();
$csvData = [];
$highestRow = $worksheet->getHighestRow();
$highestColumn = $worksheet->getHighestColumn();
$this->logger->debug('Excel file dimensions', [
'rows' => $highestRow,
'columns_detected' => $highestColumn,
'worksheet_title' => $worksheet->getTitle()
]);
$highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
for ($row = 1; $row <= $highestRow; $row++) {
$rowData = [];
// Read all columns using numeric index
for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) {
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
try {
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
$rowData[] = $cellValue ?? '';
} catch (\Exception $e) {
$this->logger->warning('Error reading cell value', [
'cell' => "{$col}{$row}",
'error' => $e->getMessage()
]);
$rowData[] = '';
}
}
$csvRow = implode($delimiter, array_map(function ($value) use ($delimiter) {
$value = (string) $value;
if (strpos($value, $delimiter) !== false || strpos($value, '"') !== false || strpos($value, "\n") !== false) {
return '"' . str_replace('"', '""', $value) . '"';
}
return $value;
}, $rowData));
$csvData[] = $csvRow;
// Log first few rows for debugging
if ($row <= 3) {
$this->logger->debug("Row {$row} converted", [
'original_data' => $rowData,
'csv_row' => $csvRow,
'first_cell_raw' => $worksheet->getCell("A{$row}")->getValue(),
'first_cell_calculated' => $worksheet->getCell("A{$row}")->getCalculatedValue()
]);
}
}
$result = implode("\n", $csvData);
$this->logger->info('Excel to CSV conversion successful', [
'total_rows' => count($csvData),
'total_characters' => strlen($result)
]);
$this->logger->debug('Full CSV data', [
'csv_data' => $result
]);
return $result;
} catch (\Exception $e) {
$this->logger->error('Failed to convert Excel to CSV', [
'file' => $file->getFilename(),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/** /**
* This functions corrects the parent setting based on the children value of the parent. * This functions corrects the parent setting based on the children value of the parent.
* *

View file

@ -26,6 +26,7 @@ use App\Entity\Parts\Category;
use App\Services\ImportExportSystem\EntityExporter; use App\Services\ImportExportSystem\EntityExporter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use PhpOffice\PhpSpreadsheet\IOFactory;
class EntityExporterTest extends WebTestCase class EntityExporterTest extends WebTestCase
{ {
@ -76,7 +77,40 @@ class EntityExporterTest extends WebTestCase
$this->assertSame('application/json', $response->headers->get('Content-Type')); $this->assertSame('application/json', $response->headers->get('Content-Type'));
$this->assertNotEmpty($response->headers->get('Content-Disposition')); $this->assertNotEmpty($response->headers->get('Content-Disposition'));
}
public function testExportToExcel(): void
{
$entities = $this->getEntities();
$xlsxData = $this->service->exportEntities($entities, ['format' => 'xlsx', 'level' => 'simple']);
$this->assertNotEmpty($xlsxData);
$tempFile = tempnam(sys_get_temp_dir(), 'test_export') . '.xlsx';
file_put_contents($tempFile, $xlsxData);
$spreadsheet = IOFactory::load($tempFile);
$worksheet = $spreadsheet->getActiveSheet();
$this->assertSame('name', $worksheet->getCell('A1')->getValue());
$this->assertSame('full_name', $worksheet->getCell('B1')->getValue());
$this->assertSame('Enitity 1', $worksheet->getCell('A2')->getValue());
$this->assertSame('Enitity 1', $worksheet->getCell('B2')->getValue());
unlink($tempFile);
}
public function testExportExcelFromRequest(): void
{
$entities = $this->getEntities();
$request = new Request();
$request->request->set('format', 'xlsx');
$request->request->set('level', 'simple');
$response = $this->service->exportEntityFromRequest($entities, $request);
$this->assertSame('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
$this->assertStringContainsString('export_Category_simple.xlsx', $response->headers->get('Content-Disposition'));
} }
} }

View file

@ -36,6 +36,9 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationListInterface; use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\HttpFoundation\File\File;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
#[Group('DB')] #[Group('DB')]
class EntityImporterTest extends WebTestCase class EntityImporterTest extends WebTestCase
@ -207,6 +210,10 @@ EOT;
yield ['json', 'json']; yield ['json', 'json'];
yield ['yaml', 'yml']; yield ['yaml', 'yml'];
yield ['yaml', 'YAML']; yield ['yaml', 'YAML'];
yield ['xlsx', 'xlsx'];
yield ['xlsx', 'XLSX'];
yield ['xls', 'xls'];
yield ['xls', 'XLS'];
} }
#[DataProvider('formatDataProvider')] #[DataProvider('formatDataProvider')]
@ -342,4 +349,41 @@ EOT;
$this->assertSame($category, $results[0]->getCategory()); $this->assertSame($category, $results[0]->getCategory());
$this->assertSame('test,test2', $results[0]->getTags()); $this->assertSame('test,test2', $results[0]->getTags());
} }
public function testImportExcelFileProjects(): void
{
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
$worksheet->setCellValue('A1', 'name');
$worksheet->setCellValue('B1', 'comment');
$worksheet->setCellValue('A2', 'Test Excel 1');
$worksheet->setCellValue('B2', 'Test Excel 1 notes');
$worksheet->setCellValue('A3', 'Test Excel 2');
$worksheet->setCellValue('B3', 'Test Excel 2 notes');
$tempFile = tempnam(sys_get_temp_dir(), 'test_excel') . '.xlsx';
$writer = new Xlsx($spreadsheet);
$writer->save($tempFile);
$file = new File($tempFile);
$errors = [];
$results = $this->service->importFile($file, [
'class' => Project::class,
'format' => 'xlsx',
'csv_delimiter' => ';',
], $errors);
$this->assertCount(2, $results);
$this->assertEmpty($errors);
$this->assertContainsOnlyInstancesOf(Project::class, $results);
$this->assertSame('Test Excel 1', $results[0]->getName());
$this->assertSame('Test Excel 1 notes', $results[0]->getComment());
$this->assertSame('Test Excel 2', $results[1]->getName());
$this->assertSame('Test Excel 2 notes', $results[1]->getComment());
unlink($tempFile);
}
} }