From 052a48a878a17e070c6e9071d74c1683bc6114e4 Mon Sep 17 00:00:00 2001 From: samy kamkar Date: Sat, 3 Oct 2026 14:12:59 -0700 Subject: [PATCH 1/2] add supplier and supplier part number columns to the parts table The suppliers of a part and their part numbers were only visible on the part's info page, so a parts list could neither show nor be sorted by where something was bought. Both columns are hidden by default and can be enabled in the column chooser or via the default columns setting (supplier, supplier_part_number). They are sorted in the filter query by the first supplier name / part number of the part, using the same aggregated join as the storage location column. Parts without a supplier (or with only empty part numbers) are kept at the end in both directions, as the DBMS disagree on where NULLs are sorted. The suppliers are fetch-joined in the detail query, so rendering the columns does not trigger a query per supplier. --- docs/configuration.md | 2 +- .../Helpers/PartDataTableHelper.php | 56 +++++++++++++++++++ src/DataTables/PartsDataTable.php | 17 ++++++ .../BehaviorSettings/PartTableColumns.php | 2 + translations/messages.en.xlf | 12 ++++ 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 1ef30c11d..f509fca2a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -181,7 +181,7 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept * `TABLE_PARTS_DEFAULT_COLUMNS`: The columns in parts tables, which are visible by default (when loading table for first time). Also specify the default order of the columns. This is a comma separated list of column names. Available columns - are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `minamount`, `partUnit`, `partCustomState`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`. + are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `supplier`, `supplier_part_number`, `minamount`, `partUnit`, `partCustomState`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`. ### History/Eventlog-related settings diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php index c10979cc6..a4f16a653 100644 --- a/src/DataTables/Helpers/PartDataTableHelper.php +++ b/src/DataTables/Helpers/PartDataTableHelper.php @@ -24,6 +24,7 @@ namespace App\DataTables\Helpers; use App\Entity\Parts\StorageLocation; +use App\Entity\Parts\Supplier; use App\Entity\ProjectSystem\Project; use App\Entity\Attachments\Attachment; use App\Entity\Parts\Part; @@ -118,6 +119,61 @@ public function renderStorageLocations(Part $context): string return implode('
', $tmp); } + /** + * Renders the names of all suppliers of this part (taken from its orderdetails), linked to their part lists. + * Each supplier is only listed once, even if the part has multiple orderdetails for it. + */ + public function renderSuppliers(Part $context): string + { + $tmp = []; + foreach ($context->getOrderdetails() as $orderdetail) { + $supplier = $orderdetail->getSupplier(); + //Ignore orderdetails without supplier + if (!$supplier instanceof Supplier) { + continue; + } + $tmp[$supplier->getID()] = sprintf( + '%s', + $this->entityURLGenerator->listPartsURL($supplier), + htmlspecialchars($this->pathCache->getFullPath($supplier)), + htmlspecialchars($supplier->getName()) + ); + } + + return implode(', ', $tmp); + } + + /** + * Renders the supplier part numbers of all orderdetails of this part. A number is linked to the product page of + * the supplier, if one is known. + */ + public function renderSupplierPartNumbers(Part $context): string + { + $tmp = []; + foreach ($context->getOrderdetails() as $orderdetail) { + $number = $orderdetail->getSupplierPartNr(); + if ($number === '') { + continue; + } + + $title = $orderdetail->getSupplier()?->getName() ?? ''; + $url = $orderdetail->getSupplierProductUrl(); + //The URL can be entered freely by users, so only link to real web addresses + if (preg_match('#^https?://#i', $url) === 1) { + $tmp[$number] = sprintf( + '%s', + htmlspecialchars($url), + htmlspecialchars($title), + htmlspecialchars($number) + ); + } else { + $tmp[$number] ??= sprintf('%s', htmlspecialchars($title), htmlspecialchars($number)); + } + } + + return implode(', ', $tmp); + } + /** * Renders an EDA/KiCad completeness indicator for the given part. * Shows icons for symbol, footprint, and value status. diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index 66fda24d1..bfe683b9f 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -181,6 +181,21 @@ public function configure(DataTable $dataTable, array $options): void 'orderable' => false, 'data' => fn(Part $context) => $this->partDataTableHelper->renderSupplierAvailableAmount($context), ]) + ->add('supplier', HTMLColumn::class, [ + 'label' => $this->translator->trans('part.table.supplier'), + //A part can have many suppliers, so we sort by the first one (see storelocation above). The leading + //expression keeps the parts without any supplier at the end, regardless of the sort direction and + //of how the DBMS orders NULLs (the direction is only appended to the last expression). + 'orderField' => 'CASE WHEN MIN(_suppliers.name) IS NULL THEN 1 ELSE 0 END, NATSORT(MIN(_suppliers.name))', + 'data' => fn(Part $context) => $this->partDataTableHelper->renderSuppliers($context), + ]) + ->add('supplier_part_number', HTMLColumn::class, [ + 'label' => $this->translator->trans('part.table.supplier_part_number'), + //Same as for the supplier column. Empty supplier part numbers are treated like missing ones. + 'orderField' => 'CASE WHEN MIN(NULLIF(_orderdetails.supplierpartnr, \'\')) IS NULL THEN 1 ELSE 0 END, ' + . 'NATSORT(MIN(NULLIF(_orderdetails.supplierpartnr, \'\')))', + 'data' => fn(Part $context) => $this->partDataTableHelper->renderSupplierPartNumbers($context), + ]) ->add('minamount', TextColumn::class, [ 'label' => $this->translator->trans('part.table.minamount'), 'data' => fn(Part $context, $value): string => $this->amountFormatter->format( @@ -370,6 +385,7 @@ private function getDetailQuery(QueryBuilder $builder, array $filter_results): v ->addSelect('footprint_attachment') ->addSelect('partLots') ->addSelect('orderdetails') + ->addSelect('suppliers') ->addSelect('attachments') ->addSelect('storelocations') ->addSelect('projectBomEntries') @@ -388,6 +404,7 @@ private function getDetailQuery(QueryBuilder $builder, array $filter_results): v ->leftJoin('footprint.master_picture_attachment', 'footprint_attachment') ->leftJoin('part.manufacturer', 'manufacturer') ->leftJoin('part.orderdetails', 'orderdetails') + ->leftJoin('orderdetails.supplier', 'suppliers') ->leftJoin('part.attachments', 'attachments') ->leftJoin('part.partUnit', 'partUnit') ->leftJoin('part.partCustomState', 'partCustomState') diff --git a/src/Settings/BehaviorSettings/PartTableColumns.php b/src/Settings/BehaviorSettings/PartTableColumns.php index e2005ea12..41310cb3f 100644 --- a/src/Settings/BehaviorSettings/PartTableColumns.php +++ b/src/Settings/BehaviorSettings/PartTableColumns.php @@ -39,6 +39,8 @@ enum PartTableColumns : string implements TranslatableInterface case LOCATION = "storage_location"; case AMOUNT = "amount"; case SUPPLIER_AVAILABLE_AMOUNT = "supplier_available_amount"; + case SUPPLIER = "supplier"; + case SUPPLIER_PART_NUMBER = "supplier_part_number"; case MIN_AMOUNT = "minamount"; case PART_UNIT = "partUnit"; case ADDED_DATE = "addedDate"; diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index c0a90ff5c..035fc8d3c 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -15518,5 +15518,17 @@ Buerklin-API Authentication server: Unknown + + + part.table.supplier + Supplier + + + + + part.table.supplier_part_number + Supplier part number + + From 8aac6c9e70de1efcc3fa2e3599ffade28c2fd11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 10 Oct 2026 16:25:42 +0200 Subject: [PATCH 2/2] Fixed translation message IDs --- translations/messages.en.xlf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 083b56780..3eff7092f 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -15926,13 +15926,13 @@ Buerklin-API Authentication server: This info provider has to throttle its requests heavily or needs a lot of processing time, so requests can take a while. - + part.table.supplier Supplier - + part.table.supplier_part_number Supplier part number