. */ namespace App\Services\ImportExportSystem; use App\Entity\AssemblySystem\Assembly; use App\Entity\AssemblySystem\AssemblyBOMEntry; use App\Entity\Parts\Category; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Part; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Repository\DBElementRepository; use App\Repository\PartRepository; use App\Repository\Parts\CategoryRepository; use App\Repository\Parts\ManufacturerRepository; use Doctrine\ORM\EntityManagerInterface; 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; use Symfony\Component\Validator\ConstraintViolation; /** * @see \App\Tests\Services\ImportExportSystem\BOMImporterTest */ class BOMImporter { private const IMPORT_TYPE_JSON = 'json'; private const IMPORT_TYPE_CSV = 'csv'; private const IMPORT_TYPE_KICAD_PCB = 'kicad_pcbnew'; private const IMPORT_TYPE_KICAD_SCHEMATIC = 'kicad_schematic'; private const MAP_KICAD_PCB_FIELDS = [ 0 => 'Id', 1 => 'Designator', 2 => 'Package', 3 => 'Quantity', 4 => 'Designation', 5 => 'Supplier and ref', ]; private string $jsonRoot = ''; private PartRepository $partRepository; private ManufacturerRepository $manufacturerRepository; private CategoryRepository $categoryRepository; private DBElementRepository $projectBOMEntryRepository; private DBElementRepository $assemblyBOMEntryRepository; private TranslatorInterface $translator; public function __construct( private readonly EntityManagerInterface $entityManager, private readonly TranslatorInterface $translator, private readonly LoggerInterface $logger, private readonly BOMValidationService $validationService ) { $this->partRepository = $entityManager->getRepository(Part::class); $this->manufacturerRepository = $entityManager->getRepository(Manufacturer::class); $this->categoryRepository = $entityManager->getRepository(Category::class); $this->projectBOMEntryRepository = $entityManager->getRepository(ProjectBOMEntry::class); $this->assemblyBOMEntryRepository = $entityManager->getRepository(AssemblyBOMEntry::class); $this->translator = $translator; } protected function configureOptions(OptionsResolver $resolver): OptionsResolver { $resolver->setRequired('type'); $resolver->setAllowedValues('type', [self::IMPORT_TYPE_KICAD_PCB, self::IMPORT_TYPE_KICAD_SCHEMATIC, self::IMPORT_TYPE_JSON, self::IMPORT_TYPE_CSV]); // For flexible schematic import with field mapping $resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']); $resolver->setDefault('delimiter', ','); $resolver->setDefault('field_priorities', []); $resolver->setAllowedTypes('field_mapping', 'array'); $resolver->setAllowedTypes('field_priorities', 'array'); $resolver->setAllowedTypes('delimiter', 'string'); return $resolver; } /** * Converts the given file into an array of BOM entries using the given options and save them into the given project. * The changes are not saved into the database yet. */ public function importFileIntoProject(UploadedFile $file, Project $project, array $options): ImporterResult { $importerResult = $this->fileToImporterResult($project, $file, $options); if ($importerResult->getViolations()->count() === 0) { //Assign the bom_entries to the project foreach ($importerResult->getBomEntries() as $bomEntry) { $project->addBomEntry($bomEntry); } } return $importerResult; } /** * Imports a file into an Assembly object and processes its contents. * * This method converts the provided file into an ImporterResult object that contains BOM entries and potential * validation violations. If no violations are found, the BOM entries extracted from the file are added to the * provided Assembly object. * * @param UploadedFile $file The file to be imported and processed. * @param Assembly $assembly The target Assembly object to which the BOM entries are added. * @param array $options Options or configurations related to the import process. * * @return ImporterResult An object containing the result of the import process, including BOM entries and any violations. */ public function importFileIntoAssembly(UploadedFile $file, Assembly $assembly, array $options): ImporterResult { $importerResult = $this->fileToImporterResult($assembly, $file, $options); if ($importerResult->getViolations()->count() === 0) { //Assign the bom_entries to the assembly foreach ($importerResult->getBomEntries() as $bomEntry) { $assembly->addBomEntry($bomEntry); } } return $importerResult; } /** * Converts the content of a file into an array of BOM (Bill of Materials) entries. * * This method processes the content of the provided file and delegates the conversion * to a helper method that generates BOM entries based on the provided import object and options. * * @param Project|Assembly $importObject The object determining the context of the BOM entries (either a Project or Assembly). * @param File $file The file whose content will be converted into BOM entries. * @param array $options Additional options or configurations to be applied during the conversion process. * * @return array An array of BOM entries created from the file content. */ public function fileToBOMEntries(Project|Assembly $importObject, File $file, array $options): array { return $this->stringToBOMEntries($importObject, $file->getContent(), $options); } /** * Validate BOM data before importing * @return array Validation result with errors, warnings, and info */ public function validateBOMData(string $data, array $options): array { $resolver = new OptionsResolver(); $resolver = $this->configureOptions($resolver); $options = $resolver->resolve($options); return match ($options['type']) { 'kicad_pcbnew' => $this->validateKiCADPCB($data), 'kicad_schematic' => $this->validateKiCADSchematicData($data, $options), default => throw new InvalidArgumentException('Invalid import type!'), }; } /** * Handles the conversion of an uploaded file into an ImporterResult for a given project or assembly. * * This method processes the uploaded file by validating its file extension based on the provided import type * options and then proceeds to convert the file content into an ImporterResult. If the file extension is * invalid or unsupported, the result will contain a corresponding violation. * * @param Project|Assembly $importObject The context of the import operation (either a Project or Assembly). * @param UploadedFile $file The uploaded file to be processed. * @param array $options An array of options, expected to include an 'type' key to determine valid file types. * * @return ImporterResult An object containing the results of the import process, including any detected violations. */ public function fileToImporterResult(Project|Assembly $importObject, UploadedFile $file, array $options): 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($importObject instanceof Project ? 'project.bom_import.type.'.$options['type'] : 'assembly.bom_import.type.'.$options['type']), '%allowedExtensions%' => implode(', ', $validExtensions), ] )); return $result; } return $this->stringToImporterResult($importObject, $file->getContent(), $options); } /** * Import string data into an array of BOM entries, which are not yet assigned to a project. * * @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly). * @param string $data The data to import * @param array $options An array of options * * @return ProjectBOMEntry[]|AssemblyBOMEntry[] An array of imported entries */ public function stringToBOMEntries(Project|Assembly $importObject, string $data, array $options): array { $resolver = new OptionsResolver(); $resolver = $this->configureOptions($resolver); $options = $resolver->resolve($options); return match ($options['type']) { self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $objectType)->getBomEntries(), default => throw new InvalidArgumentException($this->translator->trans('validator.bom_importer.invalid_import_type', [], 'validators')), }; } /** * Import string data into an array of BOM entries, which are not yet assigned to a project. * * @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly). * @param string $data The data to import * @param array $options An array of options * * @return ImporterResult An result of imported entries or a violation list */ public function stringToImporterResult(Project|Assembly $importObject, string $data, array $options): ImporterResult { $resolver = new OptionsResolver(); $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']) { self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $importObject), self::IMPORT_TYPE_JSON => $this->parseJson($data, $importObject), self::IMPORT_TYPE_CSV => $this->parseCsv($data, $importObject), default => $defaultImporterResult, }; } /** * Parses a KiCAD PCB file and imports its BOM (Bill of Materials) entries into the given Project or Assembly context. * * This method processes a semicolon-delimited CSV data string, normalizes column names, * validates the required fields, and creates BOM entries for each record in the data. * The BOM entries are added to the provided Project or Assembly, depending on the context. * * @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly). * @param string $data The semicolon- or comma-delimited CSV data to be parsed * * @return ImporterResult The result of the import process, containing the created BOM entries. * * @throws UnexpectedValueException If required fields are missing in the provided data. */ private function parseKiCADPCB(string $data, Project|Assembly $importObject): ImporterResult { $result = new ImporterResult(); $csv = Reader::createFromString($data); $csv->setDelimiter(';'); $csv->setHeaderOffset(0); foreach ($csv->getRecords() as $offset => $entry) { //Translate the german field names to english $entry = $this->normalizeColumnNames($entry); //Ensure that the entry has all required fields if (!isset($entry['Designator'])) { throw new \UnexpectedValueException('Designator missing at line ' . ($offset + 1) . '!'); } if (!isset($entry['Package'])) { throw new \UnexpectedValueException('Package missing at line ' . ($offset + 1) . '!'); } if (!isset($entry['Designation'])) { throw new \UnexpectedValueException('Designation missing at line ' . ($offset + 1) . '!'); } if (!isset($entry['Quantity'])) { throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!'); } $bom_entry = $importObject instanceof Project ? new ProjectBOMEntry() : new AssemblyBOMEntry(); if ($bom_entry instanceof ProjectBOMEntry) { $bom_entry->setName($entry['Designation'] . ' (' . $entry['Package'] . ')'); } else { $bom_entry->setName($entry['Designation']); } $bom_entry->setMountnames($entry['Designator']); $bom_entry->setComment($entry['Supplier and ref'] ?? ''); $bom_entry->setQuantity((float) ($entry['Quantity'] ?? 1)); $result->addBomEntry($bom_entry); } return $result; } /** * Validate KiCad PCB data */ private function validateKiCADPCB(string $data): array { $csv = Reader::createFromString($data); $csv->setDelimiter(';'); $csv->setHeaderOffset(0); $mapped_entries = []; foreach ($csv->getRecords() as $offset => $entry) { // Translate the german field names to english $entry = $this->normalizeColumnNames($entry); $mapped_entries[] = $entry; } return $this->validationService->validateBOMEntries($mapped_entries); } /** * Validate KiCad schematic data */ private function validateKiCADSchematicData(string $data, array $options): array { $delimiter = $options['delimiter'] ?? ','; $field_mapping = $options['field_mapping'] ?? []; $field_priorities = $options['field_priorities'] ?? []; // Handle potential BOM (Byte Order Mark) at the beginning $data = preg_replace('/^\xEF\xBB\xBF/', '', $data); $csv = Reader::createFromString($data); $csv->setDelimiter($delimiter); $csv->setHeaderOffset(0); // Handle quoted fields properly $csv->setEscape('\\'); $csv->setEnclosure('"'); $mapped_entries = []; foreach ($csv->getRecords() as $offset => $entry) { // Apply field mapping to translate column names $mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities); // Extract footprint package name if it contains library prefix if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) { $mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1]; } $mapped_entries[] = $mapped_entry; } return $this->validationService->validateBOMEntries($mapped_entries, $options); } /** * 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 Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly). * @param string $data JSON encoded string containing BOM entries data. * * @return ImporterResult The result containing parsed data and any violations encountered during the parsing process. */ private function parseJson(Project|Assembly $importObject, string $data): ImporterResult { $result = new ImporterResult(); $this->jsonRoot = 'JSON Import for '.($importObject instanceof Project ? 'Project' : 'Assembly'); $data = json_decode($data, true); foreach ($data as $key => $entry) { if (!isset($entry['quantity'])) { $result->addViolation($this->buildJsonViolation( '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_csv.quantity.float', "entry[$key].quantity", $entry['quantity'] )); } if (isset($entry['name']) && !is_string($entry['name'])) { $result->addViolation($this->buildJsonViolation( 'validator.bom_importer.json_csv.parameter.string.notEmpty', "entry[$key].name", $entry['name'] )); } if (isset($entry['part'])) { $this->processPart($importObject, $entry, $result, $key, self::IMPORT_TYPE_JSON); } else { $bomEntry = $this->getOrCreateBomEntry($importObject, $entry['name'] ?? null); $bomEntry->setQuantity((float) $entry['quantity']); $result->addBomEntry($bomEntry); } } 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 Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly). * @param string $csvData The raw CSV data to parse, with rows separated by newlines. * * @return ImporterResult Returns an ImporterResult instance containing BOM entries and any validation violations encountered. */ function parseCsv(Project|Assembly $importObject, string $csvData): ImporterResult { $result = new ImporterResult(); $rows = explode("\r\n", trim($csvData)); $headers = str_getcsv(array_shift($rows)); if (count($headers) === 1 && isset($headers[0])) { //If only one column was recognized, try fallback with semicolon as a separator $headers = str_getcsv($headers[0], ';'); } foreach ($rows as $key => $row) { $entry = []; $values = str_getcsv($row); if (count($values) === 1 || count($values) !== count($headers)) { //If only one column was recognized, try fallback with semicolon as a separator $values = str_getcsv($row, ';'); } foreach ($headers as $index => $column) { //Change the column names in small letters $column = strtolower($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]) && !in_array($column, ['name','description','manufacturer','designator'])) { //Convert to integer or float $temp = (str_contains($values[$index], '.')) ? 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.json_csv.parameter.string.notEmpty', "row[$key].name", $entry['name'] )); } if (isset($entry['id']) && is_numeric($entry['id'])) { //Use id column as a fallback for the expected part_id column $entry['part']['id'] = (int) $entry['id']; } if (isset($entry['part'])) { $this->processPart($importObject, $entry, $result, $key, self::IMPORT_TYPE_CSV); } else { $bomEntry = $this->getOrCreateBomEntry($importObject, $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 Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly). * @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 $importType The type of import being performed. * * @return void */ private function processPart(Project|Assembly $importObject, array $entry, ImporterResult $result, int $key, 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)) { $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->getManufacturer()->getID()) { //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->getCategory()->getID()) { //When updating the associated parts to a assembly, take over the category of the part. $part->setCategory($category); } if ($importObject instanceof Assembly) { $bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['assembly' => $importObject, 'part' => $part]); if ($bomEntry === null) { if (isset($entry['name']) && $entry['name'] !== '') { $bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['assembly' => $importObject, 'name' => $entry['name']]); } if ($bomEntry === null) { $bomEntry = new AssemblyBOMEntry(); } } } else { $bomEntry = $this->projectBOMEntryRepository->findOneBy(['project' => $importObject, 'part' => $part]); if ($bomEntry === null) { if (isset($entry['name']) && $entry['name'] !== '') { $bomEntry = $this->projectBOMEntryRepository->findOneBy(['project' => $importObject, 'name' => $entry['name']]); } if ($bomEntry === null) { $bomEntry = new ProjectBOMEntry(); } } } $bomEntry->setQuantity((float) $entry['quantity']); if (isset($entry['name'])) { $givenName = trim($entry['name']) === '' ? null : trim ($entry['name']); if ($givenName !== null && $bomEntry->getPart() !== null && $bomEntry->getPart()->getName() !== $givenName) { //Apply different names for parts list entry $bomEntry->setName(trim($entry['name']) === '' ? null : trim ($entry['name'])); } } else { $bomEntry->setName(null); } $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; } /** * Retrieves an existing BOM (Bill of Materials) entry by name or creates a new one if not found. * * Depending on whether the provided import object is a Project or Assembly, this method attempts to locate * a corresponding BOM entry in the appropriate repository. If no entry is located, a new BOM entry object * is instantiated according to the type of the import object. * * @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly). * @param string|null $name The name of the BOM entry to search for or assign to a new entry. * * @return ProjectBOMEntry|AssemblyBOMEntry An existing or newly created BOM entry. */ private function getOrCreateBomEntry(Project|Assembly $importObject, ?string $name): ProjectBOMEntry|AssemblyBOMEntry { $bomEntry = null; //Check whether there is a name if (!empty($name)) { if ($importObject instanceof Project) { $bomEntry = $this->projectBOMEntryRepository->findOneBy(['name' => $name]); } else { $bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['name' => $name]); } } //If no bom entry was found, a new object create if ($bomEntry === null) { if ($importObject instanceof Project) { $bomEntry = new ProjectBOMEntry(); } else { $bomEntry = new AssemblyBOMEntry(); } } $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 * @return array */ private function normalizeColumnNames(array $entry): array { $out = []; //Map the entry order to the correct column names foreach (array_values($entry) as $index => $field) { if ($index > 5) { break; } //@phpstan-ignore-next-line We want to keep this check just to be safe when something changes $new_index = self::MAP_KICAD_PCB_FIELDS[$index] ?? throw new UnexpectedValueException('Invalid field index!'); $out[$new_index] = $field; } 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. */ private function buildJsonViolation(string $message, string $propertyPath, mixed $invalidValue = null, array $parameters = []): ConstraintViolation { return new ConstraintViolation( message: $this->translator->trans($message, $parameters, 'validators'), messageTemplate: $message, parameters: $parameters, root: $this->jsonRoot, propertyPath: $propertyPath, invalidValue: $invalidValue ); } /** * Parse KiCad schematic BOM with flexible field mapping */ private function parseKiCADSchematic(string $data, array $options = []): array { $delimiter = $options['delimiter'] ?? ','; $field_mapping = $options['field_mapping'] ?? []; $field_priorities = $options['field_priorities'] ?? []; // Handle potential BOM (Byte Order Mark) at the beginning $data = preg_replace('/^\xEF\xBB\xBF/', '', $data); $csv = Reader::createFromString($data); $csv->setDelimiter($delimiter); $csv->setHeaderOffset(0); // Handle quoted fields properly $csv->setEscape('\\'); $csv->setEnclosure('"'); $bom_entries = []; $entries_by_key = []; // Track entries by name+part combination $mapped_entries = []; // Collect all mapped entries for validation foreach ($csv->getRecords() as $offset => $entry) { // Apply field mapping to translate column names $mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities); // Extract footprint package name if it contains library prefix if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) { $mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1]; } $mapped_entries[] = $mapped_entry; } // Validate all entries before processing $validation_result = $this->validationService->validateBOMEntries($mapped_entries, $options); // Log validation results $this->logger->info('BOM import validation completed', [ 'total_entries' => $validation_result['total_entries'], 'valid_entries' => $validation_result['valid_entries'], 'invalid_entries' => $validation_result['invalid_entries'], 'error_count' => count($validation_result['errors']), 'warning_count' => count($validation_result['warnings']), ]); // If there are validation errors, throw an exception with detailed messages if (!empty($validation_result['errors'])) { $error_message = $this->validationService->getErrorMessage($validation_result); throw new \UnexpectedValueException("BOM import validation failed:\n" . $error_message); } // Process validated entries foreach ($mapped_entries as $offset => $mapped_entry) { // Set name - prefer MPN, fall back to Value, then default format $mpn = trim($mapped_entry['MPN'] ?? ''); $designation = trim($mapped_entry['Designation'] ?? ''); $value = trim($mapped_entry['Value'] ?? ''); // Use the first non-empty value, or 'Unknown Component' if all are empty $name = ''; if (!empty($mpn)) { $name = $mpn; } elseif (!empty($designation)) { $name = $designation; } elseif (!empty($value)) { $name = $value; } else { $name = 'Unknown Component'; } if (isset($mapped_entry['Package']) && !empty(trim($mapped_entry['Package']))) { $name .= ' (' . trim($mapped_entry['Package']) . ')'; } // Set mountnames and quantity // The Designator field contains comma-separated mount names for all instances $designator = trim($mapped_entry['Designator']); $quantity = (float) $mapped_entry['Quantity']; // Get mountnames array (validation already ensured they match quantity) $mountnames_array = array_map('trim', explode(',', $designator)); // Try to link existing Part-DB part if ID is provided $part = null; if (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) { $partDbId = (int) $mapped_entry['Part-DB ID']; $existingPart = $this->entityManager->getRepository(Part::class)->find($partDbId); if ($existingPart) { $part = $existingPart; // Update name with actual part name $name = $existingPart->getName(); } } // Create unique key for this entry (name + part ID) $entry_key = $name . '|' . ($part ? $part->getID() : 'null'); // Check if we already have an entry with the same name and part if (isset($entries_by_key[$entry_key])) { // Merge with existing entry $existing_entry = $entries_by_key[$entry_key]; // Combine mountnames $existing_mountnames = $existing_entry->getMountnames(); $combined_mountnames = $existing_mountnames . ',' . $designator; $existing_entry->setMountnames($combined_mountnames); // Add quantities $existing_quantity = $existing_entry->getQuantity(); $existing_entry->setQuantity($existing_quantity + $quantity); $this->logger->info('Merged duplicate BOM entry', [ 'name' => $name, 'part_id' => $part ? $part->getID() : null, 'original_quantity' => $existing_quantity, 'added_quantity' => $quantity, 'new_quantity' => $existing_quantity + $quantity, 'original_mountnames' => $existing_mountnames, 'added_mountnames' => $designator, ]); continue; // Skip creating new entry } // Create new BOM entry $bom_entry = new ProjectBOMEntry(); $bom_entry->setName($name); $bom_entry->setMountnames($designator); $bom_entry->setQuantity($quantity); if ($part) { $bom_entry->setPart($part); } // Set comment with additional info $comment_parts = []; if (isset($mapped_entry['Value']) && $mapped_entry['Value'] !== ($mapped_entry['MPN'] ?? '')) { $comment_parts[] = 'Value: ' . $mapped_entry['Value']; } if (isset($mapped_entry['MPN'])) { $comment_parts[] = 'MPN: ' . $mapped_entry['MPN']; } if (isset($mapped_entry['Manufacturer'])) { $comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer']; } if (isset($mapped_entry['LCSC'])) { $comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC']; } if (isset($mapped_entry['Supplier and ref'])) { $comment_parts[] = $mapped_entry['Supplier and ref']; } if ($part) { $comment_parts[] = "Part-DB ID: " . $part->getID(); } elseif (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) { $comment_parts[] = "Part-DB ID: " . $mapped_entry['Part-DB ID'] . " (NOT FOUND)"; } $bom_entry->setComment(implode(', ', $comment_parts)); $bom_entries[] = $bom_entry; $entries_by_key[$entry_key] = $bom_entry; } return $bom_entries; } /** * Get all available field mapping targets with descriptions */ public function getAvailableFieldTargets(): array { $targets = [ 'Designator' => [ 'label' => 'Designator', 'description' => 'Component reference designators (e.g., R1, C2, U3)', 'required' => true, 'multiple' => false, ], 'Quantity' => [ 'label' => 'Quantity', 'description' => 'Number of components', 'required' => true, 'multiple' => false, ], 'Designation' => [ 'label' => 'Designation', 'description' => 'Component designation/part number', 'required' => false, 'multiple' => true, ], 'Value' => [ 'label' => 'Value', 'description' => 'Component value (e.g., 10k, 100nF)', 'required' => false, 'multiple' => true, ], 'Package' => [ 'label' => 'Package', 'description' => 'Component package/footprint', 'required' => false, 'multiple' => true, ], 'MPN' => [ 'label' => 'MPN', 'description' => 'Manufacturer Part Number', 'required' => false, 'multiple' => true, ], 'Manufacturer' => [ 'label' => 'Manufacturer', 'description' => 'Component manufacturer name', 'required' => false, 'multiple' => true, ], 'Part-DB ID' => [ 'label' => 'Part-DB ID', 'description' => 'Existing Part-DB part ID for linking', 'required' => false, 'multiple' => false, ], 'Comment' => [ 'label' => 'Comment', 'description' => 'Additional component information', 'required' => false, 'multiple' => true, ], ]; // Add dynamic supplier fields based on available suppliers in the database $suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll(); foreach ($suppliers as $supplier) { $supplierName = $supplier->getName(); $targets[$supplierName . ' SPN'] = [ 'label' => $supplierName . ' SPN', 'description' => "Supplier part number for {$supplierName}", 'required' => false, 'multiple' => true, 'supplier_id' => $supplier->getID(), ]; } return $targets; } /** * Get suggested field mappings based on common field names */ public function getSuggestedFieldMapping(array $detected_fields): array { $suggestions = []; $field_patterns = [ 'Part-DB ID' => ['part-db id', 'partdb_id', 'part_db_id', 'db_id', 'partdb'], 'Designator' => ['reference', 'ref', 'designator', 'component', 'comp'], 'Quantity' => ['qty', 'quantity', 'count', 'number', 'amount'], 'Value' => ['value', 'val', 'component_value'], 'Designation' => ['designation', 'part_number', 'partnumber', 'part'], 'Package' => ['footprint', 'package', 'housing', 'fp'], 'MPN' => ['mpn', 'part_number', 'partnumber', 'manf#', 'mfr_part_number', 'manufacturer_part'], 'Manufacturer' => ['manufacturer', 'manf', 'mfr', 'brand', 'vendor'], 'Comment' => ['comment', 'comments', 'note', 'notes', 'description'], ]; // Add supplier-specific patterns $suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll(); foreach ($suppliers as $supplier) { $supplierName = $supplier->getName(); $supplierLower = strtolower($supplierName); // Create patterns for each supplier $field_patterns[$supplierName . ' SPN'] = [ $supplierLower, $supplierLower . '#', $supplierLower . '_part', $supplierLower . '_number', $supplierLower . 'pn', $supplierLower . '_spn', $supplierLower . ' spn', // Common abbreviations $supplierLower === 'mouser' ? 'mouser' : null, $supplierLower === 'digikey' ? 'dk' : null, $supplierLower === 'farnell' ? 'farnell' : null, $supplierLower === 'rs' ? 'rs' : null, $supplierLower === 'lcsc' ? 'lcsc' : null, ]; // Remove null values $field_patterns[$supplierName . ' SPN'] = array_filter($field_patterns[$supplierName . ' SPN'], fn($value) => $value !== null); } foreach ($detected_fields as $field) { $field_lower = strtolower(trim($field)); foreach ($field_patterns as $target => $patterns) { foreach ($patterns as $pattern) { if (str_contains($field_lower, $pattern)) { $suggestions[$field] = $target; break 2; // Break both loops } } } } return $suggestions; } /** * Validate field mapping configuration */ public function validateFieldMapping(array $field_mapping, array $detected_fields): array { $errors = []; $warnings = []; $available_targets = $this->getAvailableFieldTargets(); // Check for required fields $mapped_targets = array_values($field_mapping); $required_fields = ['Designator', 'Quantity']; foreach ($required_fields as $required) { if (!in_array($required, $mapped_targets, true)) { $errors[] = "Required field '{$required}' is not mapped from any CSV column."; } } // Check for invalid target fields foreach ($field_mapping as $csv_field => $target) { if (!empty($target) && !isset($available_targets[$target])) { $errors[] = "Invalid target field '{$target}' for CSV field '{$csv_field}'."; } } // Check for unmapped fields (warnings) $unmapped_fields = array_diff($detected_fields, array_keys($field_mapping)); if (!empty($unmapped_fields)) { $warnings[] = "The following CSV fields are not mapped: " . implode(', ', $unmapped_fields); } return [ 'errors' => $errors, 'warnings' => $warnings, 'is_valid' => empty($errors), ]; } /** * Apply field mapping with support for multiple fields and priority */ private function applyFieldMapping(array $entry, array $field_mapping, array $field_priorities = []): array { $mapped = []; $field_groups = []; // Group fields by target with priority information foreach ($field_mapping as $csv_field => $target) { if (!empty($target)) { if (!isset($field_groups[$target])) { $field_groups[$target] = []; } $priority = $field_priorities[$csv_field] ?? 10; $field_groups[$target][] = [ 'field' => $csv_field, 'priority' => $priority, 'value' => $entry[$csv_field] ?? '' ]; } } // Process each target field foreach ($field_groups as $target => $field_data) { // Sort by priority (lower number = higher priority) usort($field_data, function ($a, $b) { return $a['priority'] <=> $b['priority']; }); $values = []; $non_empty_values = []; // Collect all non-empty values for this target foreach ($field_data as $data) { $value = trim($data['value']); if (!empty($value)) { $non_empty_values[] = $value; } $values[] = $value; } // Use the first non-empty value (highest priority) if (!empty($non_empty_values)) { $mapped[$target] = $non_empty_values[0]; // If multiple non-empty values exist, add alternatives to comment if (count($non_empty_values) > 1) { $mapped[$target . '_alternatives'] = array_slice($non_empty_values, 1); } } } return $mapped; } /** * Detect available fields in CSV data for field mapping UI */ public function detectFields(string $data, ?string $delimiter = null): array { if ($delimiter === null) { // Detect delimiter by counting occurrences in the first row (header) $delimiters = [',', ';', "\t"]; $lines = explode("\n", $data, 2); $header_line = $lines[0] ?? ''; $delimiter_counts = []; foreach ($delimiters as $delim) { $delimiter_counts[$delim] = substr_count($header_line, $delim); } // Choose the delimiter with the highest count, default to comma if all are zero $max_count = max($delimiter_counts); $delimiter = array_search($max_count, $delimiter_counts, true); if ($max_count === 0 || $delimiter === false) { $delimiter = ','; } } // Handle potential BOM (Byte Order Mark) at the beginning $data = preg_replace('/^\xEF\xBB\xBF/', '', $data); // Get first line only for header detection $lines = explode("\n", $data); $header_line = trim($lines[0] ?? ''); // Simple manual parsing for header detection // This handles quoted CSV fields better than the library for detection $fields = []; $current_field = ''; $in_quotes = false; $quote_char = '"'; for ($i = 0; $i < strlen($header_line); $i++) { $char = $header_line[$i]; if ($char === $quote_char && !$in_quotes) { $in_quotes = true; } elseif ($char === $quote_char && $in_quotes) { // Check for escaped quote (double quote) if ($i + 1 < strlen($header_line) && $header_line[$i + 1] === $quote_char) { $current_field .= $quote_char; $i++; // Skip next quote } else { $in_quotes = false; } } elseif ($char === $delimiter && !$in_quotes) { $fields[] = trim($current_field); $current_field = ''; } else { $current_field .= $char; } } // Add the last field if ($current_field !== '') { $fields[] = trim($current_field); } // Clean up headers - remove quotes and trim whitespace $headers = array_map(function ($header) { return trim($header, '"\''); }, $fields); return array_values($headers); } }