add supplier SPN linking for BOM import (#1209)
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / docker (push) Waiting to run
Docker Image Build (FrankenPHP) / docker (push) Waiting to run
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run

* feat: add supplier SPN lookup for BOM import

Add automatic part linking via supplier part numbers (SPNs) in the
BOM importer. When a Part-DB ID is not provided, the importer now
searches for existing parts by matching supplier SPNs from the CSV
with orderdetail records in the database.

This allows automatic part linking when KiCad schematic BOMs contain
supplier information like LCSC SPN, Mouser SPN, etc., improving the
import workflow for users who track parts by supplier part numbers.

* add tests for BOM import with supplier SPN handling
This commit is contained in:
Niklas 2026-01-31 22:37:43 +01:00 committed by GitHub
parent 584643d4ca
commit a355bda9da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 214 additions and 1 deletions

View file

@ -277,8 +277,11 @@ class BOMImporter
// Fetch suppliers once for efficiency // Fetch suppliers once for efficiency
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll(); $suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$supplierSPNKeys = []; $supplierSPNKeys = [];
$suppliersByName = []; // Map supplier names to supplier objects
foreach ($suppliers as $supplier) { foreach ($suppliers as $supplier) {
$supplierSPNKeys[] = $supplier->getName() . ' SPN'; $supplierName = $supplier->getName();
$supplierSPNKeys[] = $supplierName . ' SPN';
$suppliersByName[$supplierName] = $supplier;
} }
foreach ($csv->getRecords() as $offset => $entry) { foreach ($csv->getRecords() as $offset => $entry) {
@ -356,6 +359,41 @@ class BOMImporter
} }
} }
// Try to link existing part based on supplier part number if no Part-DB ID is given
if ($part === null) {
// Check all available supplier SPN fields
foreach ($suppliersByName as $supplierName => $supplier) {
$supplier_spn = null;
if (isset($mapped_entry[$supplierName . ' SPN']) && !empty(trim($mapped_entry[$supplierName . ' SPN']))) {
$supplier_spn = trim($mapped_entry[$supplierName . ' SPN']);
}
if ($supplier_spn !== null) {
// Query for orderdetails with matching supplier and SPN
$orderdetail = $this->entityManager->getRepository(\App\Entity\PriceInformations\Orderdetail::class)
->findOneBy([
'supplier' => $supplier,
'supplierpartnr' => $supplier_spn,
]);
if ($orderdetail !== null && $orderdetail->getPart() !== null) {
$part = $orderdetail->getPart();
$name = $part->getName(); // Update name with actual part name
$this->logger->info('Linked BOM entry to existing part via supplier SPN', [
'supplier' => $supplierName,
'supplier_spn' => $supplier_spn,
'part_id' => $part->getID(),
'part_name' => $part->getName(),
]);
break; // Stop searching once a match is found
}
}
}
}
// Create unique key for this entry (name + part ID) // Create unique key for this entry (name + part ID)
$entry_key = $name . '|' . ($part ? $part->getID() : 'null'); $entry_key = $name . '|' . ($part ? $part->getID() : 'null');

View file

