diff --git a/assets/controllers/common/toast_controller.js b/assets/controllers/common/toast_controller.js index 36b7f3cc..196692fb 100644 --- a/assets/controllers/common/toast_controller.js +++ b/assets/controllers/common/toast_controller.js @@ -20,6 +20,10 @@ import { Controller } from '@hotwired/stimulus'; import { Toast } from 'bootstrap'; +/** + * The purpose of this controller, is to show all containers. + * They should already be added via turbo-streams, but have to be called for to show them. + */ export default class extends Controller { connect() { //Move all toasts from the page into our toast container and show them @@ -33,4 +37,4 @@ export default class extends Controller { const toast = new Toast(this.element); toast.show(); } -} \ No newline at end of file +} diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index 200dd2a7..ae51e951 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -21,17 +21,31 @@ import {Controller} from "@hotwired/stimulus"; //import * as ZXing from "@zxing/library"; import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode"; +import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller"; /* stimulusFetch: 'lazy' */ + export default class extends Controller { - - //codeReader = null; - _scanner = null; - + _submitting = false; + _lastDecodedText = ""; + _onInfoChange = null; connect() { - console.log('Init Scanner'); + + // Prevent double init if connect fires twice + if (this._scanner) return; + + // clear last decoded barcode when state changes on info box + const info = document.getElementById("scan_dialog_info_mode"); + if (info) { + this._onInfoChange = () => { + this._lastDecodedText = ""; + }; + info.addEventListener("change", this._onInfoChange); + } + + const isMobile = window.matchMedia("(max-width: 768px)").matches; //This function ensures, that the qrbox is 70% of the total viewport let qrboxFunction = function(viewfinderWidth, viewfinderHeight) { @@ -45,29 +59,61 @@ export default class extends Controller { } //Try to get the number of cameras. If the number is 0, then the promise will fail, and we show the warning dialog - Html5Qrcode.getCameras().catch((devices) => { - document.getElementById('scanner-warning').classList.remove('d-none'); + Html5Qrcode.getCameras().catch(() => { + document.getElementById("scanner-warning")?.classList.remove("d-none"); }); this._scanner = new Html5QrcodeScanner(this.element.id, { fps: 10, qrbox: qrboxFunction, + // Key change: shrink preview height on mobile + ...(isMobile ? { aspectRatio: 1.0 } : {}), experimentalFeatures: { //This option improves reading quality on android chrome - useBarCodeDetectorIfSupported: true - } + useBarCodeDetectorIfSupported: true, + }, }, false); this._scanner.render(this.onScanSuccess.bind(this)); } disconnect() { - this._scanner.pause(); - this._scanner.clear(); + + // If we already stopped/cleared before submit, nothing to do. + const scanner = this._scanner; + this._scanner = null; + this._lastDecodedText = ""; + + // Unbind info-mode change handler (always do this, even if scanner is null) + const info = document.getElementById("scan_dialog_info_mode"); + if (info && this._onInfoChange) { + info.removeEventListener("change", this._onInfoChange); + } + this._onInfoChange = null; + + if (!scanner) return; + + try { + const p = scanner.clear?.(); + if (p && typeof p.then === "function") p.catch(() => {}); + } catch (_) { + // ignore + } } - onScanSuccess(decodedText, decodedResult) { - //Put our decoded Text into the input box + + onScanSuccess(decodedText) { + if (!decodedText) return; + + const normalized = String(decodedText).trim(); + if (!normalized) return; + + // scan once per barcode + if (normalized === this._lastDecodedText) return; + + // Mark as handled immediately (prevents spam even if callback fires repeatedly) + this._lastDecodedText = normalized; + document.getElementById('scan_dialog_input').value = decodedText; //Submit form document.getElementById('scan_dialog_form').requestSubmit(); diff --git a/assets/css/app/images.css b/assets/css/app/images.css index 0212a85b..7fa23a9e 100644 --- a/assets/css/app/images.css +++ b/assets/css/app/images.css @@ -58,6 +58,12 @@ object-fit: contain; } +@media (max-width: 768px) { + .part-info-image { + max-height: 100px; + } +} + .object-fit-cover { object-fit: cover; } diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index aebadd89..65eccf27 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -41,11 +41,16 @@ declare(strict_types=1); namespace App\Controller; +use App\Exceptions\InfoProviderNotActiveException; use App\Form\LabelSystem\ScanDialogType; -use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector; +use App\Services\InfoProviderSystem\Providers\LCSCProvider; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler; use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface; use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; use Doctrine\ORM\EntityNotFoundException; use InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -53,6 +58,13 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\Routing\Attribute\Route; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\ProviderRegistry; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use App\Entity\Parts\Part; +use \App\Entity\Parts\StorageLocation; +use Symfony\UX\Turbo\TurboBundle; /** * @see \App\Tests\Controller\ScanControllerTest @@ -60,9 +72,10 @@ use Symfony\Component\Routing\Attribute\Route; #[Route(path: '/scan')] class ScanController extends AbstractController { - public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeScanHelper $barcodeNormalizer) - { - } + public function __construct( + protected BarcodeScanResultHandler $resultHandler, + protected BarcodeScanHelper $barcodeNormalizer, + ) {} #[Route(path: '', name: 'scan_dialog')] public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response @@ -72,35 +85,86 @@ class ScanController extends AbstractController $form = $this->createForm(ScanDialogType::class); $form->handleRequest($request); + // If JS is working, scanning uses /scan/lookup and this action just renders the page. + // This fallback only runs if user submits the form manually or uses ?input=... if ($input === null && $form->isSubmitted() && $form->isValid()) { $input = $form['input']->getData(); - $mode = $form['mode']->getData(); } - $infoModeData = null; - if ($input !== null) { + if ($input !== null && $input !== '') { + $mode = $form->isSubmitted() ? $form['mode']->getData() : null; + $infoMode = $form->isSubmitted() && $form['info_mode']->getData(); + try { - $scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null); - //Perform a redirect if the info mode is not enabled - if (!$form['info_mode']->getData()) { - try { - return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); - } catch (EntityNotFoundException) { - $this->addFlash('success', 'scan.qr_not_found'); + $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null); + + // If not in info mode, mimic “normal scan” behavior: redirect if possible. + if (!$infoMode) { + + // Try to get an Info URL if possible + $url = $this->resultHandler->getInfoURL($scan); + if ($url !== null) { + return $this->redirect($url); + } + + //Try to get an creation URL if possible (only for vendor codes) + $createUrl = $this->buildCreateUrlForScanResult($scan); + if ($createUrl !== null) { + return $this->redirect($createUrl); + } + + //// Otherwise: show “not found” (not “format unknown”) + $this->addFlash('warning', 'scan.qr_not_found'); + } else { // Info mode + // Info mode fallback: render page with prefilled result + $decoded = $scan->getDecodedForInfoMode(); + + //Try to resolve to an entity, to enhance info mode with entity-specific data + $dbEntity = $this->resultHandler->resolveEntity($scan); + $resolvedPart = $this->resultHandler->resolvePart($scan); + $openUrl = $this->resultHandler->getInfoURL($scan); + + //If no entity is found, try to create an URL for creating a new part (only for vendor codes) + $createUrl = null; + if ($dbEntity === null) { + $createUrl = $this->buildCreateUrlForScanResult($scan); + } + + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + return $this->renderBlock('label_system/scanner/scanner.html.twig', 'scan_results', [ + 'decoded' => $decoded, + 'entity' => $dbEntity, + 'part' => $resolvedPart, + 'openUrl' => $openUrl, + 'createUrl' => $createUrl, + ]); } - } else { //Otherwise retrieve infoModeData - $infoModeData = $scan_result->getDecodedForInfoMode(); } - } catch (InvalidArgumentException) { - $this->addFlash('error', 'scan.format_unknown'); + } catch (\Throwable $e) { + // Keep fallback user-friendly; avoid 500 + $this->addFlash('warning', 'scan.format_unknown'); } } + //When we reach here, only the flash messages are relevant, so if it's a Turbo request, only send the flash message fragment, so the client can show it without a full page reload + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + //Only send our flash message, so the client can show it without a full page reload + return $this->renderBlock('_turbo_control.html.twig', 'flashes'); + } + return $this->render('label_system/scanner/scanner.html.twig', [ 'form' => $form, - 'infoModeData' => $infoModeData, + + //Info mode + 'decoded' => $decoded ?? null, + 'entity' => $dbEntity ?? null, + 'part' => $resolvedPart ?? null, + 'openUrl' => $openUrl ?? null, + 'createUrl' => $createUrl ?? null, ]); } @@ -125,11 +189,30 @@ class ScanController extends AbstractController source_type: BarcodeSourceType::INTERNAL ); - return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); + return $this->redirect($this->resultHandler->getInfoURL($scan_result) ?? throw new EntityNotFoundException("Not found")); } catch (EntityNotFoundException) { $this->addFlash('success', 'scan.qr_not_found'); return $this->redirectToRoute('homepage'); } } + + /** + * Builds a URL for creating a new part based on the barcode data, handles exceptions and shows user-friendly error messages if the provider is not active or if there is an error during URL generation. + * @param BarcodeScanResultInterface $scanResult + * @return string|null + */ + private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?string + { + try { + return $this->resultHandler->getCreationURL($scanResult); + } catch (InfoProviderNotActiveException $e) { + $this->addFlash('error', $e->getMessage()); + } catch (\Throwable) { + // Don’t break scanning UX if provider lookup fails + $this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.'); + } + + return null; + } } diff --git a/src/Exceptions/InfoProviderNotActiveException.php b/src/Exceptions/InfoProviderNotActiveException.php new file mode 100644 index 00000000..02f7cfb7 --- /dev/null +++ b/src/Exceptions/InfoProviderNotActiveException.php @@ -0,0 +1,48 @@ +. + */ + +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'] ?? '???'); + } +} diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php index 9199c31d..d9c1de0e 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -77,6 +77,7 @@ class ScanDialogType extends AbstractType BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user', BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp', BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin', + BarcodeSourceType::LCSC => 'scan_dialog.mode.lcsc', }, ]); diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index 9d5fee5e..49342301 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -389,4 +389,69 @@ class PartRepository extends NamedDBElementRepository return $baseIpn . '_' . ($maxSuffix + 1); } + /** + * Finds a part based on the provided info provider key and ID, with an option for case sensitivity. + * If no part is found with the given provider key and ID, null is returned. + * @param string $providerID + * @param string|null $providerKey If null, the provider key will not be included in the search criteria, and only the provider ID will be used for matching. + * @param bool $caseInsensitive If true, the provider ID comparison will be case-insensitive. Default is true. + * @return Part|null + */ + public function getPartByProviderInfo(string $providerID, ?string $providerKey = null, bool $caseInsensitive = true): ?Part + { + $qb = $this->createQueryBuilder('part'); + $qb->select('part'); + + if ($providerKey) { + $qb->where("part.providerReference.provider_key = :providerKey"); + $qb->setParameter('providerKey', $providerKey); + } + + + if ($caseInsensitive) { + $qb->andWhere("LOWER(part.providerReference.provider_id) = LOWER(:providerID)"); + } else { + $qb->andWhere("part.providerReference.provider_id = :providerID"); + } + + $qb->setParameter('providerID', $providerID); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * Finds a part based on the provided MPN (Manufacturer Part Number), with an option for case sensitivity. + * If no part is found with the given MPN, null is returned. + * @param string $mpn + * @param string|null $manufacturerName If provided, the search will also include a match for the manufacturer's name. If null, the manufacturer name will not be included in the search criteria. + * @param bool $caseInsensitive If true, the MPN comparison will be case-insensitive. Default is true (case-insensitive). + * @return Part|null + */ + public function getPartByMPN(string $mpn, ?string $manufacturerName = null, bool $caseInsensitive = true): ?Part + { + $qb = $this->createQueryBuilder('part'); + $qb->select('part'); + + if ($caseInsensitive) { + $qb->where("LOWER(part.manufacturer_product_number) = LOWER(:mpn)"); + } else { + $qb->where("part.manufacturer_product_number = :mpn"); + } + + if ($manufacturerName !== null) { + $qb->leftJoin('part.manufacturer', 'manufacturer'); + + if ($caseInsensitive) { + $qb->andWhere("LOWER(manufacturer.name) = LOWER(:manufacturerName)"); + } else { + $qb->andWhere("manufacturer.name = :manufacturerName"); + } + $qb->setParameter('manufacturerName', $manufacturerName); + } + + $qb->setParameter('mpn', $mpn); + + return $qb->getQuery()->getOneOrNullResult(); + } + } diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 9a24f3ae..5cc23f05 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -24,10 +24,15 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem; use App\Entity\Parts\Part; +use App\Exceptions\InfoProviderNotActiveException; +use App\Exceptions\OAuthReconnectRequiredException; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use Psr\Http\Client\ClientExceptionInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -49,6 +54,11 @@ final class PartInfoRetriever * @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 * @return SearchResultDTO[] The search results + * @throws InfoProviderNotActiveException if any of the given providers is not active + * @throws ClientException if any of the providers throws an exception during the search + * @throws \InvalidArgumentException if any of the given providers is not a valid provider key or instance + * @throws TransportException if any of the providers throws an exception during the search + * @throws OAuthReconnectRequiredException if any of the providers throws an exception during the search that indicates that the OAuth token needs to be refreshed */ public function searchByKeyword(string $keyword, array $providers): array { @@ -61,7 +71,7 @@ final class PartInfoRetriever //Ensure that the provider is active if (!$provider->isActive()) { - throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!"); + throw InfoProviderNotActiveException::fromProvider($provider); } if (!$provider instanceof InfoProviderInterface) { @@ -97,6 +107,7 @@ final class PartInfoRetriever * @param string $provider_key * @param string $part_id * @return PartDetailDTO + * @throws InfoProviderNotActiveException if the the given providers is not active */ public function getDetails(string $provider_key, string $part_id): PartDetailDTO { @@ -104,7 +115,7 @@ final class PartInfoRetriever //Ensure that the provider is active 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 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..b2363ec8 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php @@ -92,10 +92,15 @@ 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); + } + //Null means auto and we try the different formats $result = $this->parseInternalBarcode($input); @@ -125,6 +130,11 @@ final class BarcodeScanHelper return $this->parseGTINBarcode($input); } + // Try LCSC barcode + if (LCSCBarcodeScanResult::isLCSCBarcode($input)) { + return $this->parseLCSCBarcode($input); + } + throw new InvalidArgumentException('Unknown barcode'); } @@ -138,6 +148,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..372e976e --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -0,0 +1,315 @@ +. + */ + +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); + } + + throw new \InvalidArgumentException("Barcode does not support resolving to a local entity: ".get_class($barcodeScan)); + } + + /** + * 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); + } + + 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..13ab4bf3 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php @@ -26,25 +26,28 @@ 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_defined'; /** * EIGP114 formatted barcodes like used by digikey, mouser, etc. */ - case EIGP114; + case EIGP114 = 'eigp114'; /** * 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'; } 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/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/_turbo_control.html.twig b/templates/_turbo_control.html.twig index 90ae8d9a..cf65f0da 100644 --- a/templates/_turbo_control.html.twig +++ b/templates/_turbo_control.html.twig @@ -1,14 +1,20 @@ -{# Insert flashes #} -
- {% for label, messages in app.flashes() %} - {% for message in messages %} - {{ include('_toast.html.twig', { - 'label': label, - 'message': message - }) }} - {% endfor %} - {% endfor %} -
+{% block flashes %} + {# Insert flashes #} + + + +{% endblock %} {# Allow pages to request a fully reload of everything #} {% if global_reload_needed is defined and global_reload_needed %} 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 %} +
+ + + +
+ {% endif %} + +
+
+
+ +
+ + +
+

{{ part.name }}

+
{{ part.description | format_markdown(true) }}
+
+
+ {% trans %}category.label{% endtrans %} + +
+
+ {{ helper.structural_entity_link(part.category) }} +
+
+ +
+
+ {% trans %}footprint.label{% endtrans %} + +
+
+ {{ helper.structural_entity_link(part.footprint) }} +
+
+ + {# Show part lots / locations #} + {% if part.partLots is not empty %} + + + + + + + + + {% for lot in part.partLots %} + + + + + {% endfor %} + +
{% trans %}part_lots.storage_location{% endtrans %} + {% trans %}part_lots.amount{% endtrans %} +
+ {% if lot.storageLocation %} + {{ helper.structural_entity_link(lot.storageLocation) }} + {% else %} + + {% endif %} + + {% if lot.instockUnknown %} + ? + {% else %} + {{ lot.amount | format_amount(part.partUnit, {'decimals': 5}) }} + {% endif %} +
+ {% else %} +
{% trans %}label_scanner.no_locations{% endtrans %}
+ {% endif %} + +
+
+
+ + {% elseif entity %} {# If we have an entity but that is not an part #} + +
+
+ {% trans %}label_scanner.target_found{% endtrans %}: {{ type_label(entity) }} + {% if openUrl %} +
+ + + +
+ {% endif %} + +
+
+
+ +
+ + +
+

{{ entity.name }}

+

{% trans %}id.label{% endtrans %}: {{ entity.id }} ({{ type_label(entity) }})

+ + {% if entity.fullPath is defined %} + {{ helper.breadcrumb_entity_link(entity)}} + {% endif %} +
+
+
+ + {% endif %} + + + {% if createUrl %} +
+

{% trans %}label_scanner.part_can_be_created{% endtrans %}

+

{% trans %}label_scanner.part_can_be_created.help{% endtrans %}

+
+ {% trans %}label_scanner.part_create_btn{% endtrans %} +
+ {% endif %} + +

+ {% trans %}label_scanner.scan_result.title{% endtrans %} +

+ + {# Decoded barcode fields #} + + + {% for key, value in decoded %} + + + + + {% endfor %} + +
{{ key }}{{ value }}
+ + {# Whitespace under table and Input form fields #} +
+ +{% endif %} diff --git a/templates/label_system/scanner/scanner.html.twig b/templates/label_system/scanner/scanner.html.twig index 1f978a9b..f9b51388 100644 --- a/templates/label_system/scanner/scanner.html.twig +++ b/templates/label_system/scanner/scanner.html.twig @@ -10,35 +10,28 @@
-
+
+ {% include "label_system/scanner/_info_mode.html.twig" %} +
+ + {{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }} + + {{ form_end(form) }}
- - {{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }} - - {{ form_end(form) }} - - - {% if infoModeData %} -
-

{% trans %}label_scanner.decoded_info.title{% endtrans %}

- - - - {% for key, value in infoModeData %} - - - - - {% endfor %} - -
{{ key }}{{ value }}
- - {% endif %} - +{% endblock %} + +{% block scan_results %} + + + {% endblock %} diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php deleted file mode 100644 index c5bdb02d..00000000 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php +++ /dev/null @@ -1,85 +0,0 @@ -. - */ - -declare(strict_types=1); - -/** - * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). - * - * Copyright (C) 2019 - 2020 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\Tests\Services\LabelSystem\BarcodeScanner; - -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use App\Entity\LabelSystem\LabelSupportedElement; -use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector; -use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; -use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; -use Doctrine\ORM\EntityNotFoundException; -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; - -final class BarcodeRedirectorTest extends KernelTestCase -{ - private ?BarcodeRedirector $service = null; - - protected function setUp(): void - { - self::bootKernel(); - $this->service = self::getContainer()->get(BarcodeRedirector::class); - } - - public static function urlDataProvider(): \Iterator - { - yield [new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL), '/en/part/1']; - //Part lot redirects to Part info page (Part lot 1 is associated with part 3) - yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL), '/en/part/3?highlightLot=1']; - yield [new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL), '/en/store_location/1/parts']; - } - - #[DataProvider('urlDataProvider')] - #[Group('DB')] - public function testGetRedirectURL(LocalBarcodeScanResult $scanResult, string $url): void - { - $this->assertSame($url, $this->service->getRedirectURL($scanResult)); - } - - public function testGetRedirectEntityNotFount(): void - { - $this->expectException(EntityNotFoundException::class); - //If we encounter an invalid lot, we must throw an exception - $this->service->getRedirectURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, - 12_345_678, BarcodeSourceType::INTERNAL)); - } -} diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php index 248f1ae9..8f8c7a18 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php @@ -49,6 +49,7 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; final class BarcodeScanHelperTest extends WebTestCase { @@ -124,6 +125,14 @@ final class BarcodeScanHelperTest extends WebTestCase ]); yield [$eigp114Result, "[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04"]; + + $lcscInput = '{pc:C138033,pm:RC0402FR-071ML,qty:10}'; + $lcscResult = new LCSCBarcodeScanResult( + ['pc' => 'C138033', 'pm' => 'RC0402FR-071ML', 'qty' => '10'], + $lcscInput + ); + + yield [$lcscResult, $lcscInput]; } public static function invalidDataProvider(): \Iterator @@ -153,4 +162,33 @@ final class BarcodeScanHelperTest extends WebTestCase $this->expectException(\InvalidArgumentException::class); $this->service->scanBarcodeContent($input); } + + public function testAutoDetectLcscBarcode(): void + { + $input = '{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'; + + $result = $this->service->scanBarcodeContent($input); + + $this->assertInstanceOf(LCSCBarcodeScanResult::class, $result); + $this->assertSame('C138033', $result->lcscCode); + $this->assertSame('RC0402FR-071ML', $result->mpn); + } + + public function testLcscExplicitTypeParses(): void + { + $input = '{pc:C138033,pm:RC0402FR-071ML,qty:10}'; + + $result = $this->service->scanBarcodeContent($input, BarcodeSourceType::LCSC); + + $this->assertInstanceOf(LCSCBarcodeScanResult::class, $result); + $this->assertSame('C138033', $result->lcscCode); + $this->assertSame('RC0402FR-071ML', $result->mpn); + } + + public function testLcscExplicitTypeRejectsNonLcsc(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->service->scanBarcodeContent('not-an-lcsc', BarcodeSourceType::LCSC); + } } diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php new file mode 100644 index 00000000..840e84c0 --- /dev/null +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php @@ -0,0 +1,183 @@ +. + */ + +declare(strict_types=1); + +/** + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2020 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\Tests\Services\LabelSystem\BarcodeScanner; + +use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; +use App\Entity\Parts\StorageLocation; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use App\Entity\LabelSystem\LabelSupportedElement; +use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; +use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; +use Doctrine\ORM\EntityNotFoundException; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface; +use InvalidArgumentException; + + +final class BarcodeScanResultHandlerTest extends KernelTestCase +{ + private ?BarcodeScanResultHandler $service = null; + + protected function setUp(): void + { + self::bootKernel(); + $this->service = self::getContainer()->get(BarcodeScanResultHandler::class); + } + + public static function urlDataProvider(): \Iterator + { + yield [new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL), '/en/part/1']; + //Part lot redirects to Part info page (Part lot 1 is associated with part 3) + yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL), '/en/part/3?highlightLot=1']; + yield [new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL), '/en/store_location/1/parts']; + } + + #[DataProvider('urlDataProvider')] + #[Group('DB')] + public function testGetRedirectURL(LocalBarcodeScanResult $scanResult, string $url): void + { + $this->assertSame($url, $this->service->getInfoURL($scanResult)); + } + + public function testGetRedirectEntityNotFound(): void + { + //If we encounter an invalid lot, we must get an null result + $url = $this->service->getInfoURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, + 12_345_678, BarcodeSourceType::INTERNAL)); + + $this->assertNull($url); + } + + public function testGetRedirectURLThrowsOnUnknownScanType(): void + { + $unknown = new class implements BarcodeScanResultInterface { + public function getDecodedForInfoMode(): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->service->getInfoURL($unknown); + } + + public function testEIGPBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void + { + $scan = new EIGP114BarcodeScanResult([]); + + $this->assertNull($this->service->resolvePart($scan)); + $this->assertNull($this->service->getInfoURL($scan)); + } + + public function testLCSCBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void + { + $scan = new LCSCBarcodeScanResult( + fields: ['pc' => 'C0000000', 'pm' => ''], + rawInput: '{pc:C0000000,pm:}' + ); + + $this->assertNull($this->service->resolvePart($scan)); + $this->assertNull($this->service->getInfoURL($scan)); + } + + public function testResolveEntityThrowsOnUnknownScanType(): void + { + $unknown = new class implements BarcodeScanResultInterface { + public function getDecodedForInfoMode(): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->service->resolvePart($unknown); + } + + public function testResolveEntity(): void + { + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolveEntity($scan); + + $this->assertSame(1, $part->getId()); + $this->assertInstanceOf(Part::class, $part); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL); + $entity = $this->service->resolveEntity($scan); + $this->assertSame(1, $entity->getId()); + $this->assertInstanceOf(PartLot::class, $entity); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL); + $entity = $this->service->resolveEntity($scan); + $this->assertSame(1, $entity->getId()); + $this->assertInstanceOf(StorageLocation::class, $entity); + } + + public function testResolvePart(): void + { + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolvePart($scan); + + $this->assertSame(1, $part->getId()); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolvePart($scan); + $this->assertSame(3, $part->getId()); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolvePart($scan); + $this->assertNull($part); //Store location does not resolve to a part + } + + public function testGetCreateInfos(): void + { + $lcscScan = LCSCBarcodeScanResult::parse('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'); + $infos = $this->service->getCreateInfos($lcscScan); + + $this->assertSame('lcsc', $infos['providerKey']); + $this->assertSame('C138033', $infos['providerId']); + } +} diff --git a/tests/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResultTest.php b/tests/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResultTest.php new file mode 100644 index 00000000..2128f113 --- /dev/null +++ b/tests/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResultTest.php @@ -0,0 +1,86 @@ +. + */ + +namespace App\Tests\Services\LabelSystem\BarcodeScanner; + +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; + +class LCSCBarcodeScanResultTest extends TestCase +{ + public function testIsLCSCBarcode(): void + { + $this->assertFalse(LCSCBarcodeScanResult::isLCSCBarcode('invalid')); + $this->assertFalse(LCSCBarcodeScanResult::isLCSCBarcode('LCSC-12345')); + $this->assertFalse(LCSCBarcodeScanResult::isLCSCBarcode('')); + + $this->assertTrue(LCSCBarcodeScanResult::isLCSCBarcode('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}')); + $this->assertTrue(LCSCBarcodeScanResult::isLCSCBarcode('{pbn:PICK2506270148,on:GB2506270877,pc:C22437266,pm:IA0509S-2W,qty:3,mc:,cc:1,pdi:164234874,hp:null,wc:ZH}')); + } + + public function testConstruct(): void + { + $raw = '{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'; + $fields = ['pbn' => 'PB1', 'on' => 'ON1', 'pc' => 'C138033', 'pm' => 'RC0402FR-071ML', 'qty' => '10']; + $scan = new LCSCBarcodeScanResult($fields, $raw); + //Splitting up should work and assign the correct values to the properties: + $this->assertSame('RC0402FR-071ML', $scan->mpn); + $this->assertSame('C138033', $scan->lcscCode); + + //Fields and raw input should be preserved + $this->assertSame($fields, $scan->fields); + $this->assertSame($raw, $scan->rawInput); + } + + public function testLCSCParseInvalidFormatThrows(): void + { + $this->expectException(InvalidArgumentException::class); + LCSCBarcodeScanResult::parse('not-an-lcsc-barcode'); + } + + public function testParse(): void + { + $scan = LCSCBarcodeScanResult::parse('{pbn:PICK2506270148,on:GB2506270877,pc:C22437266,pm:IA0509S-2W,qty:3,mc:,cc:1,pdi:164234874,hp:null,wc:ZH}'); + + $this->assertSame('IA0509S-2W', $scan->mpn); + $this->assertSame('C22437266', $scan->lcscCode); + $this->assertSame('PICK2506270148', $scan->pickBatchNumber); + $this->assertSame('GB2506270877', $scan->orderNumber); + $this->assertSame(3, $scan->quantity); + $this->assertSame('1', $scan->countryChannel); + $this->assertSame('164234874', $scan->pdi); + $this->assertSame('null', $scan->hp); + $this->assertSame('ZH', $scan->warehouseCode); + } + + public function testLCSCParseExtractsFields(): void + { + $scan = LCSCBarcodeScanResult::parse('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'); + + $this->assertSame('RC0402FR-071ML', $scan->mpn); + $this->assertSame('C138033', $scan->lcscCode); + + $decoded = $scan->getDecodedForInfoMode(); + $this->assertSame('LCSC', $decoded['Barcode type']); + $this->assertSame('RC0402FR-071ML', $decoded['MPN (pm)']); + $this->assertSame('C138033', $decoded['LCSC code (pc)']); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index d9418563..31bc3884 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9500,6 +9500,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g EIGP 114 barcode (e.g. the datamatrix codes on digikey and mouser orders) + + + scan_dialog.mode.lcsc + LCSC.com barcode + + scan_dialog.info_mode @@ -9512,6 +9518,24 @@ Please note, that you can not impersonate a disabled user. If you try you will g Decoded information + + + label_scanner.target_found + Item found in database + + + + + label_scanner.scan_result.title + Scan result + + + + + label_scanner.no_locations + Part is not stored at any location. + + label_generator.edit_profiles @@ -12509,5 +12533,35 @@ Buerklin-API Authentication server: Last stocktake + + + label_scanner.open + View details + + + + + label_scanner.db_part_found + Database [part] found for barcode + + + + + label_scanner.part_can_be_created + [Part] can be created + + + + + label_scanner.part_can_be_created.help + No matching [part] was found in the database, but you can create a new [part] based of this barcode. + + + + + label_scanner.part_create_btn + Create [part] from barcode + +