2023-03-16 00:05:46 +01:00
< ? php
2023-06-11 18:59:07 +02:00
declare ( strict_types = 1 );
2023-03-16 00:05:46 +01:00
/*
* 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 ;
2025-03-19 08:13:45 +01:00
use App\Entity\AssemblySystem\Assembly ;
use App\Entity\AssemblySystem\AssemblyBOMEntry ;
use App\Entity\Parts\Category ;
use App\Entity\Parts\Manufacturer ;
2025-08-03 18:46:46 +02:00
use App\Entity\Parts\Part ;
2023-03-16 00:05:46 +01:00
use App\Entity\ProjectSystem\Project ;
use App\Entity\ProjectSystem\ProjectBOMEntry ;
2025-03-19 08:13:45 +01:00
use App\Repository\DBElementRepository ;
use App\Repository\PartRepository ;
use App\Repository\Parts\CategoryRepository ;
use App\Repository\Parts\ManufacturerRepository ;
2025-08-03 18:46:46 +02:00
use Doctrine\ORM\EntityManagerInterface ;
2023-03-16 00:05:46 +01:00
use InvalidArgumentException ;
use League\Csv\Reader ;
2025-08-03 18:46:46 +02:00
use Psr\Log\LoggerInterface ;
2023-03-16 00:05:46 +01:00
use Symfony\Component\HttpFoundation\File\File ;
use Symfony\Component\OptionsResolver\OptionsResolver ;
2025-03-19 08:13:45 +01:00
use RuntimeException ;
use UnexpectedValueException ;
2023-03-16 00:05:46 +01:00
2023-06-11 15:02:59 +02:00
/**
* @ see \App\Tests\Services\ImportExportSystem\BOMImporterTest
*/
2023-03-16 00:05:46 +01:00
class BOMImporter
{
private const MAP_KICAD_PCB_FIELDS = [
2024-04-29 00:09:10 +02:00
0 => 'Id' ,
1 => 'Designator' ,
2 => 'Package' ,
3 => 'Quantity' ,
4 => 'Designation' ,
5 => 'Supplier and ref' ,
2023-03-16 00:05:46 +01:00
];
2025-03-19 08:13:45 +01:00
private readonly PartRepository $partRepository ;
private readonly ManufacturerRepository $manufacturerRepository ;
private readonly CategoryRepository $categoryRepository ;
private readonly DBElementRepository $assemblyBOMEntryRepository ;
2025-08-03 18:46:46 +02:00
public function __construct (
private readonly EntityManagerInterface $entityManager ,
private readonly LoggerInterface $logger ,
private readonly BOMValidationService $validationService
) {
2025-03-19 08:13:45 +01:00
$this -> partRepository = $entityManager -> getRepository ( Part :: class );
$this -> manufacturerRepository = $entityManager -> getRepository ( Manufacturer :: class );
$this -> categoryRepository = $entityManager -> getRepository ( Category :: class );
$this -> assemblyBOMEntryRepository = $entityManager -> getRepository ( AssemblyBOMEntry :: class );
2023-03-16 00:05:46 +01:00
}
2023-03-16 23:32:12 +01:00
protected function configureOptions ( OptionsResolver $resolver ) : OptionsResolver
2023-03-16 00:05:46 +01:00
{
$resolver -> setRequired ( 'type' );
2025-03-19 08:13:45 +01:00
$resolver -> setAllowedValues ( 'type' , [ 'kicad_pcbnew' , 'kicad_schematic' , 'json' ]);
2025-08-03 18:46:46 +02:00
// 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' );
2023-03-16 00:05:46 +01:00
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 .
* @ return ProjectBOMEntry []
*/
public function importFileIntoProject ( File $file , Project $project , array $options ) : array
{
$bom_entries = $this -> fileToBOMEntries ( $file , $options );
//Assign the bom_entries to the project
foreach ( $bom_entries as $bom_entry ) {
$project -> addBomEntry ( $bom_entry );
}
return $bom_entries ;
}
2025-03-19 08:13:45 +01:00
/**
* Converts the given file into an array of BOM entries using the given options and save them into the given assembly .
* The changes are not saved into the database yet .
* @ return AssemblyBOMEntry []
*/
public function importFileIntoAssembly ( File $file , Assembly $assembly , array $options ) : array
{
$bomEntries = $this -> fileToBOMEntries ( $file , $options , AssemblyBOMEntry :: class );
//Assign the bom_entries to the assembly
foreach ( $bomEntries as $bom_entry ) {
$assembly -> addBomEntry ( $bom_entry );
}
return $bomEntries ;
}
2023-03-16 00:05:46 +01:00
/**
* Converts the given file into an array of BOM entries using the given options .
2025-03-19 08:13:45 +01:00
* @ return ProjectBOMEntry [] | AssemblyBOMEntry []
2023-03-16 00:05:46 +01:00
*/
2025-03-19 08:13:45 +01:00
public function fileToBOMEntries ( File $file , array $options , string $objectType = ProjectBOMEntry :: class ) : array
2023-03-16 00:05:46 +01:00
{
2025-03-19 08:13:45 +01:00
return $this -> stringToBOMEntries ( $file -> getContent (), $options , $objectType );
2023-03-16 00:05:46 +01:00
}
2025-08-03 18:46:46 +02:00
/**
* 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!' ),
};
}
2023-03-16 00:05:46 +01:00
/**
* Import string data into an array of BOM entries , which are not yet assigned to a project .
* @ param string $data The data to import
* @ param array $options An array of options
2025-03-19 08:13:45 +01:00
* @ return ProjectBOMEntry [] | AssemblyBOMEntry [] An array of imported entries
2023-03-16 00:05:46 +01:00
*/
2025-03-19 08:13:45 +01:00
public function stringToBOMEntries ( string $data , array $options , string $objectType = ProjectBOMEntry :: class ) : array
2023-03-16 00:05:46 +01:00
{
$resolver = new OptionsResolver ();
$resolver = $this -> configureOptions ( $resolver );
$options = $resolver -> resolve ( $options );
2023-06-11 14:15:46 +02:00
return match ( $options [ 'type' ]) {
2025-03-19 08:13:45 +01:00
'kicad_pcbnew' => $this -> parseKiCADPCB ( $data , $objectType ),
'json' => $this -> parseJson ( $data , $options , $objectType ),
2023-06-11 14:15:46 +02:00
default => throw new InvalidArgumentException ( 'Invalid import type!' ),
};
2023-03-16 00:05:46 +01:00
}
2025-03-19 08:13:45 +01:00
private function parseKiCADPCB ( string $data , string $objectType = ProjectBOMEntry :: class ) : array
2023-03-16 00:05:46 +01:00
{
$csv = Reader :: createFromString ( $data );
$csv -> setDelimiter ( ';' );
$csv -> setHeaderOffset ( 0 );
$bom_entries = [];
foreach ( $csv -> getRecords () as $offset => $entry ) {
//Translate the german field names to english
2024-04-29 00:09:10 +02:00
$entry = $this -> normalizeColumnNames ( $entry );
2023-03-16 00:05:46 +01:00
//Ensure that the entry has all required fields
2025-08-03 18:46:46 +02:00
if ( ! isset ( $entry [ 'Designator' ])) {
throw new \UnexpectedValueException ( 'Designator missing at line ' . ( $offset + 1 ) . '!' );
2023-03-16 00:05:46 +01:00
}
2025-08-03 18:46:46 +02:00
if ( ! isset ( $entry [ 'Package' ])) {
throw new \UnexpectedValueException ( 'Package missing at line ' . ( $offset + 1 ) . '!' );
2023-03-16 00:05:46 +01:00
}
2025-08-03 18:46:46 +02:00
if ( ! isset ( $entry [ 'Designation' ])) {
throw new \UnexpectedValueException ( 'Designation missing at line ' . ( $offset + 1 ) . '!' );
2023-03-16 00:05:46 +01:00
}
2025-08-03 18:46:46 +02:00
if ( ! isset ( $entry [ 'Quantity' ])) {
throw new \UnexpectedValueException ( 'Quantity missing at line ' . ( $offset + 1 ) . '!' );
2023-03-16 00:05:46 +01:00
}
2025-03-19 08:13:45 +01:00
$bom_entry = $objectType === ProjectBOMEntry :: class ? new ProjectBOMEntry () : new AssemblyBOMEntry ();
if ( $objectType === ProjectBOMEntry :: class ) {
$bom_entry -> setName ( $entry [ 'Designation' ] . ' (' . $entry [ 'Package' ] . ')' );
} else {
$bom_entry -> setName ( $entry [ 'Designation' ]);
}
2023-03-16 00:05:46 +01:00
$bom_entry -> setMountnames ( $entry [ 'Designator' ] ? ? '' );
$bom_entry -> setComment ( $entry [ 'Supplier and ref' ] ? ? '' );
$bom_entry -> setQuantity (( float ) ( $entry [ 'Quantity' ] ? ? 1 ));
$bom_entries [] = $bom_entry ;
}
return $bom_entries ;
}
2024-04-29 00:09:10 +02:00
2025-08-03 18:46:46 +02:00
/**
* 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 );
}
2025-03-19 08:13:45 +01:00
private function parseJson ( string $data , array $options = [], string $objectType = ProjectBOMEntry :: class ) : array
{
$result = [];
$data = json_decode ( $data , true );
foreach ( $data as $entry ) {
// Check quantity
if ( ! isset ( $entry [ 'quantity' ])) {
throw new UnexpectedValueException ( 'quantity missing' );
}
if ( ! is_float ( $entry [ 'quantity' ]) || $entry [ 'quantity' ] <= 0 ) {
throw new UnexpectedValueException ( 'quantity expected as float greater than 0.0' );
}
// Check name
if ( isset ( $entry [ 'name' ]) && ! is_string ( $entry [ 'name' ])) {
throw new UnexpectedValueException ( 'name of part list entry expected as string' );
}
// Check if part is assigned with relevant information
if ( isset ( $entry [ 'part' ])) {
if ( ! is_array ( $entry [ 'part' ])) {
throw new UnexpectedValueException ( 'The property "part" should be an array' );
}
$partIdValid = isset ( $entry [ 'part' ][ 'id' ]) && is_int ( $entry [ 'part' ][ 'id' ]) && $entry [ 'part' ][ 'id' ] > 0 ;
$partNameValid = isset ( $entry [ 'part' ][ 'name' ]) && is_string ( $entry [ 'part' ][ 'name' ]) && trim ( $entry [ 'part' ][ 'name' ]) !== '' ;
$partMpnrValid = isset ( $entry [ 'part' ][ 'mpnr' ]) && is_string ( $entry [ 'part' ][ 'mpnr' ]) && trim ( $entry [ 'part' ][ 'mpnr' ]) !== '' ;
$partIpnValid = isset ( $entry [ 'part' ][ 'ipn' ]) && is_string ( $entry [ 'part' ][ 'ipn' ]) && trim ( $entry [ 'part' ][ 'ipn' ]) !== '' ;
if ( ! $partIdValid && ! $partNameValid && ! $partMpnrValid && ! $partIpnValid ) {
throw new UnexpectedValueException (
'The property "part" must have either assigned: "id" as integer greater than 0, "name", "mpnr", or "ipn" as non-empty string'
);
}
$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 ) {
$part = new Part ();
$part -> setName ( $entry [ 'part' ][ 'name' ]);
}
if ( $partNameValid && $part -> getName () !== trim ( $entry [ 'part' ][ 'name' ])) {
throw new RuntimeException ( sprintf ( 'Part name does not match exact the given name. Given for import: %s, found part: %s' , $entry [ 'part' ][ 'name' ], $part -> getName ()));
}
if ( $partIpnValid && $part -> getManufacturerProductNumber () !== trim ( $entry [ 'part' ][ 'mpnr' ])) {
throw new RuntimeException ( sprintf ( 'Part mpnr does not match exact the given mpnr. Given for import: %s, found part: %s' , $entry [ 'part' ][ 'mpnr' ], $part -> getManufacturerProductNumber ()));
}
if ( $partIpnValid && $part -> getIpn () !== trim ( $entry [ 'part' ][ 'ipn' ])) {
throw new RuntimeException ( sprintf ( 'Part ipn does not match exact the given ipn. Given for import: %s, found part: %s' , $entry [ 'part' ][ 'ipn' ], $part -> getIpn ()));
}
// Part: Description check
if ( isset ( $entry [ 'part' ][ 'description' ]) && ! is_null ( $entry [ 'part' ][ 'description' ])) {
if ( ! is_string ( $entry [ 'part' ][ 'description' ]) || trim ( $entry [ 'part' ][ 'description' ]) === '' ) {
throw new UnexpectedValueException ( 'The property path "part.description" must be a non-empty string if not null' );
}
}
$partDescription = $entry [ 'part' ][ 'description' ] ? ? '' ;
// Part: Manufacturer check
$manufacturerIdValid = false ;
$manufacturerNameValid = false ;
if ( array_key_exists ( 'manufacturer' , $entry [ 'part' ])) {
if ( ! is_array ( $entry [ 'part' ][ 'manufacturer' ])) {
throw new UnexpectedValueException ( 'The property path "part.manufacturer" must be an array' );
}
$manufacturerIdValid = isset ( $entry [ 'part' ][ 'manufacturer' ][ 'id' ]) && is_int ( $entry [ 'part' ][ 'manufacturer' ][ 'id' ]) && $entry [ 'part' ][ 'manufacturer' ][ 'id' ] > 0 ;
$manufacturerNameValid = isset ( $entry [ 'part' ][ 'manufacturer' ][ 'name' ]) && is_string ( $entry [ 'part' ][ 'manufacturer' ][ 'name' ]) && trim ( $entry [ 'part' ][ 'manufacturer' ][ 'name' ]) !== '' ;
// Stellen sicher, dass mindestens eine Bedingung für manufacturer erfüllt sein muss
if ( ! $manufacturerIdValid && ! $manufacturerNameValid ) {
throw new UnexpectedValueException (
'The property "manufacturer" must have either assigned: "id" as integer greater than 0, or "name" as non-empty string'
);
}
}
$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 ( $manufacturer === null ) {
throw new RuntimeException (
'Manufacturer not found'
);
}
if ( $manufacturerNameValid && $manufacturer -> getName () !== trim ( $entry [ 'part' ][ 'manufacturer' ][ 'name' ])) {
throw new RuntimeException ( sprintf ( 'Manufacturer name does not match exact the given name. Given for import: %s, found manufacturer: %s' , $entry [ 'manufacturer' ][ 'name' ], $manufacturer -> getName ()));
}
// Part: Category check
$categoryIdValid = false ;
$categoryNameValid = false ;
if ( array_key_exists ( 'category' , $entry [ 'part' ])) {
if ( ! is_array ( $entry [ 'part' ][ 'category' ])) {
throw new UnexpectedValueException ( 'part.category must be an array' );
}
$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 ) {
throw new UnexpectedValueException (
'The property "category" must have either assigned: "id" as integer greater than 0, or "name" as non-empty string'
);
}
}
$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 ( $category === null ) {
throw new RuntimeException (
'Category not found'
);
}
if ( $categoryNameValid && $category -> getName () !== trim ( $entry [ 'part' ][ 'category' ][ 'name' ])) {
throw new RuntimeException ( sprintf ( 'Category name does not match exact the given name. Given for import: %s, found category: %s' , $entry [ 'category' ][ 'name' ], $category -> getName ()));
}
$part -> setDescription ( $partDescription );
$part -> setManufacturer ( $manufacturer );
$part -> setCategory ( $category );
if ( $partMpnrValid ) {
$part -> setManufacturerProductNumber ( $entry [ 'part' ][ 'mpnr' ] ? ? '' );
}
if ( $partIpnValid ) {
$part -> setIpn ( $entry [ 'part' ][ 'ipn' ] ? ? '' );
}
if ( $objectType === AssemblyBOMEntry :: class ) {
$bomEntry = $this -> assemblyBOMEntryRepository -> findOneBy ([ 'part' => $part ]);
if ( $bomEntry === null ) {
$name = isset ( $entry [ 'name' ]) && $entry [ 'name' ] !== null ? trim ( $entry [ 'name' ]) : '' ;
$bomEntry = $this -> assemblyBOMEntryRepository -> findOneBy ([ 'name' => $name ]);
if ( $bomEntry === null ) {
$bomEntry = new AssemblyBOMEntry ();
}
}
} else {
$bomEntry = new ProjectBOMEntry ();
}
$bomEntry -> setQuantity ( $entry [ 'quantity' ]);
$bomEntry -> setName ( $entry [ 'name' ] ? ? '' );
$bomEntry -> setPart ( $part );
}
$result [] = $bomEntry ;
}
return $result ;
}
2024-04-29 00:09:10 +02:00
/**
* 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 ;
}
2024-12-28 22:31:04 +01:00
//@phpstan-ignore-next-line We want to keep this check just to be safe when something changes
2025-03-19 08:13:45 +01:00
$new_index = self :: MAP_KICAD_PCB_FIELDS [ $index ] ? ? throw new UnexpectedValueException ( 'Invalid field index!' );
2024-04-29 00:09:10 +02:00
$out [ $new_index ] = $field ;
}
return $out ;
}
2025-08-03 18:46:46 +02:00
/**
* 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 );
}
2023-06-11 18:59:07 +02:00
}