";
+ return $notes;
+ }
+
+ private function priceToPurchaseInfo(?array $price, string $asin): PurchaseInfoDTO
+ {
+ $priceDtos = [];
+ if ($price !== null) {
+ $priceDtos[] = new PriceDTO(minimum_discount_amount: 1, price: (string) $price['value'], currency_iso_code: $price['currency'], includes_tax: true);
+ }
+
+
+ return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin));
+ }
+
+ public function getDetails(string $id): PartDetailDTO
+ {
+ //Check that the id is a valid ASIN (10 characters, letters and numbers)
+ if (!preg_match('/^[A-Z0-9]{10}$/', $id)) {
+ throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)");
+ }
+
+ //Use cached details if available and the settings allow it, to avoid unnecessary API requests, since the search results already contain most of the details
+ if(!$this->settings->alwaysGetDetails && ($cached = $this->getFromCache($id)) !== null) {
+ return $cached;
+ }
+
+ $response = $this->httpClient->request('GET', self::DETAIL_API_URL, [
+ 'query' => [
+ 'asin' => $id,
+ 'domain' => $this->settings->domain,
+ ],
+ 'headers' => [
+ 'API-KEY' => $this->settings->apiKey,
+ ],
+ ]);
+
+ $product = $response->toArray()['data']['amazonProduct'];
+
+
+ return new PartDetailDTO(
+ provider_key: $this->getProviderKey(),
+ provider_id: $product['asin'],
+ name: $product['title'],
+ description: '',
+ category: $this->categoriesToCategory($product['categories']),
+ manufacturer: $product['brand'] ?? null,
+ preview_image_url: $product['mainImageUrl'] ?? $product['imageUrls'][0] ?? null,
+ provider_url: $this->productPageFromASIN($product['asin']),
+ notes: $this->feauturesBulletsToNotes($product['featureBullets'] ?? []),
+ vendor_infos: [$this->priceToPurchaseInfo($product['price'], $product['asin'])]
+ );
+ }
+
+ public function getCapabilities(): array
+ {
+ return [
+ ProviderCapabilities::BASIC,
+ ProviderCapabilities::PICTURE,
+ ProviderCapabilities::PRICE,
+ ];
+ }
+}
diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php
index 3086b7d8..39de1e23 100644
--- a/src/Services/InfoProviderSystem/Providers/ConradProvider.php
+++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php
@@ -201,7 +201,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
public function productMediaToDatasheets(array $productMedia): array
{
$files = [];
- foreach ($productMedia['manuals'] as $manual) {
+ foreach ($productMedia['manuals'] ?? [] as $manual) {
//Filter out unwanted languages
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
continue;
diff --git a/assets/controllers/turbo/title_controller.js b/src/Services/LabelSystem/BarcodeScanner/AmazonBarcodeScanResult.php
similarity index 50%
rename from assets/controllers/turbo/title_controller.js
rename to src/Services/LabelSystem/BarcodeScanner/AmazonBarcodeScanResult.php
index 6bbebdf7..fb756043 100644
--- a/assets/controllers/turbo/title_controller.js
+++ b/src/Services/LabelSystem/BarcodeScanner/AmazonBarcodeScanResult.php
@@ -1,7 +1,8 @@
+.
*/
-import { Controller } from '@hotwired/stimulus';
+declare(strict_types=1);
-export default class extends Controller {
- connect() {
- //If we encounter an element with this, then change the title of our document according to data-title
- this.changeTitle(this.element.dataset.title);
+
+namespace App\Services\LabelSystem\BarcodeScanner;
+
+final readonly class AmazonBarcodeScanResult implements BarcodeScanResultInterface
+{
+ public function __construct(public string $asin) {
+ if (!self::isAmazonBarcode($asin)) {
+ throw new \InvalidArgumentException("The provided input '$asin' is not a valid Amazon barcode (ASIN)");
+ }
}
- changeTitle(title) {
- document.title = title;
+ public static function isAmazonBarcode(string $input): bool
+ {
+ //Amazon barcodes are 10 alphanumeric characters
+ return preg_match('/^[A-Z0-9]{10}$/i', $input) === 1;
}
-}
\ No newline at end of file
+
+ public function getDecodedForInfoMode(): array
+ {
+ return [
+ 'ASIN' => $this->asin,
+ ];
+ }
+}
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php
deleted file mode 100644
index 1a3c29c2..00000000
--- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php
+++ /dev/null
@@ -1,180 +0,0 @@
-.
- */
-
-declare(strict_types=1);
-
-/**
- * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
- *
- * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-namespace App\Services\LabelSystem\BarcodeScanner;
-
-use App\Entity\LabelSystem\LabelSupportedElement;
-use App\Entity\Parts\Manufacturer;
-use App\Entity\Parts\Part;
-use App\Entity\Parts\PartLot;
-use Doctrine\ORM\EntityManagerInterface;
-use Doctrine\ORM\EntityNotFoundException;
-use InvalidArgumentException;
-use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-
-/**
- * @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest
- */
-final class BarcodeRedirector
-{
- public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em)
- {
- }
-
- /**
- * Determines the URL to which the user should be redirected, when scanning a QR code.
- *
- * @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan
- * @return string the URL to which should be redirected
- *
- * @throws EntityNotFoundException
- */
- public function getRedirectURL(BarcodeScanResultInterface $barcodeScan): string
- {
- if($barcodeScan instanceof LocalBarcodeScanResult) {
- return $this->getURLLocalBarcode($barcodeScan);
- }
-
- if ($barcodeScan instanceof EIGP114BarcodeScanResult) {
- return $this->getURLVendorBarcode($barcodeScan);
- }
-
- if ($barcodeScan instanceof GTINBarcodeScanResult) {
- return $this->getURLGTINBarcode($barcodeScan);
- }
-
- throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan));
- }
-
- private function getURLLocalBarcode(LocalBarcodeScanResult $barcodeScan): string
- {
- switch ($barcodeScan->target_type) {
- case LabelSupportedElement::PART:
- return $this->urlGenerator->generate('app_part_show', ['id' => $barcodeScan->target_id]);
- case LabelSupportedElement::PART_LOT:
- //Try to determine the part to the given lot
- $lot = $this->em->find(PartLot::class, $barcodeScan->target_id);
- if (!$lot instanceof PartLot) {
- throw new EntityNotFoundException();
- }
-
- return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID(), 'highlightLot' => $lot->getID()]);
-
- case LabelSupportedElement::STORELOCATION:
- return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
-
- default:
- throw new InvalidArgumentException('Unknown $type: '.$barcodeScan->target_type->name);
- }
- }
-
- /**
- * Gets the URL to a part from a scan of a Vendor Barcode
- */
- private function getURLVendorBarcode(EIGP114BarcodeScanResult $barcodeScan): string
- {
- $part = $this->getPartFromVendor($barcodeScan);
- return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
- }
-
- private function getURLGTINBarcode(GTINBarcodeScanResult $barcodeScan): string
- {
- $part = $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]);
- if (!$part instanceof Part) {
- throw new EntityNotFoundException();
- }
-
- return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
- }
-
- /**
- * Gets a part from a scan of a Vendor Barcode by filtering for parts
- * with the same Info Provider Id or, if that fails, by looking for parts with a
- * matching manufacturer product number. Only returns the first matching part.
- */
- private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part
- {
- // first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via
- // the info provider system or if the part was bought from a different vendor than the data was retrieved
- // from.
- if($barcodeScan->digikeyPartNumber) {
- $qb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
- //Lower() to be case insensitive
- $qb->where($qb->expr()->like('LOWER(part.providerReference.provider_id)', 'LOWER(:vendor_id)'));
- $qb->setParameter('vendor_id', $barcodeScan->digikeyPartNumber);
- $results = $qb->getQuery()->getResult();
- if ($results) {
- return $results[0];
- }
- }
-
- if(!$barcodeScan->supplierPartNumber){
- throw new EntityNotFoundException();
- }
-
- //Fallback to the manufacturer part number. This may return false positives, since it is common for
- //multiple manufacturers to use the same part number for their version of a common product
- //We assume the user is able to realize when this returns the wrong part
- //If the barcode specifies the manufacturer we try to use that as well
- $mpnQb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
- $mpnQb->where($mpnQb->expr()->like('LOWER(part.manufacturer_product_number)', 'LOWER(:mpn)'));
- $mpnQb->setParameter('mpn', $barcodeScan->supplierPartNumber);
-
- if($barcodeScan->mouserManufacturer){
- $manufacturerQb = $this->em->getRepository(Manufacturer::class)->createQueryBuilder("manufacturer");
- $manufacturerQb->where($manufacturerQb->expr()->like("LOWER(manufacturer.name)", "LOWER(:manufacturer_name)"));
- $manufacturerQb->setParameter("manufacturer_name", $barcodeScan->mouserManufacturer);
- $manufacturers = $manufacturerQb->getQuery()->getResult();
-
- if($manufacturers) {
- $mpnQb->andWhere($mpnQb->expr()->eq("part.manufacturer", ":manufacturer"));
- $mpnQb->setParameter("manufacturer", $manufacturers);
- }
-
- }
-
- $results = $mpnQb->getQuery()->getResult();
- if($results){
- return $results[0];
- }
- throw new EntityNotFoundException();
- }
-}
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php
index 520c9f3b..0bee33a1 100644
--- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php
@@ -92,10 +92,19 @@ final class BarcodeScanHelper
if ($type === BarcodeSourceType::EIGP114) {
return $this->parseEIGP114Barcode($input);
}
+
if ($type === BarcodeSourceType::GTIN) {
return $this->parseGTINBarcode($input);
}
+ if ($type === BarcodeSourceType::LCSC) {
+ return $this->parseLCSCBarcode($input);
+ }
+
+ if ($type === BarcodeSourceType::AMAZON) {
+ return new AmazonBarcodeScanResult($input);
+ }
+
//Null means auto and we try the different formats
$result = $this->parseInternalBarcode($input);
@@ -125,6 +134,16 @@ final class BarcodeScanHelper
return $this->parseGTINBarcode($input);
}
+ // Try LCSC barcode
+ if (LCSCBarcodeScanResult::isLCSCBarcode($input)) {
+ return $this->parseLCSCBarcode($input);
+ }
+
+ //Try amazon barcode
+ if (AmazonBarcodeScanResult::isAmazonBarcode($input)) {
+ return new AmazonBarcodeScanResult($input);
+ }
+
throw new InvalidArgumentException('Unknown barcode');
}
@@ -138,6 +157,11 @@ final class BarcodeScanHelper
return EIGP114BarcodeScanResult::parseFormat06Code($input);
}
+ private function parseLCSCBarcode(string $input): LCSCBarcodeScanResult
+ {
+ return LCSCBarcodeScanResult::parse($input);
+ }
+
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
{
$lot_repo = $this->entityManager->getRepository(PartLot::class);
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php
new file mode 100644
index 00000000..e24c7077
--- /dev/null
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php
@@ -0,0 +1,327 @@
+.
+ */
+
+declare(strict_types=1);
+
+/**
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+namespace App\Services\LabelSystem\BarcodeScanner;
+
+use App\Entity\LabelSystem\LabelSupportedElement;
+use App\Entity\Parts\Manufacturer;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartLot;
+use App\Entity\Parts\StorageLocation;
+use App\Exceptions\InfoProviderNotActiveException;
+use App\Repository\Parts\PartRepository;
+use App\Services\InfoProviderSystem\PartInfoRetriever;
+use App\Services\InfoProviderSystem\ProviderRegistry;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\EntityNotFoundException;
+use InvalidArgumentException;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+
+/**
+ * This class handles the result of a barcode scan and determines further actions, like which URL the user should be redirected to.
+ *
+ * @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest
+ */
+final readonly class BarcodeScanResultHandler
+{
+ public function __construct(private UrlGeneratorInterface $urlGenerator, private EntityManagerInterface $em, private PartInfoRetriever $infoRetriever,
+ private ProviderRegistry $providerRegistry)
+ {
+ }
+
+ /**
+ * Determines the URL to which the user should be redirected, when scanning a QR code.
+ *
+ * @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan
+ * @return string|null the URL to which should be redirected, or null if no suitable URL could be determined for the given barcode scan result
+ */
+ public function getInfoURL(BarcodeScanResultInterface $barcodeScan): ?string
+ {
+ //For other barcodes try to resolve the part first and then redirect to the part page
+ $entity = $this->resolveEntity($barcodeScan);
+
+ if ($entity === null) {
+ return null;
+ }
+
+ if ($entity instanceof Part) {
+ return $this->urlGenerator->generate('app_part_show', ['id' => $entity->getID()]);
+ }
+
+ if ($entity instanceof PartLot) {
+ return $this->urlGenerator->generate('app_part_show', ['id' => $entity->getPart()->getID(), 'highlightLot' => $entity->getID()]);
+ }
+
+ if ($entity instanceof StorageLocation) {
+ return $this->urlGenerator->generate('part_list_store_location', ['id' => $entity->getID()]);
+ }
+
+ //@phpstan-ignore-next-line This should never happen, since resolveEntity should only return Part, PartLot or StorageLocation
+ throw new \LogicException("Resolved entity is of unknown type: ".get_class($entity));
+ }
+
+ /**
+ * Returns a URL to create a new part based on this barcode scan result, if possible.
+ * @param BarcodeScanResultInterface $scanResult
+ * @return string|null
+ * @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system
+ */
+ public function getCreationURL(BarcodeScanResultInterface $scanResult): ?string
+ {
+ $infos = $this->getCreateInfos($scanResult);
+ if ($infos === null) {
+ return null;
+ }
+
+ //Ensure that the provider is active, otherwise we should not generate a creation URL for it
+ $provider = $this->providerRegistry->getProviderByKey($infos['providerKey']);
+ if (!$provider->isActive()) {
+ throw InfoProviderNotActiveException::fromProvider($provider);
+ }
+
+ return $this->urlGenerator->generate('info_providers_create_part', ['providerKey' => $infos['providerKey'], 'providerId' => $infos['providerId']]);
+ }
+
+ /**
+ * Tries to resolve the given barcode scan result to a local entity. This can be a Part, a PartLot or a StorageLocation, depending on the type of the barcode and the information contained in it.
+ * Returns null if no matching entity could be found.
+ * @param BarcodeScanResultInterface $barcodeScan
+ * @return Part|PartLot|StorageLocation|null
+ */
+ public function resolveEntity(BarcodeScanResultInterface $barcodeScan): Part|PartLot|StorageLocation|null
+ {
+ if ($barcodeScan instanceof LocalBarcodeScanResult) {
+ return $this->resolvePartFromLocal($barcodeScan);
+ }
+
+ if ($barcodeScan instanceof EIGP114BarcodeScanResult) {
+ return $this->resolvePartFromVendor($barcodeScan);
+ }
+
+ if ($barcodeScan instanceof GTINBarcodeScanResult) {
+ return $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]);
+ }
+
+ if ($barcodeScan instanceof LCSCBarcodeScanResult) {
+ return $this->resolvePartFromLCSC($barcodeScan);
+ }
+
+ if ($barcodeScan instanceof AmazonBarcodeScanResult) {
+ return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin)
+ ?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
+ }
+
+ return null;
+ }
+
+ /**
+ * Tries to resolve a Part from the given barcode scan result. Returns null if no part could be found for the given barcode,
+ * or the barcode doesn't contain information allowing to resolve to a local part.
+ * @param BarcodeScanResultInterface $barcodeScan
+ * @return Part|null
+ * @throws \InvalidArgumentException if the barcode scan result type is unknown and cannot be handled this function
+ */
+ public function resolvePart(BarcodeScanResultInterface $barcodeScan): ?Part
+ {
+ $entity = $this->resolveEntity($barcodeScan);
+ if ($entity instanceof Part) {
+ return $entity;
+ }
+ if ($entity instanceof PartLot) {
+ return $entity->getPart();
+ }
+ //Storage locations are not associated with a specific part, so we cannot resolve a part for
+ //a storage location barcode
+ return null;
+ }
+
+ private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): Part|PartLot|StorageLocation|null
+ {
+ return match ($barcodeScan->target_type) {
+ LabelSupportedElement::PART => $this->em->find(Part::class, $barcodeScan->target_id),
+ LabelSupportedElement::PART_LOT => $this->em->find(PartLot::class, $barcodeScan->target_id),
+ LabelSupportedElement::STORELOCATION => $this->em->find(StorageLocation::class, $barcodeScan->target_id),
+ };
+ }
+
+ /**
+ * Gets a part from a scan of a Vendor Barcode by filtering for parts
+ * with the same Info Provider Id or, if that fails, by looking for parts with a
+ * matching manufacturer product number. Only returns the first matching part.
+ */
+ private function resolvePartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : ?Part
+ {
+ // first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via
+ // the info provider system or if the part was bought from a different vendor than the data was retrieved
+ // from.
+ if($barcodeScan->digikeyPartNumber) {
+
+ $part = $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->digikeyPartNumber);
+ if ($part !== null) {
+ return $part;
+ }
+ }
+
+ if (!$barcodeScan->supplierPartNumber){
+ return null;
+ }
+
+ //Fallback to the manufacturer part number. This may return false positives, since it is common for
+ //multiple manufacturers to use the same part number for their version of a common product
+ //We assume the user is able to realize when this returns the wrong part
+ //If the barcode specifies the manufacturer we try to use that as well
+
+ return $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->supplierPartNumber, $barcodeScan->mouserManufacturer);
+ }
+
+ /**
+ * Resolve LCSC barcode -> Part.
+ * Strategy:
+ * 1) Try providerReference.provider_id == pc (LCSC "Cxxxxxx") if you store it there
+ * 2) Fallback to manufacturer_product_number == pm (MPN)
+ * Returns first match (consistent with EIGP114 logic)
+ */
+ private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part
+ {
+ // Try LCSC code (pc) as provider id if available
+ $pc = $barcodeScan->lcscCode; // e.g. C138033
+ if ($pc) {
+ $part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pc);
+ if ($part !== null) {
+ return $part;
+ }
+ }
+
+ // Fallback to MPN (pm)
+ $pm = $barcodeScan->mpn; // e.g. RC0402FR-071ML
+ if (!$pm) {
+ return null;
+ }
+
+ return $this->em->getRepository(Part::class)->getPartByMPN($pm);
+ }
+
+
+ /**
+ * Tries to extract creation information for a part from the given barcode scan result. This can be used to
+ * automatically fill in the info provider reference of a part, when creating a new part based on the scan result.
+ * Returns null if no provider information could be extracted from the scan result, or if the scan result type is unknown and cannot be handled by this function.
+ * It is not necessarily checked that the provider is active, or that the result actually exists on the provider side.
+ * @param BarcodeScanResultInterface $scanResult
+ * @return array{providerKey: string, providerId: string}|null
+ * @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system
+ */
+ public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array
+ {
+ // LCSC
+ if ($scanResult instanceof LCSCBarcodeScanResult) {
+ return [
+ 'providerKey' => 'lcsc',
+ 'providerId' => $scanResult->lcscCode,
+ ];
+ }
+
+ if ($scanResult instanceof EIGP114BarcodeScanResult) {
+ return $this->getCreationInfoForEIGP114($scanResult);
+ }
+
+ if ($scanResult instanceof AmazonBarcodeScanResult) {
+ return [
+ 'providerKey' => 'canopy',
+ 'providerId' => $scanResult->asin,
+ ];
+ }
+
+ return null;
+
+ }
+
+ /**
+ * @param EIGP114BarcodeScanResult $scanResult
+ * @return array{providerKey: string, providerId: string}|null
+ */
+ private function getCreationInfoForEIGP114(EIGP114BarcodeScanResult $scanResult): ?array
+ {
+ $vendor = $scanResult->guessBarcodeVendor();
+
+ // Mouser: use supplierPartNumber -> search provider -> provider_id
+ if ($vendor === 'mouser' && $scanResult->supplierPartNumber !== null
+ ) {
+ // Search Mouser using the MPN
+ $dtos = $this->infoRetriever->searchByKeyword(
+ keyword: $scanResult->supplierPartNumber,
+ providers: ["mouser"]
+ );
+
+ // If there are results, provider_id is MouserPartNumber (per MouserProvider.php)
+ $best = $dtos[0] ?? null;
+
+ if ($best !== null) {
+ return [
+ 'providerKey' => 'mouser',
+ 'providerId' => $best->provider_id,
+ ];
+ }
+
+ return null;
+ }
+
+ // Digi-Key: can use customerPartNumber or supplierPartNumber directly
+ if ($vendor === 'digikey') {
+ return [
+ 'providerKey' => 'digikey',
+ 'providerId' => $scanResult->customerPartNumber ?? $scanResult->supplierPartNumber,
+ ];
+ }
+
+ // Element14: can use supplierPartNumber directly
+ if ($vendor === 'element14') {
+ return [
+ 'providerKey' => 'element14',
+ 'providerId' => $scanResult->supplierPartNumber,
+ ];
+ }
+
+ return null;
+ }
+
+
+}
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php
index 88130351..befa91b6 100644
--- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php
@@ -33,4 +33,4 @@ interface BarcodeScanResultInterface
* @return array
*/
public function getDecodedForInfoMode(): array;
-}
\ No newline at end of file
+}
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php
index 43643d12..fb6eaa77 100644
--- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php
@@ -26,25 +26,30 @@ namespace App\Services\LabelSystem\BarcodeScanner;
/**
* This enum represents the different types, where a barcode/QR-code can be generated from
*/
-enum BarcodeSourceType
+enum BarcodeSourceType: string
{
/** This Barcode was generated using Part-DB internal recommended barcode generator */
- case INTERNAL;
+ case INTERNAL = 'internal';
/** This barcode is containing an internal part number (IPN) */
- case IPN;
+ case IPN = 'ipn';
/**
* This barcode is a user defined barcode defined on a part lot
*/
- case USER_DEFINED;
+ case USER_DEFINED = 'user';
/**
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
*/
- case EIGP114;
+ case EIGP114 = 'eigp';
/**
* GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part.
*/
- case GTIN;
+ case GTIN = 'gtin';
+
+ /** For LCSC.com formatted QR codes */
+ case LCSC = 'lcsc';
+
+ case AMAZON = 'amazon';
}
diff --git a/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php
index 0b4f4b56..37c03f55 100644
--- a/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php
+++ b/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php
@@ -28,40 +28,40 @@ namespace App\Services\LabelSystem\BarcodeScanner;
* Based on PR 811, EIGP 114.2018 (https://www.ecianow.org/assets/docs/GIPC/EIGP-114.2018%20ECIA%20Labeling%20Specification%20for%20Product%20and%20Shipment%20Identification%20in%20the%20Electronics%20Industry%20-%202D%20Barcode.pdf),
* , https://forum.digikey.com/t/digikey-product-labels-decoding-digikey-barcodes/41097
*/
-class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
+readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
{
/**
* @var string|null Ship date in format YYYYMMDD
*/
- public readonly ?string $shipDate;
+ public ?string $shipDate;
/**
* @var string|null Customer assigned part number – Optional based on
* agreements between Distributor and Supplier
*/
- public readonly ?string $customerPartNumber;
+ public ?string $customerPartNumber;
/**
* @var string|null Supplier assigned part number
*/
- public readonly ?string $supplierPartNumber;
+ public ?string $supplierPartNumber;
/**
* @var int|null Quantity of product
*/
- public readonly ?int $quantity;
+ public ?int $quantity;
/**
* @var string|null Customer assigned purchase order number
*/
- public readonly ?string $customerPO;
+ public ?string $customerPO;
/**
* @var string|null Line item number from PO. Required on Logistic Label when
* used on back of Packing Slip. See Section 4.9
*/
- public readonly ?string $customerPOLine;
+ public ?string $customerPOLine;
/**
* 9D - YYWW (Year and Week of Manufacture). ) If no date code is used
@@ -69,7 +69,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
* to indicate the product is Not Traceable by this data field.
* @var string|null
*/
- public readonly ?string $dateCode;
+ public ?string $dateCode;
/**
* 10D - YYWW (Year and Week of Manufacture). ) If no date code is used
@@ -77,7 +77,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
* to indicate the product is Not Traceable by this data field.
* @var string|null
*/
- public readonly ?string $alternativeDateCode;
+ public ?string $alternativeDateCode;
/**
* Traceability number assigned to a batch or group of items. If
@@ -86,14 +86,14 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
* by this data field.
* @var string|null
*/
- public readonly ?string $lotCode;
+ public ?string $lotCode;
/**
* Country where part was manufactured. Two-letter code from
* ISO 3166 country code list
* @var string|null
*/
- public readonly ?string $countryOfOrigin;
+ public ?string $countryOfOrigin;
/**
* @var string|null Unique alphanumeric number assigned by supplier
@@ -101,85 +101,85 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
* Carton. Always used in conjunction with a mixed logistic label
* with a 5S data identifier for Package ID.
*/
- public readonly ?string $packageId1;
+ public ?string $packageId1;
/**
* @var string|null
* 4S - Package ID for Logistic Carton with like items
*/
- public readonly ?string $packageId2;
+ public ?string $packageId2;
/**
* @var string|null
* 5S - Package ID for Logistic Carton with mixed items
*/
- public readonly ?string $packageId3;
+ public ?string $packageId3;
/**
* @var string|null Unique alphanumeric number assigned by supplier.
*/
- public readonly ?string $packingListNumber;
+ public ?string $packingListNumber;
/**
* @var string|null Ship date in format YYYYMMDD
*/
- public readonly ?string $serialNumber;
+ public ?string $serialNumber;
/**
* @var string|null Code for sorting and classifying LEDs. Use when applicable
*/
- public readonly ?string $binCode;
+ public ?string $binCode;
/**
* @var int|null Sequential carton count in format “#/#” or “# of #”
*/
- public readonly ?int $packageCount;
+ public ?int $packageCount;
/**
* @var string|null Alphanumeric string assigned by the supplier to distinguish
* from one closely-related design variation to another. Use as
* required or when applicable
*/
- public readonly ?string $revisionNumber;
+ public ?string $revisionNumber;
/**
* @var string|null Digikey Extension: This is not represented in the ECIA spec, but the field being used is found in the ANSI MH10.8.2-2016 spec on which the ECIA spec is based. In the ANSI spec it is called First Level (Supplier Assigned) Part Number.
*/
- public readonly ?string $digikeyPartNumber;
+ public ?string $digikeyPartNumber;
/**
* @var string|null Digikey Extension: This can be shared across multiple invoices and time periods and is generated as an order enters our system from any vector (web, API, phone order, etc.)
*/
- public readonly ?string $digikeySalesOrderNumber;
+ public ?string $digikeySalesOrderNumber;
/**
* @var string|null Digikey extension: This is typically assigned per shipment as items are being released to be picked in the warehouse. A SO can have many Invoice numbers
*/
- public readonly ?string $digikeyInvoiceNumber;
+ public ?string $digikeyInvoiceNumber;
/**
* @var string|null Digikey extension: This is for internal DigiKey purposes and defines the label type.
*/
- public readonly ?string $digikeyLabelType;
+ public ?string $digikeyLabelType;
/**
* @var string|null You will also see this as the last part of a URL for a product detail page. Ex https://www.digikey.com/en/products/detail/w%C3%BCrth-elektronik/860010672008/5726907
*/
- public readonly ?string $digikeyPartID;
+ public ?string $digikeyPartID;
/**
* @var string|null Digikey Extension: For internal use of Digikey. Probably not needed
*/
- public readonly ?string $digikeyNA;
+ public ?string $digikeyNA;
/**
* @var string|null Digikey Extension: This is a field of varying length used to keep the barcode approximately the same size between labels. It is safe to ignore.
*/
- public readonly ?string $digikeyPadding;
+ public ?string $digikeyPadding;
- public readonly ?string $mouserPositionInOrder;
+ public ?string $mouserPositionInOrder;
- public readonly ?string $mouserManufacturer;
+ public ?string $mouserManufacturer;
@@ -187,7 +187,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
*
* @param array $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content
*/
- public function __construct(public readonly array $data)
+ public function __construct(public array $data)
{
//IDs per EIGP 114.2018
$this->shipDate = $data['6D'] ?? null;
@@ -329,4 +329,4 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
return $tmp;
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php
new file mode 100644
index 00000000..0151cffa
--- /dev/null
+++ b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php
@@ -0,0 +1,157 @@
+ $fields
+ */
+ public function __construct(
+ public array $fields,
+ public string $rawInput,
+ ) {
+
+ $this->pickBatchNumber = $this->fields['pbn'] ?? null;
+ $this->orderNumber = $this->fields['on'] ?? null;
+ $this->lcscCode = $this->fields['pc'] ?? null;
+ $this->mpn = $this->fields['pm'] ?? null;
+ $this->quantity = isset($this->fields['qty']) ? (int)$this->fields['qty'] : null;
+ $this->countryChannel = $this->fields['cc'] ?? null;
+ $this->warehouseCode = $this->fields['wc'] ?? null;
+ $this->pdi = $this->fields['pdi'] ?? null;
+ $this->hp = $this->fields['hp'] ?? null;
+
+ }
+
+ public function getSourceType(): BarcodeSourceType
+ {
+ return BarcodeSourceType::LCSC;
+ }
+
+ /**
+ * @return array|float[]|int[]|null[]|string[] An array of fields decoded from the barcode
+ */
+ public function getDecodedForInfoMode(): array
+ {
+ // Keep it human-friendly
+ return [
+ 'Barcode type' => 'LCSC',
+ 'MPN (pm)' => $this->mpn ?? '',
+ 'LCSC code (pc)' => $this->lcscCode ?? '',
+ 'Qty' => $this->quantity !== null ? (string) $this->quantity : '',
+ 'Order No (on)' => $this->orderNumber ?? '',
+ 'Pick Batch (pbn)' => $this->pickBatchNumber ?? '',
+ 'Warehouse (wc)' => $this->warehouseCode ?? '',
+ 'Country/Channel (cc)' => $this->countryChannel ?? '',
+ 'PDI (unknown meaning)' => $this->pdi ?? '',
+ 'HP (unknown meaning)' => $this->hp ?? '',
+ ];
+ }
+
+ /**
+ * Parses the barcode data to see if the input matches the expected format used by lcsc.com
+ * @param string $input
+ * @return bool
+ */
+ public static function isLCSCBarcode(string $input): bool
+ {
+ $s = trim($input);
+
+ // Your example: {pbn:...,on:...,pc:...,pm:...,qty:...}
+ if (!str_starts_with($s, '{') || !str_ends_with($s, '}')) {
+ return false;
+ }
+
+ // Must contain at least pm: and pc: (common for LCSC labels)
+ return (stripos($s, 'pm:') !== false) && (stripos($s, 'pc:') !== false);
+ }
+
+ /**
+ * Parse the barcode input string into the fields used by lcsc.com
+ * @param string $input
+ * @return self
+ */
+ public static function parse(string $input): self
+ {
+ $raw = trim($input);
+
+ if (!self::isLCSCBarcode($raw)) {
+ throw new InvalidArgumentException('Not an LCSC barcode');
+ }
+
+ $inner = substr($raw, 1, -1); // remove { }
+
+ $fields = [];
+
+ // This format is comma-separated pairs, values do not contain commas in your sample.
+ $pairs = array_filter(
+ array_map(trim(...), explode(',', $inner)),
+ static fn(string $s): bool => $s !== ''
+ );
+
+ foreach ($pairs as $pair) {
+ $pos = strpos($pair, ':');
+ if ($pos === false) {
+ continue;
+ }
+
+ $k = trim(substr($pair, 0, $pos));
+ $v = trim(substr($pair, $pos + 1));
+
+ if ($k === '') {
+ continue;
+ }
+
+ $fields[$k] = $v;
+ }
+
+ if (!isset($fields['pm']) || trim($fields['pm']) === '') {
+ throw new InvalidArgumentException('LCSC barcode missing pm field');
+ }
+
+ return new self($fields, $raw);
+ }
+}
diff --git a/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php
index 050aff6f..25fb4710 100644
--- a/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php
+++ b/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php
@@ -29,12 +29,12 @@ use App\Entity\LabelSystem\LabelSupportedElement;
* This class represents the result of a barcode scan of a barcode that uniquely identifies a local entity,
* like an internally generated barcode or a barcode that was added manually to the system by a user
*/
-class LocalBarcodeScanResult implements BarcodeScanResultInterface
+readonly class LocalBarcodeScanResult implements BarcodeScanResultInterface
{
public function __construct(
- public readonly LabelSupportedElement $target_type,
- public readonly int $target_id,
- public readonly BarcodeSourceType $source_type,
+ public LabelSupportedElement $target_type,
+ public int $target_id,
+ public BarcodeSourceType $source_type,
) {
}
@@ -46,4 +46,4 @@ class LocalBarcodeScanResult implements BarcodeScanResultInterface
'Target ID' => $this->target_id,
];
}
-}
\ No newline at end of file
+}
diff --git a/src/Settings/InfoProviderSystem/CanopySettings.php b/src/Settings/InfoProviderSystem/CanopySettings.php
new file mode 100644
index 00000000..0858871b
--- /dev/null
+++ b/src/Settings/InfoProviderSystem/CanopySettings.php
@@ -0,0 +1,96 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Settings\InfoProviderSystem;
+
+use App\Form\Type\APIKeyType;
+use App\Settings\SettingsIcon;
+use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
+use Jbtronics\SettingsBundle\Settings\Settings;
+use Jbtronics\SettingsBundle\Settings\SettingsParameter;
+use Jbtronics\SettingsBundle\Settings\SettingsTrait;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Form\Extension\Core\Type\CountryType;
+use Symfony\Component\Translation\TranslatableMessage as TM;
+use Symfony\Component\Validator\Constraints as Assert;
+
+#[Settings(label: new TM("settings.ips.canopy"))]
+#[SettingsIcon("fa-plug")]
+class CanopySettings
+{
+ public const ALLOWED_DOMAINS = [
+ "amazon.de" => "DE",
+ "amazon.com" => "US",
+ "amazon.co.uk" => "UK",
+ "amazon.fr" => "FR",
+ "amazon.it" => "IT",
+ "amazon.es" => "ES",
+ "amazon.ca" => "CA",
+ "amazon.com.au" => "AU",
+ "amazon.com.br" => "BR",
+ "amazon.com.mx" => "MX",
+ "amazon.in" => "IN",
+ "amazon.co.jp" => "JP",
+ "amazon.nl" => "NL",
+ "amazon.pl" => "PL",
+ "amazon.sa" => "SA",
+ "amazon.sg" => "SG",
+ "amazon.se" => "SE",
+ "amazon.com.tr" => "TR",
+ "amazon.ae" => "AE",
+ "amazon.com.be" => "BE",
+ "amazon.com.cn" => "CN",
+ ];
+
+ use SettingsTrait;
+
+ #[SettingsParameter(label: new TM("settings.ips.mouser.apiKey"),
+ formType: APIKeyType::class,
+ formOptions: ["help_html" => true], envVar: "PROVIDER_CANOPY_API_KEY", envVarMode: EnvVarMode::OVERWRITE)]
+ public ?string $apiKey = null;
+
+ /**
+ * @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant
+ */
+ #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS])]
+ public string $domain = "DE";
+
+ /**
+ * @var bool If true, the provider will always retrieve details for a part, resulting in an additional API request
+ */
+ #[SettingsParameter(label: new TM("settings.ips.canopy.alwaysGetDetails"), description: new TM("settings.ips.canopy.alwaysGetDetails.help"))]
+ public bool $alwaysGetDetails = false;
+
+ /**
+ * Returns the real domain (e.g. amazon.de) based on the selected domain (e.g. DE)
+ * @return string
+ */
+ public function getRealDomain(): string
+ {
+ $domain = array_search($this->domain, self::ALLOWED_DOMAINS, true);
+ if ($domain === false) {
+ throw new \InvalidArgumentException("Invalid domain selected");
+ }
+ return $domain;
+ }
+}
diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php
index 3e78233f..248fcedc 100644
--- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php
+++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php
@@ -72,4 +72,7 @@ class InfoProviderSettings
#[EmbeddedSettings]
public ?ConradSettings $conrad = null;
+
+ #[EmbeddedSettings]
+ public ?CanopySettings $canopy = null;
}
diff --git a/src/Twig/AttachmentExtension.php b/src/Twig/AttachmentExtension.php
index 3d5ec611..23ab7d6e 100644
--- a/src/Twig/AttachmentExtension.php
+++ b/src/Twig/AttachmentExtension.php
@@ -23,7 +23,10 @@ declare(strict_types=1);
namespace App\Twig;
use App\Entity\Attachments\Attachment;
+use App\Entity\Attachments\AttachmentContainingDBElement;
+use App\Entity\Parts\Part;
use App\Services\Attachments\AttachmentURLGenerator;
+use App\Services\Attachments\PartPreviewGenerator;
use App\Services\Misc\FAIconGenerator;
use Twig\Attribute\AsTwigFunction;
use Twig\Extension\AbstractExtension;
@@ -31,7 +34,7 @@ use Twig\TwigFunction;
final readonly class AttachmentExtension
{
- public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator)
+ public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator, private PartPreviewGenerator $partPreviewGenerator)
{
}
@@ -44,6 +47,26 @@ final readonly class AttachmentExtension
return $this->attachmentURLGenerator->getThumbnailURL($attachment, $filter_name);
}
+ /**
+ * Returns the URL of the thumbnail of the given element. Returns null if no thumbnail is available.
+ * For parts, a special preview image is generated, for other entities, the master picture is used as preview (if available).
+ */
+ #[AsTwigFunction("entity_thumbnail")]
+ public function entityThumbnail(AttachmentContainingDBElement $element, string $filter_name = 'thumbnail_sm'): ?string
+ {
+ if ($element instanceof Part) {
+ $preview_attachment = $this->partPreviewGenerator->getTablePreviewAttachment($element);
+ } else { // For other entities, we just use the master picture as preview, if available
+ $preview_attachment = $element->getMasterPictureAttachment();
+ }
+
+ if ($preview_attachment === null) {
+ return null;
+ }
+
+ return $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, $filter_name);
+ }
+
/**
* Return the font-awesome icon type for the given file extension. Returns "file" if no specific icon is available.
* Null is allowed for files withot extension
diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig
index d327a4f6..57331370 100644
--- a/templates/_navbar.html.twig
+++ b/templates/_navbar.html.twig
@@ -60,6 +60,15 @@
{% endif %}
+
+ {% if is_granted("@tools.label_scanner") %}
+
- {# This menu is filled by 'turbo/locale_menu' controller from the _turbo_control.html.twig template, to always have the correct path #}
+ {# This menu is filled by a turbo-stream in _turbo_contro.html.twig #}
- {% for label, messages in app.flashes() %}
- {% for message in messages %}
- {{ include('_toast.html.twig', {
- 'label': label,
- 'message': message
- }) }}
- {% endfor %}
- {% endfor %}
-
+{% block flashes %}
+ {# Insert flashes #}
+
+
+
+ {% for label, messages in app.flashes() %}
+ {% for message in messages %}
+ {{ include('_toast.html.twig', {
+ 'label': label,
+ 'message': message
+ }) }}
+ {% endfor %}
+ {% endfor %}
+
+
+
+{% endblock %}
+
-{# Allow pages to request a fully reload of everything #}
-{% if global_reload_needed is defined and global_reload_needed %}
-
-{% endif %}
{# Insert info about when the sidebar trees were updated last time, so the sidebar_tree_controller can decide if it needs to reload the tree #}
-{# The title block is already escaped, therefore we dont require any additional escaping here #}
-
+
+
+ {% set locales = settings_instance('localization').languageMenuEntries %}
+ {% if locales is empty %}
+ {% set locales = locale_menu %}
+ {% endif %}
+
+ {% for locale in locales %}
+
+ {{ locale|language_name }} ({{ locale|upper }})
+ {% endfor %}
+
+
-
- {% set locales = settings_instance('localization').languageMenuEntries %}
- {% if locales is empty %}
- {% set locales = locale_menu %}
- {% endif %}
- {% for locale in locales %}
-
- {{ locale|language_name }} ({{ locale|upper }})
- {% endfor %}
-
diff --git a/templates/base.html.twig b/templates/base.html.twig
index 2db726ee..8dc87239 100644
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -26,6 +26,11 @@
+ {# Allow pages to request a fully reload of everything #}
+ {% if global_reload_needed is defined and global_reload_needed %}
+
+ {% endif %}
+
diff --git a/templates/label_system/scanner/_info_mode.html.twig b/templates/label_system/scanner/_info_mode.html.twig
new file mode 100644
index 00000000..23deb6d3
--- /dev/null
+++ b/templates/label_system/scanner/_info_mode.html.twig
@@ -0,0 +1,154 @@
+{% import "helper.twig" as helper %}
+
+{% if decoded is not empty %}
+
+
+ {% if part %} {# Show detailed info when it is a part #}
+
+
+ {% trans %}label_scanner.db_part_found{% endtrans %}
+ {% if openUrl %}
+