diff --git a/docs/usage/eda_integration.md b/docs/usage/eda_integration.md index 9b09c5a08..0c42f461d 100644 --- a/docs/usage/eda_integration.md +++ b/docs/usage/eda_integration.md @@ -96,6 +96,30 @@ If you set this value to -1, all parts are shown inside a single category in KiC You can view the "real" category path of a part in the part details dialog in KiCad. +### Exported symbol fields + +Besides the fields KiCad needs (symbol, footprint, reference, value, datasheet, description) Part-DB exports additional +information as symbol fields: manufacturer and MPN, the Part-DB ID and URL, stock and storage locations, supplier part +numbers, KiCost compatible fields (`manf`, `manf#`, `#`), part info (category, manufacturing status, mass, IPN, ...) +and the tags as symbol keywords. + +KiCad compares the fields of a placed symbol with the library. Every difference is reported as a "library symbol mismatch" +by the ERC. If you use the stock or supplier fields, every stock booking or supplier edit in Part-DB therefore triggers +ERC warnings in all schematics that use the part. + +To avoid this, you can disable groups of fields that you do not need in your schematics in the server settings under +"KiCAD integration", or via the following env options (a value of `0` disables the group): + +| Option | Fields | +|---------------------------------------|--------------------------------------------------------------------------------------------------| +| `EDA_KICAD_EXPORT_STOCK_FIELDS` | `Stock`, `Storage Location` | +| `EDA_KICAD_EXPORT_SUPPLIER_FIELDS` | ` SPN` fields | +| `EDA_KICAD_EXPORT_KICOST_FIELDS` | `manf`, `manf#`, `#` | +| `EDA_KICAD_EXPORT_PART_INFO_FIELDS` | `Category`, `Manufacturing Status`, `Mass`, `Part-DB IPN`, `Part-DB Footprint`, `Part-DB Unit`, `Part-DB Custom state` | +| `EDA_KICAD_EXPORT_TAGS_AS_KEYWORDS` | symbol keywords (from the part tags) | + +All groups are enabled by default, so existing installations keep exporting the same fields as before. + ### Kicad:populate command Part-DB also provides a command that attempts to automatically populate the KiCad symbol and footprint fields based on the part's category and footprint names. diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php index 85159c51d..5be54e071 100644 --- a/src/Services/EDA/KiCadHelper.php +++ b/src/Services/EDA/KiCadHelper.php @@ -145,7 +145,10 @@ public function getCategories(): array */ public function getCategoryParts(?Category $category): array { - $cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth; + // Settings fingerprint keeps cached responses from surviving settings changes + // (tag-based invalidation only fires on part/category/footprint edits) + $cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth + . '_' . $this->edaSettingsFingerprint(); return $this->kicadCache->get($cacheKey, function (ItemInterface $item) use ($category) { $item->tag([ @@ -213,7 +216,9 @@ public function getKiCADPart(Part $part): array $result["fields"]["footprint"] = $this->createField($part->getEdaInfo()->getKicadFootprint() ?? $part->getFootprint()?->getEdaInfo()->getKicadFootprint() ?? ""); $result["fields"]["reference"] = $this->createField($part->getEdaInfo()->getReferencePrefix() ?? $part->getCategory()?->getEdaInfo()->getReferencePrefix() ?? 'U', true); $result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true); - $result["fields"]["keywords"] = $this->createField($part->getTags()); + if ($this->kiCadEDASettings->exportTagsAsKeywords) { + $result["fields"]["keywords"] = $this->createField($part->getTags()); + } //Use the part info page as Part-DB link. It must be an absolute URL. $partUrl = $this->urlGenerator->generate( @@ -233,7 +238,7 @@ public function getKiCADPart(Part $part): array //Add basic fields $result["fields"]["description"] = $this->createField($part->getDescription()); - if ($part->getCategory() !== null) { + if ($this->kiCadEDASettings->exportPartInfoFields && $part->getCategory() !== null) { $result["fields"]["Category"] = $this->createField($part->getCategory()->getFullPath('/')); } if ($part->getManufacturer() !== null) { @@ -242,39 +247,40 @@ public function getKiCADPart(Part $part): array if ($part->getManufacturerProductNumber() !== "") { $result['fields']["MPN"] = $this->createField($part->getManufacturerProductNumber()); } - if ($part->getManufacturingStatus() !== null) { + if ($this->kiCadEDASettings->exportPartInfoFields && $part->getManufacturingStatus() !== null) { $result["fields"]["Manufacturing Status"] = $this->createField( //Always use the english translation $this->translator->trans($part->getManufacturingStatus()->toTranslationKey(), locale: 'en') ); } - if ($part->getFootprint() !== null) { + if ($this->kiCadEDASettings->exportPartInfoFields && $part->getFootprint() !== null) { $result["fields"]["Part-DB Footprint"] = $this->createField($part->getFootprint()->getName()); } - if ($part->getPartUnit() !== null) { + if ($this->kiCadEDASettings->exportPartInfoFields && $part->getPartUnit() !== null) { $unit = $part->getPartUnit()->getName(); if ($part->getPartUnit()->getUnit() !== "") { $unit .= ' ('.$part->getPartUnit()->getUnit().')'; } $result["fields"]["Part-DB Unit"] = $this->createField($unit); } - if ($part->getPartCustomState() !== null) { + if ($this->kiCadEDASettings->exportPartInfoFields && $part->getPartCustomState() !== null) { $customState = $part->getPartCustomState()->getName(); $result["fields"]["Part-DB Custom state"] = $this->createField($customState); } - if ($part->getMass()) { + if ($this->kiCadEDASettings->exportPartInfoFields && $part->getMass()) { $result["fields"]["Mass"] = $this->createField($part->getMass() . ' g'); } $result["fields"]["Part-DB ID"] = $this->createField($part->getId()); - if ($part->getIpn() !== null && $part->getIpn() !== '' && $part->getIpn() !== '0') { + if ($this->kiCadEDASettings->exportPartInfoFields + && $part->getIpn() !== null && $part->getIpn() !== '' && $part->getIpn() !== '0') { $result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn()); } //Add KiCost manufacturer fields (always present, independent of orderdetails) - if ($part->getManufacturer() !== null) { + if ($this->kiCadEDASettings->exportKicostFields && $part->getManufacturer() !== null) { $result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName()); } - if ($part->getManufacturerProductNumber() !== "") { + if ($this->kiCadEDASettings->exportKicostFields && $part->getManufacturerProductNumber() !== "") { $result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber()); } @@ -282,7 +288,8 @@ public function getKiCADPart(Part $part): array // If any orderdetail has eda_visibility explicitly set to true, only export those; // otherwise export all (backward compat when no flags are set) $allOrderdetails = $part->getOrderdetails(false); - if ($allOrderdetails->count() > 0) { + if (($this->kiCadEDASettings->exportSupplierFields || $this->kiCadEDASettings->exportKicostFields) + && $allOrderdetails->count() > 0) { $hasExplicitEdaVisibility = false; foreach ($allOrderdetails as $od) { if ($od->isEdaVisibility() !== null) { @@ -312,30 +319,36 @@ public function getKiCADPart(Part $part): array ? $supplierName . ' ' . $supplierCounts[$supplierName] : $supplierName; - $result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr()); + if ($this->kiCadEDASettings->exportSupplierFields) { + $result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr()); + } //Also add a KiCost-compatible field (supplier_name# = SPN) - $kicostFieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#'; - $result["fields"][$kicostFieldName] = $this->createField($orderdetail->getSupplierPartNr()); + if ($this->kiCadEDASettings->exportKicostFields) { + $kicostFieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#'; + $result["fields"][$kicostFieldName] = $this->createField($orderdetail->getSupplierPartNr()); + } } } } //Add stock quantity and storage locations (only count non-expired lots with known quantity) - $totalStock = 0; - $locations = []; - foreach ($part->getPartLots() as $lot) { - $isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true; - if ($isAvailable) { - $totalStock += $lot->getAmount(); - if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) { - $locations[] = $lot->getStorageLocation()->getName(); + if ($this->kiCadEDASettings->exportStockFields) { + $totalStock = 0; + $locations = []; + foreach ($part->getPartLots() as $lot) { + $isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true; + if ($isAvailable) { + $totalStock += $lot->getAmount(); + if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) { + $locations[] = $lot->getStorageLocation()->getName(); + } } } - } - $result['fields']['Stock'] = $this->createField($totalStock); - if ($locations !== []) { - $result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations))); + $result['fields']['Stock'] = $this->createField($totalStock); + if ($locations !== []) { + $result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations))); + } } //Add parameters marked for EDA export (explicit true, or system default when null) @@ -355,6 +368,24 @@ public function getKiCADPart(Part $part): array return $result; } + /** + * Fingerprint of every setting that changes the content of a serialized part. + */ + private function edaSettingsFingerprint(): string + { + return md5(json_encode([ + $this->datasheetAsPdf, + $this->kiCadEDASettings->defaultOrderdetailsVisibility, + $this->kiCadEDASettings->defaultParameterVisibility, + $this->kiCadEDASettings->defaultParameterSymbolVisibility, + $this->kiCadEDASettings->exportStockFields, + $this->kiCadEDASettings->exportSupplierFields, + $this->kiCadEDASettings->exportKicostFields, + $this->kiCadEDASettings->exportPartInfoFields, + $this->kiCadEDASettings->exportTagsAsKeywords, + ], JSON_THROW_ON_ERROR)); + } + /** * Determine if the given part should be visible for the EDA. * @param Category $category diff --git a/src/Settings/MiscSettings/KiCadEDASettings.php b/src/Settings/MiscSettings/KiCadEDASettings.php index a32b31e5a..6f88dd049 100644 --- a/src/Settings/MiscSettings/KiCadEDASettings.php +++ b/src/Settings/MiscSettings/KiCadEDASettings.php @@ -75,4 +75,39 @@ class KiCadEDASettings description: new TM("settings.misc.kicad_eda.use_custom_list.help"), )] public bool $useCustomList = false; + + #[SettingsParameter( + label: new TM("settings.misc.kicad_eda.export_stock_fields"), + description: new TM("settings.misc.kicad_eda.export_stock_fields.help"), + envVar: "bool:EDA_KICAD_EXPORT_STOCK_FIELDS", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $exportStockFields = true; + + #[SettingsParameter( + label: new TM("settings.misc.kicad_eda.export_supplier_fields"), + description: new TM("settings.misc.kicad_eda.export_supplier_fields.help"), + envVar: "bool:EDA_KICAD_EXPORT_SUPPLIER_FIELDS", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $exportSupplierFields = true; + + #[SettingsParameter( + label: new TM("settings.misc.kicad_eda.export_kicost_fields"), + description: new TM("settings.misc.kicad_eda.export_kicost_fields.help"), + envVar: "bool:EDA_KICAD_EXPORT_KICOST_FIELDS", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $exportKicostFields = true; + + #[SettingsParameter( + label: new TM("settings.misc.kicad_eda.export_part_info_fields"), + description: new TM("settings.misc.kicad_eda.export_part_info_fields.help"), + envVar: "bool:EDA_KICAD_EXPORT_PART_INFO_FIELDS", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $exportPartInfoFields = true; + + #[SettingsParameter( + label: new TM("settings.misc.kicad_eda.export_tags_as_keywords"), + description: new TM("settings.misc.kicad_eda.export_tags_as_keywords.help"), + envVar: "bool:EDA_KICAD_EXPORT_TAGS_AS_KEYWORDS", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $exportTagsAsKeywords = true; } diff --git a/tests/Services/EDA/KiCadHelperTest.php b/tests/Services/EDA/KiCadHelperTest.php index b043d5980..c661e6064 100644 --- a/tests/Services/EDA/KiCadHelperTest.php +++ b/tests/Services/EDA/KiCadHelperTest.php @@ -33,6 +33,7 @@ use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Orderdetail; use App\Services\EDA\KiCadHelper; +use App\Settings\MiscSettings\KiCadEDASettings; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\Group; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -42,12 +43,14 @@ final class KiCadHelperTest extends KernelTestCase { private KiCadHelper $helper; private EntityManagerInterface $em; + private KiCadEDASettings $settings; protected function setUp(): void { self::bootKernel(); $this->helper = self::getContainer()->get(KiCadHelper::class); $this->em = self::getContainer()->get(EntityManagerInterface::class); + $this->settings = self::getContainer()->get(KiCadEDASettings::class); } /** @@ -654,4 +657,178 @@ public function testCategoryPartsListingContainsFields(): void self::assertArrayHasKey('symbolIdStr', $part); } } + + /** + * The category listing is cached and only invalidated by entity changes. + * Changing a setting that affects the exported fields must not serve a stale listing. + */ + public function testCategoryPartsCacheIsInvalidatedBySettingsChange(): void + { + /** @var KiCadEDASettings $settings */ + $settings = self::getContainer()->get(KiCadEDASettings::class); + $category = $this->em->find(Category::class, 1); + + $part = new Part(); + $part->setName('Part for cache test'); + $part->setCategory($category); + $param = new PartParameter(); + $param->setName('CacheTestParam'); + $param->setValueText('42'); + $param->setEdaVisibility(null); + $part->addParameter($param); + $this->em->persist($part); + $this->em->flush(); + + $findFields = fn(array $listing): array => array_values(array_filter($listing, fn($p) => (int) $p['id'] === $part->getId()))[0]['fields']; + + $settings->defaultParameterVisibility = false; + $before = $findFields($this->helper->getCategoryParts($category)); + self::assertArrayNotHasKey('CacheTestParam', $before); + + // No entity changed, only the setting: the cached listing must not be reused + $settings->defaultParameterVisibility = true; + $after = $findFields($this->helper->getCategoryParts($category)); + self::assertArrayHasKey('CacheTestParam', $after); + self::assertSame('42', $after['CacheTestParam']['value']); + } + + /** + * Creates a part that has data for every optional field group (stock, supplier, KiCost, part info, tags). + */ + private function createPartWithAllFieldGroups(): Part + { + $category = $this->em->find(Category::class, 1); + $location = $this->em->find(StorageLocation::class, 1); + + $manufacturer = new Manufacturer(); + $manufacturer->setName('Switch Corp'); + $this->em->persist($manufacturer); + + $supplier = new Supplier(); + $supplier->setName('SwitchSupplier'); + $this->em->persist($supplier); + + $part = new Part(); + $part->setName('Part with all field groups'); + $part->setCategory($category); + $part->setManufacturer($manufacturer); + $part->setManufacturerProductNumber('SW-1234'); + $part->setTags('tag1,tag2'); + $part->setMass(1.5); + + $lot = new PartLot(); + $lot->setAmount(5); + $lot->setStorageLocation($location); + $part->addPartLot($lot); + + $orderdetail = new Orderdetail(); + $orderdetail->setSupplier($supplier); + $orderdetail->setSupplierpartnr('SUP-999'); + $part->addOrderdetail($orderdetail); + + $this->em->persist($part); + $this->em->flush(); + + return $part; + } + + /** + * With the default settings every field group is exported. + */ + public function testAllFieldGroupsExportedByDefault(): void + { + $part = $this->createPartWithAllFieldGroups(); + $fields = $this->helper->getKiCADPart($part)['fields']; + + self::assertArrayHasKey('Stock', $fields); + self::assertArrayHasKey('Storage Location', $fields); + self::assertArrayHasKey('SwitchSupplier SPN', $fields); + self::assertArrayHasKey('manf', $fields); + self::assertArrayHasKey('manf#', $fields); + self::assertArrayHasKey('switchsupplier#', $fields); + self::assertArrayHasKey('Category', $fields); + self::assertArrayHasKey('Mass', $fields); + self::assertArrayHasKey('keywords', $fields); + self::assertSame('tag1,tag2', $fields['keywords']['value']); + } + + public function testStockFieldsCanBeDisabled(): void + { + $this->settings->exportStockFields = false; + $part = $this->createPartWithAllFieldGroups(); + $fields = $this->helper->getKiCADPart($part)['fields']; + + self::assertArrayNotHasKey('Stock', $fields); + self::assertArrayNotHasKey('Storage Location', $fields); + // Other groups are unaffected + self::assertArrayHasKey('SwitchSupplier SPN', $fields); + self::assertArrayHasKey('manf#', $fields); + } + + public function testSupplierFieldsCanBeDisabledIndependentlyOfKicostFields(): void + { + $this->settings->exportSupplierFields = false; + $part = $this->createPartWithAllFieldGroups(); + $fields = $this->helper->getKiCADPart($part)['fields']; + + self::assertArrayNotHasKey('SwitchSupplier SPN', $fields); + // KiCost supplier field is still exported, as it is controlled by its own switch + self::assertArrayHasKey('switchsupplier#', $fields); + self::assertSame('SUP-999', $fields['switchsupplier#']['value']); + } + + public function testKicostFieldsCanBeDisabledIndependentlyOfSupplierFields(): void + { + $this->settings->exportKicostFields = false; + $part = $this->createPartWithAllFieldGroups(); + $fields = $this->helper->getKiCADPart($part)['fields']; + + self::assertArrayNotHasKey('manf', $fields); + self::assertArrayNotHasKey('manf#', $fields); + self::assertArrayNotHasKey('switchsupplier#', $fields); + // The regular supplier and manufacturer fields stay + self::assertArrayHasKey('SwitchSupplier SPN', $fields); + self::assertArrayHasKey('Manufacturer', $fields); + self::assertArrayHasKey('MPN', $fields); + } + + public function testPartInfoFieldsCanBeDisabled(): void + { + $this->settings->exportPartInfoFields = false; + $part = $this->createPartWithAllFieldGroups(); + $fields = $this->helper->getKiCADPart($part)['fields']; + + self::assertArrayNotHasKey('Category', $fields); + self::assertArrayNotHasKey('Mass', $fields); + // Fields needed by KiCad itself and for identification are always present + self::assertArrayHasKey('Part-DB ID', $fields); + self::assertArrayHasKey('Part-DB URL', $fields); + self::assertArrayHasKey('MPN', $fields); + self::assertArrayHasKey('description', $fields); + } + + public function testTagsAsKeywordsCanBeDisabled(): void + { + $this->settings->exportTagsAsKeywords = false; + $part = $this->createPartWithAllFieldGroups(); + $fields = $this->helper->getKiCADPart($part)['fields']; + + self::assertArrayNotHasKey('keywords', $fields); + } + + /** + * The new export switches must also be part of the cache fingerprint. + */ + public function testCategoryPartsCacheIsInvalidatedByExportSettingsChange(): void + { + $category = $this->em->find(Category::class, 1); + + $before = $this->helper->getCategoryParts($category); + self::assertNotEmpty($before); + self::assertArrayHasKey('Stock', $before[0]['fields']); + + $this->settings->exportStockFields = false; + $after = $this->helper->getCategoryParts($category); + self::assertArrayNotHasKey('Stock', $after[0]['fields']); + } } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index f7a9d4e9f..1a7208f9e 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -10224,6 +10224,66 @@ Please note, that you can not impersonate a disabled user. If you try you will g When enabled, KiCad autocomplete uses public/kicad/footprints_custom.txt and public/kicad/symbols_custom.txt instead of the autogenerated default files. + + + settings.misc.kicad_eda.export_stock_fields + Export stock fields (Stock, Storage Location) + + + + + settings.misc.kicad_eda.export_stock_fields.help + When enabled, the current stock and the storage locations are exported as symbol fields. Disable this if stock changes should not modify the KiCad symbols (each change is reported as a library symbol mismatch by the KiCad ERC). + + + + + settings.misc.kicad_eda.export_supplier_fields + Export supplier part number fields + + + + + settings.misc.kicad_eda.export_supplier_fields.help + When enabled, one "<Supplier> SPN" field per order detail is exported. + + + + + settings.misc.kicad_eda.export_kicost_fields + Export KiCost fields (manf, manf#, <supplier>#) + + + + + settings.misc.kicad_eda.export_kicost_fields.help + When enabled, KiCost-compatible copies of the manufacturer, manufacturer part number and supplier part numbers are exported. + + + + + settings.misc.kicad_eda.export_part_info_fields + Export part info fields (Category, Manufacturing Status, Mass, IPN, ...) + + + + + settings.misc.kicad_eda.export_part_info_fields.help + When enabled, the Category, Manufacturing Status, Mass, Part-DB IPN, Part-DB Footprint, Part-DB Unit and Part-DB Custom state fields are exported. + + + + + settings.misc.kicad_eda.export_tags_as_keywords + Export tags as keywords + + + + + settings.misc.kicad_eda.export_tags_as_keywords.help + When enabled, the part tags are exported as the KiCad symbol keywords. Disable this if tag changes should not modify the KiCad symbols. + + settings.misc.kicad_eda.editor.custom_footprints