mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-01-12 05:09:33 +00:00
Die Standardanzeige des Tabs "Details" wurde korrigiert. Im BOMImporter wurden nichtnumerische Spalten kategorisch ausgeschlossen und eine Validation-message angepasst.
1456 lines
62 KiB
PHP
1456 lines
62 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
*
|
|
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
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']) ? '<strong>' . $entry['part']['id'] . '</strong>' : '-',
|
|
isset($entry['part']['mpnr']) ? '<strong>' . $entry['part']['mpnr'] . '</strong>' : '-',
|
|
isset($entry['part']['ipn']) ? '<strong>' . $entry['part']['ipn'] . '</strong>' : '-',
|
|
isset($entry['part']['name']) ? '<strong>' . $entry['part']['name'] . '</strong>' : '-',
|
|
);
|
|
|
|
$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%' => '<strong>' . $entry['part']['name'] . '</strong>',
|
|
'%foundId%' => $part->getID(),
|
|
'%foundValue%' => '<strong>' . $part->getName() . '</strong>'
|
|
]
|
|
));
|
|
}
|
|
|
|
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%' => '<strong>' . $entry['part']['mpnr'] . '</strong>',
|
|
'%foundId%' => $part->getID(),
|
|
'%foundValue%' => '<strong>' . $part->getManufacturerProductNumber() . '</strong>'
|
|
]
|
|
));
|
|
}
|
|
|
|
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%' => '<strong>' . $entry['part']['ipn'] . '</strong>',
|
|
'%foundId%' => $part->getID(),
|
|
'%foundValue%' => '<strong>' . $part->getIpn() . '</strong>'
|
|
]
|
|
));
|
|
}
|
|
|
|
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 ? '<strong>' . $entry['part']['manufacturer']['id'] . '</strong>' : '-',
|
|
isset($entry['part']['manufacturer']['name']) && $entry['part']['manufacturer']['name'] !== null ? '<strong>' . $entry['part']['manufacturer']['name'] . '</strong>' : '-'
|
|
);
|
|
|
|
$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%' => '<strong>' . $entry['part']['manufacturer']['name'] . '</strong>',
|
|
'%foundId%' => $manufacturer->getID(),
|
|
'%foundValue%' => '<strong>' . $manufacturer->getName() . '</strong>'
|
|
]
|
|
));
|
|
}
|
|
|
|
$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 ? '<strong>' . $entry['part']['category']['id'] . '</strong>' : '-',
|
|
isset($entry['part']['category']['name']) && $entry['part']['category']['name'] !== null ? '<strong>' . $entry['part']['category']['name'] . '</strong>' : '-'
|
|
);
|
|
|
|
$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%' => '<strong>' . $entry['part']['category']['name'] . '</strong>',
|
|
'%foundId%' => $category->getID(),
|
|
'%foundValue%' => '<strong>' . $category->getName() . '</strong>'
|
|
]
|
|
));
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|