diff --git a/src/Controller/AssemblyController.php b/src/Controller/AssemblyController.php index 54cc1abb..9106f677 100644 --- a/src/Controller/AssemblyController.php +++ b/src/Controller/AssemblyController.php @@ -139,6 +139,7 @@ class AssemblyController extends AbstractController 'required' => true, 'choices' => [ 'assembly.bom_import.type.json' => 'json', + 'assembly.bom_import.type.csv' => 'csv', 'assembly.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew', ] ]); diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php index e7672623..2f6236af 100644 --- a/src/Services/ImportExportSystem/BOMImporter.php +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -38,6 +38,7 @@ use InvalidArgumentException; use League\Csv\Reader; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Contracts\Translation\TranslatorInterface; use UnexpectedValueException; @@ -48,6 +49,9 @@ use Symfony\Component\Validator\ConstraintViolation; */ class BOMImporter { + private const IMPORT_TYPE_JSON = 'json'; + private const IMPORT_TYPE_CSV = 'csv'; + private const IMPORT_TYPE_KICAD_PCB = 'kicad_pcbnew'; private const MAP_KICAD_PCB_FIELDS = [ 0 => 'Id', @@ -67,6 +71,7 @@ class BOMImporter private readonly PartRepository $partRepository, private readonly ManufacturerRepository $manufacturerRepository, private readonly CategoryRepository $categoryRepository, + private readonly DBElementRepository $projectBOMEntryRepository, private readonly DBElementRepository $assemblyBOMEntryRepositor, private readonly TranslatorInterface $translator ) { @@ -109,7 +114,7 @@ class BOMImporter * Converts the given file into an ImporterResult with an array of BOM entries using the given options and save them into the given assembly. * The changes are not saved into the database yet. */ - public function importFileIntoAssembly(File $file, Assembly $assembly, array $options): ImporterResult + public function importFileIntoAssembly(UploadedFile $file, Assembly $assembly, array $options): ImporterResult { $importerResult = $this->fileToImporterResult($file, $options, AssemblyBOMEntry::class); @@ -135,8 +140,44 @@ class BOMImporter /** * Converts the given file into an ImporterResult with an array of BOM entries using the given options. */ - public function fileToImporterResult(File $file, array $options, string $objectType = ProjectBOMEntry::class): ImporterResult + public function fileToImporterResult(UploadedFile $file, array $options, string $objectType = ProjectBOMEntry::class): ImporterResult { + $result = new ImporterResult(); + + //Available file endings depending on the import type + $validExtensions = match ($options['type']) { + self::IMPORT_TYPE_KICAD_PCB => ['kicad_pcb'], + self::IMPORT_TYPE_JSON => ['json'], + self::IMPORT_TYPE_CSV => ['csv'], + default => [], + }; + + //Get the file extension of the uploaded file + $fileExtension = pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION); + + //Check whether the file extension is valid + if ($validExtensions === []) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.invalid_import_type', + 'import.type' + )); + + return $result; + } else if (!in_array(strtolower($fileExtension), $validExtensions, true)) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.invalid_file_extension', + 'file.extension', + $fileExtension, + [ + '%extension%' => $fileExtension, + '%importType%' => $this->translator->trans('assembly.bom_import.type.'.$options['type']), + '%allowedExtensions%' => implode(', ', $validExtensions), + ] + )); + + return $result; + } + return $this->stringToImporterResult($file->getContent(), $options, $objectType); } @@ -180,7 +221,7 @@ class BOMImporter * Import string data into an array of BOM entries, which are not yet assigned to a project. * @param string $data The data to import * @param array $options An array of options - * @return ProjectBOMEntry[]|AssemblyBOMEntry[] An array of imported entries + * @return ImporterResult An result of imported entries or a violation list */ public function stringToImporterResult(string $data, array $options, string $objectType = ProjectBOMEntry::class): ImporterResult { @@ -188,10 +229,17 @@ class BOMImporter $resolver = $this->configureOptions($resolver); $options = $resolver->resolve($options); + $defaultImporterResult = new ImporterResult(); + $defaultImporterResult->addViolation($this->buildJsonViolation( + 'validator.bom_importer.invalid_import_type', + 'import.type' + )); + return match ($options['type']) { - 'kicad_pcbnew' => $this->parseKiCADPCB($data, $objectType), - 'json' => $this->parseJson($data, $options, $objectType), - default => throw new InvalidArgumentException('Invalid import type!'), + self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $objectType), + self::IMPORT_TYPE_JSON => $this->parseJson($data, $objectType), + self::IMPORT_TYPE_CSV => $this->parseCsv($data, $objectType), + default => $defaultImporterResult, }; } @@ -228,7 +276,7 @@ class BOMImporter $bom_entry->setName($entry['Designation']); } - $bom_entry->setMountnames($entry['Designator'] ?? ''); + $bom_entry->setMountnames($entry['Designator']); $bom_entry->setComment($entry['Supplier and ref'] ?? ''); $bom_entry->setQuantity((float) ($entry['Quantity'] ?? 1)); @@ -295,7 +343,37 @@ class BOMImporter return $this->validationService->validateBOMEntries($mapped_entries, $options); } - private function parseJson(string $data, array $options = [], string $objectType = ProjectBOMEntry::class): ImporterResult + /** + * Parses the given JSON data into an ImporterResult while validating and transforming entries according to the + * specified options and object type. If violations are encountered during parsing, they are added to the result. + * + * The structure of each entry in the JSON data is validated to ensure that required fields (e.g., quantity, and name) + * are present, and optional composite fields, like `part` and its sub-properties, meet specific criteria. Various + * conditions are checked, including whether the provided values are the correct types, and if relationships (like + * matching parts or manufacturers) are resolved successfully. + * + * Violations are added for: + * - Missing or invalid `quantity` values. + * - Non-string `name` values. + * - Invalid structure or missing sub-properties in `part`. + * - Incorrect or unresolved references to parts and their information, such as `id`, `name`, `manufacturer_product_number` + * (mpnr), `internal_part_number` (ipn), or `description`. + * - Inconsistent or absent manufacturer information. + * + * If a match for a part or manufacturer cannot be resolved, a violation is added alongside an indication of the + * imported value and any partially matched information. Warnings for no exact matches are also added for parts + * using specific identifying properties like name, manufacturer product number, or internal part numbers. + * + * Additional validations include: + * - Checking for empty or invalid descriptions. + * - Ensuring manufacturers, if specified, have valid `name` or `id` values. + * + * @param string $data JSON encoded string containing BOM entries data. + * @param string $objectType The type of entries expected during import (e.g., `ProjectBOMEntry` or `AssemblyBOMEntry`). + * + * @return ImporterResult The result containing parsed data and any violations encountered during the parsing process. + */ + private function parseJson(string $data, string $objectType = ProjectBOMEntry::class): ImporterResult { $result = new ImporterResult(); $this->jsonRoot = 'JSON Import for '.$objectType === ProjectBOMEntry::class ? 'Project' : 'Assembly'; @@ -303,291 +381,34 @@ class BOMImporter $data = json_decode($data, true); foreach ($data as $key => $entry) { - // Check quantity if (!isset($entry['quantity'])) { $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.quantity.required', + 'validator.bom_importer.json_csv.quantity.required', "entry[$key].quantity" )); } if (isset($entry['quantity']) && (!is_float($entry['quantity']) || $entry['quantity'] <= 0)) { $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.quantity.float', + 'validator.bom_importer.json_csv.quantity.float', "entry[$key].quantity", $entry['quantity'] )); } - // Check name if (isset($entry['name']) && !is_string($entry['name'])) { $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.string.notEmpty', + 'validator.bom_importer.json_csv.parameter.string.notEmpty', "entry[$key].name", $entry['name'] )); } - // Check if part is assigned with relevant information if (isset($entry['part'])) { - if (!is_array($entry['part'])) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.array', - "entry[$key].part", - $entry['part'] - )); - } - - $partIdValid = isset($entry['part']['id']) && is_int($entry['part']['id']) && $entry['part']['id'] > 0; - $partNameValid = isset($entry['part']['name']) && is_string($entry['part']['name']) && trim($entry['part']['name']) !== ''; - $partMpnrValid = isset($entry['part']['mpnr']) && is_string($entry['part']['mpnr']) && trim($entry['part']['mpnr']) !== ''; - $partIpnValid = isset($entry['part']['ipn']) && is_string($entry['part']['ipn']) && trim($entry['part']['ipn']) !== ''; - - if (!$partIdValid && !$partNameValid && !$partMpnrValid && !$partIpnValid) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.subproperties', - "entry[$key].part", - $entry['part'], - ['%propertyString%' => '"id", "name", "mpnr", or "ipn"'] - )); - } - - $part = $partIdValid ? $this->partRepository->findOneBy(['id' => $entry['part']['id']]) : null; - $part = $part ?? ($partMpnrValid ? $this->partRepository->findOneBy(['manufacturer_product_number' => trim($entry['part']['mpnr'])]) : null); - $part = $part ?? ($partIpnValid ? $this->partRepository->findOneBy(['ipn' => trim($entry['part']['ipn'])]) : null); - $part = $part ?? ($partNameValid ? $this->partRepository->findOneBy(['name' => trim($entry['part']['name'])]) : null); - - if ($part === null) { - $value = sprintf('part.id: %s, part.mpnr: %s, part.ipn: %s, part.name: %s', - isset($entry['part']['id']) ? '' . $entry['part']['id'] . '' : '-', - isset($entry['part']['mpnr']) ? '' . $entry['part']['mpnr'] . '' : '-', - isset($entry['part']['ipn']) ? '' . $entry['part']['ipn'] . '' : '-', - isset($entry['part']['name']) ? '' . $entry['part']['name'] . '' : '-', - ); - - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.notFoundFor', - "entry[$key].part", - $entry['part'], - ['%value%' => $value] - )); - } - - if ($partNameValid && $part !== null && isset($entry['part']['name']) && $part->getName() !== trim($entry['part']['name'])) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.noExactMatch', - "entry[$key].part.name", - $entry['part']['name'], - [ - '%importValue%' => '' . $entry['part']['name'] . '', - '%foundId%' => $part->getID(), - '%foundValue%' => '' . $part->getName() . '' - ] - )); - } - - if ($partMpnrValid && $part !== null && isset($entry['part']['mpnr']) && $part->getManufacturerProductNumber() !== trim($entry['part']['mpnr'])) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.noExactMatch', - "entry[$key].part.mpnr", - $entry['part']['mpnr'], - [ - '%importValue%' => '' . $entry['part']['mpnr'] . '', - '%foundId%' => $part->getID(), - '%foundValue%' => '' . $part->getManufacturerProductNumber() . '' - ] - )); - } - - if ($partIpnValid && $part !== null && isset($entry['part']['ipn']) && $part->getIpn() !== trim($entry['part']['ipn'])) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.noExactMatch', - "entry[$key].part.ipn", - $entry['part']['ipn'], - [ - '%importValue%' => '' . $entry['part']['ipn'] . '', - '%foundId%' => $part->getID(), - '%foundValue%' => '' . $part->getIpn() . '' - ] - )); - } - - // Part: Description check - if (isset($entry['part']['description'])) { - if (!is_string($entry['part']['description']) || trim($entry['part']['description']) === '') { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.string.notEmpty', - 'entry[$key].part.description', - $entry['part']['description'] - )); - } - } - - $partDescription = $entry['part']['description'] ?? ''; - - // Part: Manufacturer check - $manufacturerIdValid = false; - $manufacturerNameValid = false; - if (array_key_exists('manufacturer', $entry['part'])) { - if (!is_array($entry['part']['manufacturer'])) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.array', - 'entry[$key].part.manufacturer', - $entry['part']['manufacturer']) ?? null - ); - } - - $manufacturerIdValid = isset($entry['part']['manufacturer']['id']) && is_int($entry['part']['manufacturer']['id']) && $entry['part']['manufacturer']['id'] > 0; - $manufacturerNameValid = isset($entry['part']['manufacturer']['name']) && is_string($entry['part']['manufacturer']['name']) && trim($entry['part']['manufacturer']['name']) !== ''; - - // Stellen sicher, dass mindestens eine Bedingung für manufacturer erfüllt sein muss - if (!$manufacturerIdValid && !$manufacturerNameValid) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.manufacturerOrCategoryWithSubProperties', - "entry[$key].part.manufacturer", - $entry['part']['manufacturer'], - )); - } - } - - $manufacturer = $manufacturerIdValid ? $this->manufacturerRepository->findOneBy(['id' => $entry['part']['manufacturer']['id']]) : null; - $manufacturer = $manufacturer ?? ($manufacturerNameValid ? $this->manufacturerRepository->findOneBy(['name' => trim($entry['part']['manufacturer']['name'])]) : null); - - if (($manufacturerIdValid || $manufacturerNameValid) && $manufacturer === null) { - $value = sprintf( - 'manufacturer.id: %s, manufacturer.name: %s', - isset($entry['part']['manufacturer']['id']) && $entry['part']['manufacturer']['id'] !== null ? '' . $entry['part']['manufacturer']['id'] . '' : '-', - isset($entry['part']['manufacturer']['name']) && $entry['part']['manufacturer']['name'] !== null ? '' . $entry['part']['manufacturer']['name'] . '' : '-' - ); - - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.notFoundFor', - "entry[$key].part.manufacturer", - $entry['part']['manufacturer'], - ['%value%' => $value] - )); - } - - if ($manufacturerNameValid && $manufacturer !== null && isset($entry['part']['manufacturer']['name']) && $manufacturer->getName() !== trim($entry['part']['manufacturer']['name'])) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.noExactMatch', - "entry[$key].part.manufacturer.name", - $entry['part']['manufacturer']['name'], - [ - '%importValue%' => '' . $entry['part']['manufacturer']['name'] . '', - '%foundId%' => $manufacturer->getID(), - '%foundValue%' => '' . $manufacturer->getName() . '' - ] - )); - } - - // Part: Category check - $categoryIdValid = false; - $categoryNameValid = false; - if (array_key_exists('category', $entry['part'])) { - if (!is_array($entry['part']['category'])) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.array', - 'entry[$key].part.category', - $entry['part']['category']) ?? null - ); - } - - $categoryIdValid = isset($entry['part']['category']['id']) && is_int($entry['part']['category']['id']) && $entry['part']['category']['id'] > 0; - $categoryNameValid = isset($entry['part']['category']['name']) && is_string($entry['part']['category']['name']) && trim($entry['part']['category']['name']) !== ''; - - if (!$categoryIdValid && !$categoryNameValid) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.manufacturerOrCategoryWithSubProperties', - "entry[$key].part.category", - $entry['part']['category'] - )); - } - } - - $category = $categoryIdValid ? $this->categoryRepository->findOneBy(['id' => $entry['part']['category']['id']]) : null; - $category = $category ?? ($categoryNameValid ? $this->categoryRepository->findOneBy(['name' => trim($entry['part']['category']['name'])]) : null); - - if (($categoryIdValid || $categoryNameValid) && $category === null) { - $value = sprintf( - 'category.id: %s, category.name: %s', - isset($entry['part']['category']['id']) && $entry['part']['category']['id'] !== null ? '' . $entry['part']['category']['id'] . '' : '-', - isset($entry['part']['category']['name']) && $entry['part']['category']['name'] !== null ? '' . $entry['part']['category']['name'] . '' : '-' - ); - - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.notFoundFor', - "entry[$key].part.category", - $entry['part']['category'], - ['%value%' => $value] - )); - } - - if ($categoryNameValid && $category !== null && isset($entry['part']['category']['name']) && $category->getName() !== trim($entry['part']['category']['name'])) { - $result->addViolation($this->buildJsonViolation( - 'validator.bom_importer.json.parameter.noExactMatch', - "entry[$key].part.category.name", - $entry['part']['category']['name'], - [ - '%importValue%' => '' . $entry['part']['category']['name'] . '', - '%foundId%' => $category->getID(), - '%foundValue%' => '' . $category->getName() . '' - ] - )); - } - - if ($result->getViolations()->count() > 0) { - continue; - } - - if ($partDescription !== '') { - //Beim Import / Aktualisieren von zugehörigen Bauteilen zu einer Baugruppe die Beschreibung des Bauteils mit übernehmen. - $part->setDescription($partDescription); - } - - if ($manufacturer !== null && $manufacturer->getID() !== $part->getManufacturerID()) { - //Beim Import / Aktualisieren von zugehörigen Bauteilen zu einer Baugruppe des Hersteller des Bauteils mit übernehmen. - $part->setManufacturer($manufacturer); - } - - if ($category !== null && $category->getID() !== $part->getCategoryID()) { - //Beim Import / Aktualisieren von zugehörigen Bauteilen zu einer Baugruppe die Kategorie des Bauteils mit übernehmen. - $part->setCategory($category); - } - - if ($objectType === AssemblyBOMEntry::class) { - $bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['part' => $part]); - - if ($bomEntry === null) { - if (isset($entry['name']) && $entry['name'] !== '') { - $bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['name' => $entry['name']]); - } - - if ($bomEntry === null) { - $bomEntry = new AssemblyBOMEntry(); - } - } - } else { - $bomEntry = new ProjectBOMEntry(); - } - - $bomEntry->setQuantity($entry['quantity']); - $bomEntry->setName($entry['name'] ?? ''); - - $bomEntry->setPart($part); - - $result->addBomEntry($bomEntry); + $this->processPart($entry, $result, $key, $objectType,self::IMPORT_TYPE_JSON); } else { - //Eintrag ohne Part-Relation in die Bauteilliste aufnehmen - - if ($objectType === AssemblyBOMEntry::class) { - $bomEntry = new AssemblyBOMEntry(); - } else { - $bomEntry = new ProjectBOMEntry(); - } - - $bomEntry->setQuantity($entry['quantity']); - $bomEntry->setName($entry['name'] ?? ''); + $bomEntry = $this->getOrCreateBomEntry($objectType, $entry['name'] ?? null); + $bomEntry->setQuantity((float) $entry['quantity']); $result->addBomEntry($bomEntry); } @@ -596,6 +417,400 @@ class BOMImporter return $result; } + + /** + * Parses a CSV string and processes its rows into hierarchical data structures, + * performing validations and converting data based on the provided headers. + * Handles potential violations and manages the creation of BOM entries based on the given type. + * + * @param string $csvData The raw CSV data to parse, with rows separated by newlines. + * @param string $objectType The class type to instantiate for BOM entries, defaults to ProjectBOMEntry. + * + * @return ImporterResult Returns an ImporterResult instance containing BOM entries and any validation violations encountered. + */ + function parseCsv(string $csvData, string $objectType = ProjectBOMEntry::class): ImporterResult + { + $result = new ImporterResult(); + $rows = explode("\n", trim($csvData)); + $headers = str_getcsv(array_shift($rows), ';'); + + foreach ($rows as $key => $row) { + $entry = []; + $values = str_getcsv($row, ';'); + + foreach ($headers as $index => $column) { + //Convert column name into hierarchy + $path = explode('_', $column); + $temp = &$entry; + + foreach ($path as $step) { + if (!isset($temp[$step])) { + $temp[$step] = []; + } + + $temp = &$temp[$step]; + } + + //If there is no value, skip + if (isset($values[$index]) && $values[$index] !== '') { + //Check whether the value is numerical + if (is_numeric($values[$index])) { + //Convert to integer or float + $temp = (strpos($values[$index], '.') !== false) + ? floatval($values[$index]) + : intval($values[$index]); + } else { + //Leave other data types untouched + $temp = $values[$index]; + } + } + } + + $entry = $this->removeEmptyProperties($entry); + + if (!isset($entry['quantity'])) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.csv.quantity.required', + "row[$key].quantity" + )); + } + + if (isset($entry['quantity']) && (!is_numeric($entry['quantity']) || $entry['quantity'] <= 0)) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.csv.quantity.float', + "row[$key].quantity", + $entry['quantity'] + )); + } + + if (isset($entry['name']) && !is_string($entry['name'])) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.csv.parameter.string.notEmpty', + "row[$key].name", + $entry['name'] + )); + } + + if (isset($entry['part'])) { + $this->processPart($entry, $result, $key, $objectType, self::IMPORT_TYPE_CSV); + } else { + $bomEntry = $this->getOrCreateBomEntry($objectType, $entry['name'] ?? null); + $bomEntry->setQuantity((float) $entry['quantity'] ?? 0); + + $result->addBomEntry($bomEntry); + } + } + + return $result; + } + + /** + * Processes an individual part entry in the import data. + * + * This method validates the structure and content of the provided part entry and uses the findings + * to identify corresponding objects in the database. The result is recorded, and violations are + * logged if issues or discrepancies exist in the validation or database matching process. + * + * @param array $entry The array representation of the part entry. + * @param ImporterResult $result The result object used for recording validation violations. + * @param int $key The index of the entry in the data array. + * @param string $objectType The type of object being processed. + * @param string $importType The type of import being performed. + * + * @return void + */ + private function processPart(array $entry, ImporterResult $result, int $key, string $objectType, string $importType): void + { + $prefix = $importType === self::IMPORT_TYPE_JSON ? 'entry' : 'row'; + + if (!is_array($entry['part'])) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.array', + $prefix."[$key].part", + $entry['part'] + )); + } + + $partIdValid = isset($entry['part']['id']) && is_int($entry['part']['id']) && $entry['part']['id'] > 0; + $partMpnrValid = isset($entry['part']['mpnr']) && is_string($entry['part']['mpnr']) && trim($entry['part']['mpnr']) !== ''; + $partIpnValid = isset($entry['part']['ipn']) && is_string($entry['part']['ipn']) && trim($entry['part']['ipn']) !== ''; + $partNameValid = isset($entry['part']['name']) && is_string($entry['part']['name']) && trim($entry['part']['name']) !== ''; + + if (!$partIdValid && !$partNameValid && !$partMpnrValid && !$partIpnValid) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.subproperties', + $prefix."[$key].part", + $entry['part'], + ['%propertyString%' => '"id", "name", "mpnr", or "ipn"'] + )); + } + + $part = $partIdValid ? $this->partRepository->findOneBy(['id' => $entry['part']['id']]) : null; + $part = $part ?? ($partMpnrValid ? $this->partRepository->findOneBy(['manufacturer_product_number' => trim($entry['part']['mpnr'])]) : null); + $part = $part ?? ($partIpnValid ? $this->partRepository->findOneBy(['ipn' => trim($entry['part']['ipn'])]) : null); + $part = $part ?? ($partNameValid ? $this->partRepository->findOneBy(['name' => trim($entry['part']['name'])]) : null); + + if ($part === null) { + $value = sprintf('part.id: %s, part.mpnr: %s, part.ipn: %s, part.name: %s', + isset($entry['part']['id']) ? '' . $entry['part']['id'] . '' : '-', + isset($entry['part']['mpnr']) ? '' . $entry['part']['mpnr'] . '' : '-', + isset($entry['part']['ipn']) ? '' . $entry['part']['ipn'] . '' : '-', + isset($entry['part']['name']) ? '' . $entry['part']['name'] . '' : '-', + ); + + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.notFoundFor', + $prefix."[$key].part", + $entry['part'], + ['%value%' => $value] + )); + } + + if ($partNameValid && $part !== null && isset($entry['part']['name']) && $part->getName() !== trim($entry['part']['name'])) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.noExactMatch', + $prefix."[$key].part.name", + $entry['part']['name'], + [ + '%importValue%' => '' . $entry['part']['name'] . '', + '%foundId%' => $part->getID(), + '%foundValue%' => '' . $part->getName() . '' + ] + )); + } + + if ($partMpnrValid && $part !== null && isset($entry['part']['mpnr']) && $part->getManufacturerProductNumber() !== trim($entry['part']['mpnr'])) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.noExactMatch', + $prefix."[$key].part.mpnr", + $entry['part']['mpnr'], + [ + '%importValue%' => '' . $entry['part']['mpnr'] . '', + '%foundId%' => $part->getID(), + '%foundValue%' => '' . $part->getManufacturerProductNumber() . '' + ] + )); + } + + if ($partIpnValid && $part !== null && isset($entry['part']['ipn']) && $part->getIpn() !== trim($entry['part']['ipn'])) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.noExactMatch', + $prefix."[$key].part.ipn", + $entry['part']['ipn'], + [ + '%importValue%' => '' . $entry['part']['ipn'] . '', + '%foundId%' => $part->getID(), + '%foundValue%' => '' . $part->getIpn() . '' + ] + )); + } + + if (isset($entry['part']['description'])) { + if (!is_string($entry['part']['description']) || trim($entry['part']['description']) === '') { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.string.notEmpty', + 'entry[$key].part.description', + $entry['part']['description'] + )); + } + } + + $partDescription = $entry['part']['description'] ?? ''; + + $manufacturerIdValid = false; + $manufacturerNameValid = false; + if (array_key_exists('manufacturer', $entry['part'])) { + if (!is_array($entry['part']['manufacturer'])) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.array', + 'entry[$key].part.manufacturer', + $entry['part']['manufacturer']) ?? null + ); + } + + $manufacturerIdValid = isset($entry['part']['manufacturer']['id']) && is_int($entry['part']['manufacturer']['id']) && $entry['part']['manufacturer']['id'] > 0; + $manufacturerNameValid = isset($entry['part']['manufacturer']['name']) && is_string($entry['part']['manufacturer']['name']) && trim($entry['part']['manufacturer']['name']) !== ''; + + if (!$manufacturerIdValid && !$manufacturerNameValid) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.manufacturerOrCategoryWithSubProperties', + $prefix."[$key].part.manufacturer", + $entry['part']['manufacturer'], + )); + } + } + + $manufacturer = $manufacturerIdValid ? $this->manufacturerRepository->findOneBy(['id' => $entry['part']['manufacturer']['id']]) : null; + $manufacturer = $manufacturer ?? ($manufacturerNameValid ? $this->manufacturerRepository->findOneBy(['name' => trim($entry['part']['manufacturer']['name'])]) : null); + + if (($manufacturerIdValid || $manufacturerNameValid) && $manufacturer === null) { + $value = sprintf( + 'manufacturer.id: %s, manufacturer.name: %s', + isset($entry['part']['manufacturer']['id']) && $entry['part']['manufacturer']['id'] !== null ? '' . $entry['part']['manufacturer']['id'] . '' : '-', + isset($entry['part']['manufacturer']['name']) && $entry['part']['manufacturer']['name'] !== null ? '' . $entry['part']['manufacturer']['name'] . '' : '-' + ); + + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.notFoundFor', + $prefix."[$key].part.manufacturer", + $entry['part']['manufacturer'], + ['%value%' => $value] + )); + } + + if ($manufacturerNameValid && $manufacturer !== null && isset($entry['part']['manufacturer']['name']) && $manufacturer->getName() !== trim($entry['part']['manufacturer']['name'])) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.noExactMatch', + $prefix."[$key].part.manufacturer.name", + $entry['part']['manufacturer']['name'], + [ + '%importValue%' => '' . $entry['part']['manufacturer']['name'] . '', + '%foundId%' => $manufacturer->getID(), + '%foundValue%' => '' . $manufacturer->getName() . '' + ] + )); + } + + $categoryIdValid = false; + $categoryNameValid = false; + if (array_key_exists('category', $entry['part'])) { + if (!is_array($entry['part']['category'])) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.array', + 'entry[$key].part.category', + $entry['part']['category']) ?? null + ); + } + + $categoryIdValid = isset($entry['part']['category']['id']) && is_int($entry['part']['category']['id']) && $entry['part']['category']['id'] > 0; + $categoryNameValid = isset($entry['part']['category']['name']) && is_string($entry['part']['category']['name']) && trim($entry['part']['category']['name']) !== ''; + + if (!$categoryIdValid && !$categoryNameValid) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.manufacturerOrCategoryWithSubProperties', + $prefix."[$key].part.category", + $entry['part']['category'] + )); + } + } + + $category = $categoryIdValid ? $this->categoryRepository->findOneBy(['id' => $entry['part']['category']['id']]) : null; + $category = $category ?? ($categoryNameValid ? $this->categoryRepository->findOneBy(['name' => trim($entry['part']['category']['name'])]) : null); + + if (($categoryIdValid || $categoryNameValid) && $category === null) { + $value = sprintf( + 'category.id: %s, category.name: %s', + isset($entry['part']['category']['id']) && $entry['part']['category']['id'] !== null ? '' . $entry['part']['category']['id'] . '' : '-', + isset($entry['part']['category']['name']) && $entry['part']['category']['name'] !== null ? '' . $entry['part']['category']['name'] . '' : '-' + ); + + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.notFoundFor', + $prefix."[$key].part.category", + $entry['part']['category'], + ['%value%' => $value] + )); + } + + if ($categoryNameValid && $category !== null && isset($entry['part']['category']['name']) && $category->getName() !== trim($entry['part']['category']['name'])) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.json_csv.parameter.noExactMatch', + $prefix."[$key].part.category.name", + $entry['part']['category']['name'], + [ + '%importValue%' => '' . $entry['part']['category']['name'] . '', + '%foundId%' => $category->getID(), + '%foundValue%' => '' . $category->getName() . '' + ] + )); + } + + if ($result->getViolations()->count() > 0) { + return; + } + + if ($partDescription !== '') { + //When updating the associated parts to a assembly, take over the description of the part. + $part->setDescription($partDescription); + } + + if ($manufacturer !== null && $manufacturer->getID() !== $part->getManufacturerID()) { + //When updating the associated parts, take over to a assembly of the manufacturer of the part. + $part->setManufacturer($manufacturer); + } + + if ($category !== null && $category->getID() !== $part->getCategoryID()) { + //When updating the associated parts to a assembly, take over the category of the part. + $part->setCategory($category); + } + + if ($objectType === AssemblyBOMEntry::class) { + $bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['part' => $part]); + + if ($bomEntry === null) { + if (isset($entry['name']) && $entry['name'] !== '') { + $bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['name' => $entry['name']]); + } + + if ($bomEntry === null) { + $bomEntry = new AssemblyBOMEntry(); + } + } + } else { + $bomEntry = new ProjectBOMEntry(); + } + + $bomEntry->setQuantity((float) $entry['quantity']); + $bomEntry->setName($entry['name'] ?? ''); + + $bomEntry->setPart($part); + + $result->addBomEntry($bomEntry); + } + + private function removeEmptyProperties(array $data): array + { + foreach ($data as $key => &$value) { + //Recursive check when the value is an array + if (is_array($value)) { + $value = $this->removeEmptyProperties($value); + + //Remove the array when it is empty after cleaning + if (empty($value)) { + unset($data[$key]); + } + } elseif ($value === null || $value === '') { + //Remove values that are explicitly zero or empty + unset($data[$key]); + } + } + + return $data; + } + + private function getOrCreateBomEntry(string $objectType, ?string $name) + { + $bomEntry = null; + + //Check whether there is a name + if (!empty($name)) { + if ($objectType === ProjectBOMEntry::class) { + $bomEntry = $this->projectBOMEntryRepository->findOneBy(['name' => $name]); + } elseif ($objectType === AssemblyBOMEntry::class) { + $bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['name' => $name]); + } + } + + //If no bom enttry was found, a new object create + if ($bomEntry === null) { + $bomEntry = new $objectType(); + } + + $bomEntry->setName($name); + + return $bomEntry; + } + /** * This function uses the order of the fields in the CSV files to make them locale independent. * @param array $entry @@ -619,6 +834,21 @@ class BOMImporter return $out; } + + /** + * Builds a JSON-based constraint violation. + * + * This method creates a `ConstraintViolation` object that represents a validation error. + * The violation includes a message, property path, invalid value, and other contextual information. + * Translations for the violation message can be applied through the translator service. + * + * @param string $message The translation key for the validation message. + * @param string $propertyPath The property path where the violation occurred. + * @param mixed|null $invalidValue The value that caused the violation (optional). + * @param array $parameters Additional parameters for message placeholders (default is an empty array). + * + * @return ConstraintViolation The created constraint violation object. + */ /** * Parse KiCad schematic BOM with flexible field mapping */ @@ -1097,6 +1327,20 @@ class BOMImporter return array_values($headers); } + /** + * Builds a JSON-based constraint violation. + * + * This method creates a `ConstraintViolation` object that represents a validation error. + * The violation includes a message, property path, invalid value, and other contextual information. + * Translations for the violation message can be applied through the translator service. + * + * @param string $message The translation key for the validation message. + * @param string $propertyPath The property path where the violation occurred. + * @param mixed|null $invalidValue The value that caused the violation (optional). + * @param array $parameters Additional parameters for message placeholders (default is an empty array). + * + * @return ConstraintViolation The created constraint violation object. + */ private function buildJsonViolation(string $message, string $propertyPath, mixed $invalidValue = null, array $parameters = []): ConstraintViolation { return new ConstraintViolation( diff --git a/templates/assemblies/import_bom.html.twig b/templates/assemblies/import_bom.html.twig index 04ff328a..9e99c541 100644 --- a/templates/assemblies/import_bom.html.twig +++ b/templates/assemblies/import_bom.html.twig @@ -53,6 +53,34 @@ +
quantity;name;part_id;part_mpnr;part_ipn;part_name;part_description;part_manufacturer_id;part_manufacturer_name;part_category_id;part_category_name+ +
Id;Designator;Package;Quantity;Designation;Supplier and ref+ +