@ -616,6 +616,181 @@ class BOMImporterTest extends WebTestCase
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames()); $this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
} }
public function testStringToBOMEntriesKiCADSchematicWithSupplierSPN(): void
{
// Create test supplier
$lcscSupplier = new Supplier();
$lcscSupplier->setName('LCSC');
$this->entityManager->persist($lcscSupplier);
// Create a test part with required fields
$part = new Part();
$part->setName('Test Resistor 10k 0805');
$part->setCategory($this->getDefaultCategory($this->entityManager));
$this->entityManager->persist($part);
// Create orderdetail linking the part to a supplier SPN
$orderdetail = new \App\Entity\PriceInformations\Orderdetail();
$orderdetail->setPart($part);
$orderdetail->setSupplier($lcscSupplier);
$orderdetail->setSupplierpartnr('C123456');
$this->entityManager->persist($orderdetail);
$this->entityManager->flush();
// Import CSV with LCSC SPN matching the orderdetail
$input = <<<CSV
"Reference","Value","LCSC SPN","Quantity"
"R1,R2","10k","C123456","2"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'LCSC SPN' => 'LCSC SPN',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(1, $bom_entries);
// Verify that the BOM entry is linked to the correct part via supplier SPN
$this->assertSame($part, $bom_entries[0]->getPart());
$this->assertEquals('Test Resistor 10k 0805', $bom_entries[0]->getName());
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
$this->assertEquals(2.0, $bom_entries[0]->getQuantity());
$this->assertStringContainsString('LCSC SPN: C123456', $bom_entries[0]->getComment());
$this->assertStringContainsString('Part-DB ID: ' . $part->getID(), $bom_entries[0]->getComment());
// Clean up
$this->entityManager->remove($orderdetail);
$this->entityManager->remove($part);
$this->entityManager->remove($lcscSupplier);
$this->entityManager->flush();
}
public function testStringToBOMEntriesKiCADSchematicWithMultipleSupplierSPNs(): void
{
// Create test suppliers
$lcscSupplier = new Supplier();
$lcscSupplier->setName('LCSC');
$mouserSupplier = new Supplier();
$mouserSupplier->setName('Mouser');
$this->entityManager->persist($lcscSupplier);
$this->entityManager->persist($mouserSupplier);
// Create first part linked via LCSC SPN
$part1 = new Part();
$part1->setName('Resistor 10k');
$part1->setCategory($this->getDefaultCategory($this->entityManager));
$this->entityManager->persist($part1);
$orderdetail1 = new \App\Entity\PriceInformations\Orderdetail();
$orderdetail1->setPart($part1);
$orderdetail1->setSupplier($lcscSupplier);
$orderdetail1->setSupplierpartnr('C123456');
$this->entityManager->persist($orderdetail1);
// Create second part linked via Mouser SPN
$part2 = new Part();
$part2->setName('Capacitor 100nF');
$part2->setCategory($this->getDefaultCategory($this->entityManager));
$this->entityManager->persist($part2);
$orderdetail2 = new \App\Entity\PriceInformations\Orderdetail();
$orderdetail2->setPart($part2);
$orderdetail2->setSupplier($mouserSupplier);
$orderdetail2->setSupplierpartnr('789-CAP100NF');
$this->entityManager->persist($orderdetail2);
$this->entityManager->flush();
// Import CSV with both LCSC and Mouser SPNs
$input = <<<CSV
"Reference","Value","LCSC SPN","Mouser SPN","Quantity"
"R1","10k","C123456","","1"
"C1","100nF","","789-CAP100NF","1"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'LCSC SPN' => 'LCSC SPN',
'Mouser SPN' => 'Mouser SPN',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertCount(2, $bom_entries);
// Verify first entry linked via LCSC SPN
$this->assertSame($part1, $bom_entries[0]->getPart());
$this->assertEquals('Resistor 10k', $bom_entries[0]->getName());
// Verify second entry linked via Mouser SPN
$this->assertSame($part2, $bom_entries[1]->getPart());
$this->assertEquals('Capacitor 100nF', $bom_entries[1]->getName());
// Clean up
$this->entityManager->remove($orderdetail1);
$this->entityManager->remove($orderdetail2);
$this->entityManager->remove($part1);
$this->entityManager->remove($part2);
$this->entityManager->remove($lcscSupplier);
$this->entityManager->remove($mouserSupplier);
$this->entityManager->flush();
}
public function testStringToBOMEntriesKiCADSchematicWithNonMatchingSPN(): void
{
// Create test supplier
$lcscSupplier = new Supplier();
$lcscSupplier->setName('LCSC');
$this->entityManager->persist($lcscSupplier);
$this->entityManager->flush();
// Import CSV with LCSC SPN that doesn't match any orderdetail
$input = <<<CSV
"Reference","Value","LCSC SPN","Quantity"
"R1","10k","C999999","1"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'LCSC SPN' => 'LCSC SPN',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertCount(1, $bom_entries);
// Verify that no part is linked (SPN not found)
$this->assertNull($bom_entries[0]->getPart());
$this->assertEquals('10k', $bom_entries[0]->getName()); // Should use Value as name
$this->assertStringContainsString('LCSC SPN: C999999', $bom_entries[0]->getComment());
// Clean up
$this->entityManager->remove($lcscSupplier);
$this->entityManager->flush();
}
private function getDefaultCategory(EntityManagerInterface $entityManager) private function getDefaultCategory(EntityManagerInterface $entityManager)
{ {
// Get the first available category or create a default one // Get the first available category or create a default one