From a355bda9da6eb7011036f6aac886932d31a4fbcc Mon Sep 17 00:00:00 2001 From: Niklas <44636701+MayNiklas@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:37:43 +0100 Subject: [PATCH] add supplier SPN linking for BOM import (#1209) * 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 --- .../ImportExportSystem/BOMImporter.php | 40 +++- .../ImportExportSystem/BOMImporterTest.php | 175 ++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php index 33a402cb..8a91c825 100644 --- a/src/Services/ImportExportSystem/BOMImporter.php +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -277,8 +277,11 @@ class BOMImporter // Fetch suppliers once for efficiency $suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll(); $supplierSPNKeys = []; + $suppliersByName = []; // Map supplier names to supplier objects foreach ($suppliers as $supplier) { - $supplierSPNKeys[] = $supplier->getName() . ' SPN'; + $supplierName = $supplier->getName(); + $supplierSPNKeys[] = $supplierName . ' SPN'; + $suppliersByName[$supplierName] = $supplier; } 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) $entry_key = $name . '|' . ($part ? $part->getID() : 'null'); diff --git a/tests/Services/ImportExportSystem/BOMImporterTest.php b/tests/Services/ImportExportSystem/BOMImporterTest.php index 47ddcc24..a8841f17 100644 --- a/tests/Services/ImportExportSystem/BOMImporterTest.php +++ b/tests/Services/ImportExportSystem/BOMImporterTest.php @@ -616,6 +616,181 @@ class BOMImporterTest extends WebTestCase $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 = << '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 = << '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 = << '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) { // Get the first available category or create a default one