. */ 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); } //So far we can just copy over our provider info array to the URL parameters: return $this->urlGenerator->generate('info_providers_create_part', $infos); } /** * 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 * Returns first match (consistent with EIGP114 logic) * 2) Fallback to search across supplier part number (SPN) */ 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 search by SPN return $this->em->getRepository(Part::class)->getPartBySPN($pc); } /** * 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, lotAmount?: float|int, lotName?: string, lotUserBarcode?: 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, 'lotAmount' => $scanResult->quantity, 'lotName' => $scanResult->orderNumber ?? $scanResult->pickBatchNumber, 'lotUserBarcode' => $scanResult->rawInput, ]; } 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, lotAmount?: float|int, lotName?: string, lotUserBarcode?: 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, 'lotAmount' => $scanResult->quantity, 'lotName' => $scanResult->customerPO, 'lotUserBarcode' => $scanResult->rawInput, ]; } return null; } // Digi-Key: supplierPartNumber directly if ($vendor === 'digikey') { return [ 'providerKey' => 'digikey', 'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Digikey barcode does not contain required supplier part number'), 'lotAmount' => $scanResult->quantity, 'lotName' => $scanResult->digikeyInvoiceNumber ?? $scanResult->digikeySalesOrderNumber ?? $scanResult->customerPO, 'lotUserBarcode' => $scanResult->rawInput, ]; } // Element14: can use supplierPartNumber directly if ($vendor === 'element14') { return [ 'providerKey' => 'element14', 'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Element14 barcode does not contain required supplier part number'), 'lotAmount' => $scanResult->quantity, 'lotName' => $scanResult->customerPO, 'lotUserBarcode' => $scanResult->rawInput, ]; } return null; } }