Moved barcode to info provider logic from Controller to BarcodeScanResultHandler service

This commit is contained in:
Jan Böhmer 2026-02-22 12:37:59 +01:00
parent caa71bbdda
commit 8dd972f1ad
4 changed files with 171 additions and 95 deletions

View file

@ -41,6 +41,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Exceptions\InfoProviderNotActiveException;
use App\Form\LabelSystem\ScanDialogType; use App\Form\LabelSystem\ScanDialogType;
use App\Services\InfoProviderSystem\Providers\LCSCProvider; use App\Services\InfoProviderSystem\Providers\LCSCProvider;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler; use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler;
@ -71,11 +72,11 @@ use \App\Entity\Parts\StorageLocation;
class ScanController extends AbstractController class ScanController extends AbstractController
{ {
public function __construct( public function __construct(
protected BarcodeScanResultHandler $barcodeParser, protected BarcodeScanResultHandler $resultHandler,
protected BarcodeScanHelper $barcodeNormalizer, protected BarcodeScanHelper $barcodeNormalizer,
private readonly ProviderRegistry $providerRegistry, private readonly ProviderRegistry $providerRegistry,
private readonly PartInfoRetriever $infoRetriever, private readonly PartInfoRetriever $infoRetriever,
) {} ) {}
#[Route(path: '', name: 'scan_dialog')] #[Route(path: '', name: 'scan_dialog')]
public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response
@ -103,7 +104,7 @@ class ScanController extends AbstractController
// If not in info mode, mimic “normal scan” behavior: redirect if possible. // If not in info mode, mimic “normal scan” behavior: redirect if possible.
if (!$infoMode) { if (!$infoMode) {
try { try {
$url = $this->barcodeParser->getInfoURL($scan); $url = $this->resultHandler->getInfoURL($scan);
return $this->redirect($url); return $this->redirect($url);
} catch (EntityNotFoundException) { } catch (EntityNotFoundException) {
// Decoded OK, but no part is found. If its a vendor code, redirect to create. // Decoded OK, but no part is found. If its a vendor code, redirect to create.
@ -153,7 +154,7 @@ class ScanController extends AbstractController
source_type: BarcodeSourceType::INTERNAL source_type: BarcodeSourceType::INTERNAL
); );
return $this->redirect($this->barcodeParser->getInfoURL($scan_result)); return $this->redirect($this->resultHandler->getInfoURL($scan_result));
} catch (EntityNotFoundException) { } catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found'); $this->addFlash('success', 'scan.qr_not_found');
@ -168,86 +169,13 @@ class ScanController extends AbstractController
*/ */
private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?string private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?string
{ {
// LCSC try {
if ($scanResult instanceof LCSCBarcodeScanResult) { return $this->resultHandler->getCreationURL($scanResult);
$lcscCode = $scanResult->lcscCode; } catch (InfoProviderNotActiveException $e) {
if ($lcscCode !== null && $lcscCode !== '') { $this->addFlash('error', $e->getMessage());
return $this->generateUrl('info_providers_create_part', [ } catch (\Throwable) {
'providerKey' => 'lcsc', $this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.');
'providerId' => $lcscCode, // Dont break scanning UX if provider lookup fails
]);
}
}
// Mouser / Digi-Key (EIGP114)
if ($scanResult instanceof EIGP114BarcodeScanResult) {
$vendor = $scanResult->guessBarcodeVendor();
// Mouser: use supplierPartNumber -> search provider -> provider_id
if ($vendor === 'mouser'
&& $scanResult->supplierPartNumber !== null
&& $scanResult->supplierPartNumber !== ''
) {
try {
$mouserProvider = $this->providerRegistry->getProviderByKey('mouser');
if (!$mouserProvider->isActive()) {
$this->addFlash('warning', 'Mouser provider is disabled / not configured.');
return null;
}
// Search Mouser using the MPN
$dtos = $this->infoRetriever->searchByKeyword(
keyword: $scanResult->supplierPartNumber,
providers: [$mouserProvider]
);
// If there are results, provider_id is MouserPartNumber (per MouserProvider.php)
$best = $dtos[0] ?? null;
if ($best !== null && $best->provider_id !== '') {
return $this->generateUrl('info_providers_create_part', [
'providerKey' => 'mouser',
'providerId' => $best->provider_id,
]);
}
$this->addFlash('warning', 'No Mouser match found for this MPN.');
return null;
} catch (\InvalidArgumentException) {
// provider key not found in registry
$this->addFlash('warning', 'Mouser provider is not installed/enabled.');
return null;
} catch (\Throwable $e) {
// Dont break scanning UX if provider lookup fails
$this->addFlash('warning', 'Mouser lookup failed: ' . $e->getMessage());
return null;
}
}
// Digi-Key: can use customerPartNumber or supplierPartNumber directly
if ($vendor === 'digikey') {
try {
$provider = $this->providerRegistry->getProviderByKey('digikey');
if (!$provider->isActive()) {
$this->addFlash('warning', 'Digi-Key provider is disabled / not configured (API key missing).');
return null;
}
$id = $scanResult->customerPartNumber ?: $scanResult->supplierPartNumber;
if (is_string($id) && $id !== '') {
return $this->generateUrl('info_providers_create_part', [
'providerKey' => 'digikey',
'providerId' => $id,
]);
}
} catch (\InvalidArgumentException) {
$this->addFlash('warning', 'Digi-Key provider is not installed/enabled');
return null;
}
}
} }
return null; return null;
@ -338,7 +266,7 @@ class ScanController extends AbstractController
$targetFound = false; $targetFound = false;
try { try {
$redirectUrl = $this->barcodeParser->getInfoURL($scan); $redirectUrl = $this->resultHandler->getInfoURL($scan);
$targetFound = true; $targetFound = true;
} catch (EntityNotFoundException) { } catch (EntityNotFoundException) {
} }
@ -350,7 +278,7 @@ class ScanController extends AbstractController
$locations = []; $locations = [];
if ($targetFound) { if ($targetFound) {
$part = $this->barcodeParser->resolvePart($scan); $part = $this->resultHandler->resolvePart($scan);
if ($part instanceof Part) { if ($part instanceof Part) {
$partName = $part->getName(); $partName = $part->getName();

View file

@ -0,0 +1,48 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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 <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Exceptions;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
/**
* An exception denoting that a required info provider is not active. This can be used to display a user-friendly error message,
* when a user tries to use an info provider that is not active.
*/
class InfoProviderNotActiveException extends \RuntimeException
{
public function __construct(public readonly string $providerKey, public readonly string $friendlyName)
{
parent::__construct(sprintf('The info provider "%s" (%s) is not active.', $this->friendlyName, $this->providerKey));
}
/**
* Creates an instance of this exception from an info provider instance
* @param InfoProviderInterface $provider
* @return self
*/
public static function fromProvider(InfoProviderInterface $provider): self
{
return new self($provider->getProviderKey(), $provider->getProviderInfo()['name'] ?? '???');
}
}

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem; namespace App\Services\InfoProviderSystem;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Exceptions\InfoProviderNotActiveException;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
@ -49,6 +50,7 @@ final class PartInfoRetriever
* @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances * @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances
* @param string $keyword The keyword to search for * @param string $keyword The keyword to search for
* @return SearchResultDTO[] The search results * @return SearchResultDTO[] The search results
* @throws InfoProviderNotActiveException if any of the given providers is not active
*/ */
public function searchByKeyword(string $keyword, array $providers): array public function searchByKeyword(string $keyword, array $providers): array
{ {
@ -61,7 +63,7 @@ final class PartInfoRetriever
//Ensure that the provider is active //Ensure that the provider is active
if (!$provider->isActive()) { if (!$provider->isActive()) {
throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!"); throw InfoProviderNotActiveException::fromProvider($provider);
} }
if (!$provider instanceof InfoProviderInterface) { if (!$provider instanceof InfoProviderInterface) {
@ -97,6 +99,7 @@ final class PartInfoRetriever
* @param string $provider_key * @param string $provider_key
* @param string $part_id * @param string $part_id
* @return PartDetailDTO * @return PartDetailDTO
* @throws InfoProviderNotActiveException if the the given providers is not active
*/ */
public function getDetails(string $provider_key, string $part_id): PartDetailDTO public function getDetails(string $provider_key, string $part_id): PartDetailDTO
{ {
@ -104,7 +107,7 @@ final class PartInfoRetriever
//Ensure that the provider is active //Ensure that the provider is active
if (!$provider->isActive()) { if (!$provider->isActive()) {
throw new \RuntimeException("The provider with key $provider_key is not active!"); throw InfoProviderNotActiveException::fromProvider($provider);
} }
//Generate key and escape reserved characters from the provider id //Generate key and escape reserved characters from the provider id

View file

@ -46,7 +46,10 @@ use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot; use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation; use App\Entity\Parts\StorageLocation;
use App\Exceptions\InfoProviderNotActiveException;
use App\Repository\Parts\PartRepository; use App\Repository\Parts\PartRepository;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException; use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException; use InvalidArgumentException;
@ -59,7 +62,8 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
*/ */
final readonly class BarcodeScanResultHandler final readonly class BarcodeScanResultHandler
{ {
public function __construct(private UrlGeneratorInterface $urlGenerator, private EntityManagerInterface $em) public function __construct(private UrlGeneratorInterface $urlGenerator, private EntityManagerInterface $em, private PartInfoRetriever $infoRetriever,
private ProviderRegistry $providerRegistry)
{ {
} }
@ -96,11 +100,33 @@ final readonly class BarcodeScanResultHandler
throw new \LogicException("Resolved entity is of unknown type: ".get_class($entity)); 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. * 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. * Returns null if no matching entity could be found.
* @param BarcodeScanResultInterface $barcodeScan * @param BarcodeScanResultInterface $barcodeScan
* @return Part|PartLot|StorageLocation * @return Part|PartLot|StorageLocation|null
*/ */
public function resolveEntity(BarcodeScanResultInterface $barcodeScan): Part|PartLot|StorageLocation|null public function resolveEntity(BarcodeScanResultInterface $barcodeScan): Part|PartLot|StorageLocation|null
{ {
@ -113,7 +139,7 @@ final readonly class BarcodeScanResultHandler
} }
if ($barcodeScan instanceof GTINBarcodeScanResult) { if ($barcodeScan instanceof GTINBarcodeScanResult) {
return $this->resolvePartFromGTIN($barcodeScan); return $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]);
} }
if ($barcodeScan instanceof LCSCBarcodeScanResult) { if ($barcodeScan instanceof LCSCBarcodeScanResult) {
@ -210,11 +236,82 @@ final readonly class BarcodeScanResultHandler
return $this->em->getRepository(Part::class)->getPartByMPN($pm); return $this->em->getRepository(Part::class)->getPartByMPN($pm);
} }
private function resolvePartFromGTIN(GTINBarcodeScanResult $barcodeScan): ?Part
/**
* 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
{ {
return $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]); // LCSC
if ($scanResult instanceof LCSCBarcodeScanResult) {
return [
'providerKey' => 'lcsc',
'providerId' => $scanResult->lcscCode,
];
}
if ($scanResult instanceof EIGP114BarcodeScanResult) {
return $this->getCreationInfoForEIGP114($scanResult);
}
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;
} }
} }