diff --git a/migrations/Version20260210120000.php b/migrations/Version20260210120000.php new file mode 100644 index 00000000..04684a36 --- /dev/null +++ b/migrations/Version20260210120000.php @@ -0,0 +1,46 @@ +addSql('ALTER TABLE `orderdetails` ADD kicad_export TINYINT(1) NOT NULL DEFAULT 0'); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE `orderdetails` DROP COLUMN kicad_export'); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql('ALTER TABLE orderdetails ADD COLUMN kicad_export BOOLEAN NOT NULL DEFAULT 0'); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql('ALTER TABLE orderdetails DROP COLUMN kicad_export'); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql('ALTER TABLE orderdetails ADD kicad_export BOOLEAN NOT NULL DEFAULT FALSE'); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE orderdetails DROP COLUMN kicad_export'); + } +} diff --git a/src/Controller/KiCadApiController.php b/src/Controller/KiCadApiController.php index 70ba7786..ea93138c 100644 --- a/src/Controller/KiCadApiController.php +++ b/src/Controller/KiCadApiController.php @@ -75,7 +75,8 @@ class KiCadApiController extends AbstractController } $this->denyAccessUnlessGranted('@parts.read'); - $data = $this->kiCADHelper->getCategoryParts($category); + $minimal = $request->query->getBoolean('minimal', false); + $data = $this->kiCADHelper->getCategoryParts($category, $minimal); return $this->createCachedJsonResponse($request, $data, 300); } diff --git a/src/Controller/KiCadApiV2Controller.php b/src/Controller/KiCadApiV2Controller.php new file mode 100644 index 00000000..5332ccd8 --- /dev/null +++ b/src/Controller/KiCadApiV2Controller.php @@ -0,0 +1,104 @@ +. + */ + +declare(strict_types=1); + +namespace App\Controller; + +use App\Entity\Parts\Category; +use App\Entity\Parts\Part; +use App\Services\EDA\KiCadHelper; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +/** + * KiCad HTTP Library API v2 controller. + * + * Differences from v1: + * - Volatile fields: Stock and Storage Location are marked volatile (shown in KiCad but NOT saved to schematic) + * - Category descriptions: Uses actual category comments instead of URLs + * - Response format: Arrays wrapped in objects for extensibility + */ +#[Route('/kicad-api/v2')] +class KiCadApiV2Controller extends AbstractController +{ + public function __construct( + private readonly KiCadHelper $kiCADHelper, + ) { + } + + #[Route('/', name: 'kicad_api_v2_root')] + public function root(): Response + { + $this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS'); + + return $this->json([ + 'categories' => '', + 'parts' => '', + ]); + } + + #[Route('/categories.json', name: 'kicad_api_v2_categories')] + public function categories(Request $request): Response + { + $this->denyAccessUnlessGranted('@categories.read'); + + $data = $this->kiCADHelper->getCategories(); + return $this->createCachedJsonResponse($request, $data, 300); + } + + #[Route('/parts/category/{category}.json', name: 'kicad_api_v2_category')] + public function categoryParts(Request $request, ?Category $category): Response + { + if ($category !== null) { + $this->denyAccessUnlessGranted('read', $category); + } else { + $this->denyAccessUnlessGranted('@categories.read'); + } + $this->denyAccessUnlessGranted('@parts.read'); + + $minimal = $request->query->getBoolean('minimal', false); + $data = $this->kiCADHelper->getCategoryParts($category, $minimal); + return $this->createCachedJsonResponse($request, $data, 300); + } + + #[Route('/parts/{part}.json', name: 'kicad_api_v2_part')] + public function partDetails(Request $request, Part $part): Response + { + $this->denyAccessUnlessGranted('read', $part); + + // Use API v2 format with volatile fields + $data = $this->kiCADHelper->getKiCADPart($part, true); + return $this->createCachedJsonResponse($request, $data, 60); + } + + private function createCachedJsonResponse(Request $request, array $data, int $maxAge): Response + { + $response = new JsonResponse($data); + $response->setEtag(md5(json_encode($data))); + $response->headers->set('Cache-Control', 'private, max-age=' . $maxAge); + $response->isNotModified($request); + + return $response; + } +} diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php index c33c3a82..ca9687eb 100644 --- a/src/DataTables/Helpers/PartDataTableHelper.php +++ b/src/DataTables/Helpers/PartDataTableHelper.php @@ -115,6 +115,59 @@ class PartDataTableHelper return implode('
', $tmp); } + /** + * Renders an EDA/KiCad completeness indicator for the given part. + * Shows icons for symbol, footprint, and value status. + */ + public function renderEdaStatus(Part $context): string + { + $edaInfo = $context->getEdaInfo(); + $category = $context->getCategory(); + $footprint = $context->getFootprint(); + + // Determine effective values (direct or inherited) + $hasSymbol = $edaInfo->getKicadSymbol() !== null || $category?->getEdaInfo()->getKicadSymbol() !== null; + $hasFootprint = $edaInfo->getKicadFootprint() !== null || $footprint?->getEdaInfo()->getKicadFootprint() !== null; + $hasReference = $edaInfo->getReferencePrefix() !== null || $category?->getEdaInfo()->getReferencePrefix() !== null; + + $symbolInherited = $edaInfo->getKicadSymbol() === null && $category?->getEdaInfo()->getKicadSymbol() !== null; + $footprintInherited = $edaInfo->getKicadFootprint() === null && $footprint?->getEdaInfo()->getKicadFootprint() !== null; + + $icons = []; + + // Symbol status + if ($hasSymbol) { + $title = $this->translator->trans('eda.status.symbol_set'); + $class = $symbolInherited ? 'text-info' : 'text-success'; + $icons[] = sprintf('', $class, $title); + } + + // Footprint status + if ($hasFootprint) { + $title = $this->translator->trans('eda.status.footprint_set'); + $class = $footprintInherited ? 'text-info' : 'text-success'; + $icons[] = sprintf('', $class, $title); + } + + // Reference prefix status + if ($hasReference) { + $icons[] = sprintf('', + $this->translator->trans('eda.status.reference_set')); + } + + if (empty($icons)) { + return ''; + } + + // Overall status: all 3 = green check, partial = yellow + $allSet = $hasSymbol && $hasFootprint && $hasReference; + $statusIcon = $allSet + ? sprintf('', $this->translator->trans('eda.status.complete')) + : sprintf('', $this->translator->trans('eda.status.partial')); + + return $statusIcon; + } + public function renderAmount(Part $context): string { $amount = $context->getAmountSum(); diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index d2faba76..84f1eadd 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -228,6 +228,11 @@ final class PartsDataTable implements DataTableTypeInterface ]) ->add('attachments', PartAttachmentsColumn::class, [ 'label' => $this->translator->trans('part.table.attachments'), + ]) + ->add('eda_status', TextColumn::class, [ + 'label' => $this->translator->trans('part.table.eda_status'), + 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderEdaStatus($context), + 'className' => 'text-center', ]); //Add a column to list the projects where the part is used, when the user has the permission to see the projects diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php index 58f69598..1584b3b0 100644 --- a/src/Entity/PriceInformations/Orderdetail.php +++ b/src/Entity/PriceInformations/Orderdetail.php @@ -122,6 +122,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N #[ORM\Column(type: Types::BOOLEAN)] protected bool $obsolete = false; + /** + * @var bool Whether this orderdetail's supplier part number should be exported as a KiCad field + */ + #[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])] + #[ORM\Column(type: Types::BOOLEAN)] + protected bool $kicad_export = false; + /** * @var string The URL to the product on the supplier's website */ @@ -418,6 +425,21 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N return $this; } + public function isKicadExport(): bool + { + return $this->kicad_export; + } + + /** + * @return $this + */ + public function setKicadExport(bool $kicad_export): self + { + $this->kicad_export = $kicad_export; + + return $this; + } + public function getName(): string { return $this->getSupplierPartNr(); diff --git a/src/Form/Part/OrderdetailType.php b/src/Form/Part/OrderdetailType.php index ca295c7e..d875f9e7 100644 --- a/src/Form/Part/OrderdetailType.php +++ b/src/Form/Part/OrderdetailType.php @@ -79,6 +79,11 @@ class OrderdetailType extends AbstractType 'label' => 'orderdetails.edit.prices_includes_vat', ]); + $builder->add('kicad_export', CheckboxType::class, [ + 'required' => false, + 'label' => 'orderdetails.edit.kicad_export', + ]); + //Add pricedetails after we know the data, so we can set the default currency $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void { /** @var Orderdetail $orderdetail */ diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php index 1617e886..882152dd 100644 --- a/src/Services/EDA/KiCadHelper.php +++ b/src/Services/EDA/KiCadHelper.php @@ -116,11 +116,16 @@ class KiCadHelper } //Format the category for KiCAD + // Use the category comment as description if available, otherwise use the Part-DB URL + $description = $category->getComment(); + if ($description === null || $description === '') { + $description = $this->entityURLGenerator->listPartsURL($category); + } + $result[] = [ 'id' => (string)$category->getId(), 'name' => $category->getFullPath('/'), - //Show the category link as the category description, this also fixes an segfault in KiCad see issue #878 - 'description' => $this->entityURLGenerator->listPartsURL($category), + 'description' => $description, ]; } @@ -132,11 +137,13 @@ class KiCadHelper * Returns an array of objects containing all parts for the given category in the format required by KiCAD. * The result is cached for performance and invalidated on category or part changes. * @param Category|null $category + * @param bool $minimal If true, only return id and name (faster for symbol chooser listing) * @return array */ - public function getCategoryParts(?Category $category): array + public function getCategoryParts(?Category $category, bool $minimal = false): array { - return $this->kicadCache->get('kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth, + $cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth . ($minimal ? '_min' : ''); + return $this->kicadCache->get($cacheKey, function (ItemInterface $item) use ($category) { $item->tag([ $this->tagGenerator->getElementTypeCacheTag(Category::class), @@ -182,7 +189,10 @@ class KiCadHelper }); } - public function getKiCADPart(Part $part): array + /** + * @param bool $apiV2 If true, use API v2 format with volatile field support + */ + public function getKiCADPart(Part $part, bool $apiV2 = false): array { $result = [ 'id' => (string)$part->getId(), @@ -250,32 +260,7 @@ class KiCadHelper $result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn()); } - // Add supplier information from orderdetails (include obsolete orderdetails) - if ($part->getOrderdetails(false)->count() > 0) { - $supplierCounts = []; - - foreach ($part->getOrderdetails(false) as $orderdetail) { - if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') { - $supplierName = $orderdetail->getSupplier()->getName(); - - $supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number - - if (!isset($supplierCounts[$supplierName])) { - $supplierCounts[$supplierName] = 0; - } - $supplierCounts[$supplierName]++; - - // Create field name with sequential number if more than one from same supplier (e.g. "Mouser", "Mouser 2", etc.) - $fieldName = $supplierCounts[$supplierName] > 1 - ? $supplierName . ' ' . $supplierCounts[$supplierName] - : $supplierName; - - $result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr()); - } - } - } - - //Add fields for KiCost: + //Add KiCost manufacturer fields (always present, independent of orderdetails) if ($part->getManufacturer() !== null) { $result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName()); } @@ -283,13 +268,43 @@ class KiCadHelper $result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber()); } - //For each supplier, add a field with the supplier name and the supplier part number for KiCost - if ($part->getOrderdetails(false)->count() > 0) { - foreach ($part->getOrderdetails(false) as $orderdetail) { + // Add supplier information from orderdetails (include obsolete orderdetails) + // If any orderdetail has kicad_export=true, only export those; otherwise export all (backward compat) + $allOrderdetails = $part->getOrderdetails(false); + if ($allOrderdetails->count() > 0) { + $hasKicadExportFlag = false; + foreach ($allOrderdetails as $od) { + if ($od->isKicadExport()) { + $hasKicadExportFlag = true; + break; + } + } + + $supplierCounts = []; + foreach ($allOrderdetails as $orderdetail) { if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') { - $fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#'; + // Skip orderdetails not marked for export when the flag is used + if ($hasKicadExportFlag && !$orderdetail->isKicadExport()) { + continue; + } + + $supplierName = $orderdetail->getSupplier()->getName() . ' SPN'; + + if (!isset($supplierCounts[$supplierName])) { + $supplierCounts[$supplierName] = 0; + } + $supplierCounts[$supplierName]++; + + // Create field name with sequential number if more than one from same supplier + $fieldName = $supplierCounts[$supplierName] > 1 + ? $supplierName . ' ' . $supplierCounts[$supplierName] + : $supplierName; $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()); } } } @@ -306,9 +321,10 @@ class KiCadHelper } } } - $result['fields']['Stock'] = $this->createField($totalStock); + // In API v2, stock and location are volatile (shown but not saved to schematic) + $result['fields']['Stock'] = $this->createField($totalStock, false, $apiV2); if ($locations !== []) { - $result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations))); + $result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)), false, $apiV2); } //Add parameters marked for KiCad export @@ -377,7 +393,7 @@ class KiCadHelper //If the user set a visibility, then use it if ($eda_info->getVisibility() !== null) { - return $part->getEdaInfo()->getVisibility(); + return $eda_info->getVisibility(); } //If the part has a category, then use the category visibility if possible @@ -419,14 +435,21 @@ class KiCadHelper * Creates a field array for KiCAD * @param string|int|float $value * @param bool $visible + * @param bool $volatile If true (API v2), field is shown in KiCad but NOT saved to schematic * @return array */ - private function createField(string|int|float $value, bool $visible = false): array + private function createField(string|int|float $value, bool $visible = false, bool $volatile = false): array { - return [ + $field = [ 'value' => (string)$value, 'visible' => $this->boolToKicadBool($visible), ]; + + if ($volatile) { + $field['volatile'] = $this->boolToKicadBool(true); + } + + return $field; } /** diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php index abf72d74..e6518687 100644 --- a/src/Services/ImportExportSystem/BOMImporter.php +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -396,10 +396,14 @@ class BOMImporter } } - // Create unique key for this entry (name + part ID) - $entry_key = $name . '|' . ($part ? $part->getID() : 'null'); + // Create unique key for this entry. + // When linked to a Part-DB part, use the part ID as key (merges footprint variants). + // Otherwise, use name (which includes package) to avoid merging unrelated components. + $entry_key = $part !== null + ? 'part:' . $part->getID() + : 'name:' . $name; - // Check if we already have an entry with the same name and part + // Check if we already have an entry with the same key if (isset($entries_by_key[$entry_key])) { // Merge with existing entry $existing_entry = $entries_by_key[$entry_key]; @@ -413,14 +417,22 @@ class BOMImporter $existing_quantity = $existing_entry->getQuantity(); $existing_entry->setQuantity($existing_quantity + $quantity); + // Track footprint variants in comment when merging entries with different packages + $currentPackage = trim($mapped_entry['Package'] ?? ''); + if ($currentPackage !== '' && !str_contains($existing_entry->getComment(), $currentPackage)) { + $comment = $existing_entry->getComment(); + $existing_entry->setComment($comment . ', Footprint variant: ' . $currentPackage); + } + $this->logger->info('Merged duplicate BOM entry', [ 'name' => $name, - 'part_id' => $part ? $part->getID() : null, + 'part_id' => $part?->getID(), 'original_quantity' => $existing_quantity, 'added_quantity' => $quantity, 'new_quantity' => $existing_quantity + $quantity, 'original_mountnames' => $existing_mountnames, 'added_mountnames' => $designator, + 'package' => $currentPackage, ]); continue; // Skip creating new entry diff --git a/templates/parts/edit/edit_form_styles.html.twig b/templates/parts/edit/edit_form_styles.html.twig index 5376f754..6564bc55 100644 --- a/templates/parts/edit/edit_form_styles.html.twig +++ b/templates/parts/edit/edit_form_styles.html.twig @@ -33,6 +33,7 @@ {{ form_row(form.supplier_product_url, {'attr': {'class': 'form-control-sm'}}) }} {{ form_widget(form.obsolete) }} {{ form_widget(form.pricesIncludesVAT) }} + {{ form_widget(form.kicad_export) }}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index ff3a2523..9c811a21 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -2930,6 +2930,42 @@ If you have done this incorrectly or if a computer is no longer trusted, you can Attachments + + + part.table.eda_status + EDA + + + + + eda.status.symbol_set + KiCad symbol set + + + + + eda.status.footprint_set + KiCad footprint set + + + + + eda.status.reference_set + Reference prefix set + + + + + eda.status.complete + EDA fields complete (symbol, footprint, reference) + + + + + eda.status.partial + EDA fields partially set + + flash.login_successful @@ -3272,6 +3308,12 @@ If you have done this incorrectly or if a computer is no longer trusted, you can No longer available + + + orderdetails.edit.kicad_export + Export to KiCad + + orderdetails.edit.supplierpartnr.placeholder