Label Scanner Enhancements: LCSC barcode, create part, augmented scanning (#1194)

* added handling of LCSC barcode decoding and part loading on Label Scanner

* when a part is scanned and not found, the scanner did not redraw so scanning subsequent parts was not possible without reloading the browser page.  fixed the barcode scanner initialization and shutdown so it redraws properly after part not found

* added redirection to part page on successful scan of lcsc, digikey, and mouser barcodes.   added create part button if part does not exist in database

* added augmented mode to label scanner to use vendor labels for part lookup to see part storage location quickly

* shrink camera height on mobile so augmented information can been viewed onscreen

* handle momentarily bad reads from qrcode library

* removed augmented checkbox and combined functionality into info mode checkbox.  changed barcode scanner to use XHR callback for barcode decoding to avoid problems with form submission and camera caused by page reloaded when part not found.

* fix scanning of part-db barcodes to redirect to storage location or part lots.   made scan result messages conditional for parts or other non-part barcodes

* fix static analysis errors

* added unit tests for meeting code coverage report

* fix @MayNiklas reported bug:  when manually submitting the form (from a barcode scan or manual input) redirect to Create New part screen for the decoded information instead of showing 'Format Unknown' popup error message

* fix @d-buchmann bug:  clear 'scan-augmented-result' field upon rescan of new barcode

* fix @d-buchmann bug: after scanning in Info mode, if Info mode is turned off when scanning a part that did not exist, it now redirects user to create part page

* fix @d-buchmann bug: make barcode decode table 100% width of page

* fix bug with manual form submission where a part does not exist but decodes properly which causes the camera to not redraw on page reload due to unclean shutdown. this is an existing bug in the scanner interface.

steps to produce the issue:
- have camera active
- put in code in Input
- info mode ticked
- click submit button

on page reload the camera does not reactivate

* fixed translation messages

* Use symfony native functions to generate the routes for part creation

* Use native request functions for request param parsing

* Refactored LCSCBarcocdeScanResult to be an value object like the other Barcode results

* Added test for LCSCBarcodeScanResult

* Fixed exception when submitting form for info mode

* Made BarcodeSourceType a backed enum, so that it can be used in Request::getEnum()

* Moved database queries from BarcodeRedirector to PartRepository

* Fixed modeEnum parsing

* Fixed test errors

* Refactored BarcodeRedirector logic to be more universal

* Fixed BarcodeScanResultHandler test

* Refactored BarcodeScanResultHandler to be able to resolve arbitary entities from scans

* Moved barcode to info provider logic from Controller to BarcodeScanResultHandler service

* Improved augmentented info styling and allow to use it with normal form submit too

* Correctly handle nullable infoURL in ScanController

* Replaced the custom controller for fragment replacements with symfony streams

This does not require a complete new endpoint

* Removed data-lookup-url attribute from scan read box

* Removed unused translations

* Added basic info block when an storage location was found for an barcode

* Fixed phpstan issues

* Fixed tests

* Fixed part image for mobile view

* Added more tests for BarcodeScanResultHandler service

* Fixed tests

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
swdee 2026-02-23 09:26:44 +13:00 committed by GitHub
parent 8ef9dd432f
commit c29605ef23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1370 additions and 344 deletions

View file

@ -20,6 +20,10 @@
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
import { Toast } from 'bootstrap'; 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 { export default class extends Controller {
connect() { connect() {
//Move all toasts from the page into our toast container and show them //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); const toast = new Toast(this.element);
toast.show(); toast.show();
} }
} }

View file

@ -21,17 +21,31 @@ import {Controller} from "@hotwired/stimulus";
//import * as ZXing from "@zxing/library"; //import * as ZXing from "@zxing/library";
import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode"; import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller";
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller { export default class extends Controller {
//codeReader = null;
_scanner = null; _scanner = null;
_submitting = false;
_lastDecodedText = "";
_onInfoChange = null;
connect() { 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 //This function ensures, that the qrbox is 70% of the total viewport
let qrboxFunction = function(viewfinderWidth, viewfinderHeight) { 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 //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) => { Html5Qrcode.getCameras().catch(() => {
document.getElementById('scanner-warning').classList.remove('d-none'); document.getElementById("scanner-warning")?.classList.remove("d-none");
}); });
this._scanner = new Html5QrcodeScanner(this.element.id, { this._scanner = new Html5QrcodeScanner(this.element.id, {
fps: 10, fps: 10,
qrbox: qrboxFunction, qrbox: qrboxFunction,
// Key change: shrink preview height on mobile
...(isMobile ? { aspectRatio: 1.0 } : {}),
experimentalFeatures: { experimentalFeatures: {
//This option improves reading quality on android chrome //This option improves reading quality on android chrome
useBarCodeDetectorIfSupported: true useBarCodeDetectorIfSupported: true,
} },
}, false); }, false);
this._scanner.render(this.onScanSuccess.bind(this)); this._scanner.render(this.onScanSuccess.bind(this));
} }
disconnect() { 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; document.getElementById('scan_dialog_input').value = decodedText;
//Submit form //Submit form
document.getElementById('scan_dialog_form').requestSubmit(); document.getElementById('scan_dialog_form').requestSubmit();

View file

@ -58,6 +58,12 @@
object-fit: contain; object-fit: contain;
} }
@media (max-width: 768px) {
.part-info-image {
max-height: 100px;
}
}
.object-fit-cover { .object-fit-cover {
object-fit: cover; object-fit: cover;
} }

View file

@ -41,11 +41,16 @@ 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\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\BarcodeScanHelper;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface;
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
use Doctrine\ORM\EntityNotFoundException; use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException; use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -53,6 +58,13 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route; 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 * @see \App\Tests\Controller\ScanControllerTest
@ -60,9 +72,10 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route(path: '/scan')] #[Route(path: '/scan')]
class ScanController extends AbstractController 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')] #[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
@ -72,35 +85,86 @@ class ScanController extends AbstractController
$form = $this->createForm(ScanDialogType::class); $form = $this->createForm(ScanDialogType::class);
$form->handleRequest($request); $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()) { if ($input === null && $form->isSubmitted() && $form->isValid()) {
$input = $form['input']->getData(); $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 { try {
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null); $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
//Perform a redirect if the info mode is not enabled
if (!$form['info_mode']->getData()) { // If not in info mode, mimic “normal scan” behavior: redirect if possible.
try { if (!$infoMode) {
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
} catch (EntityNotFoundException) { // Try to get an Info URL if possible
$this->addFlash('success', 'scan.qr_not_found'); $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) { } catch (\Throwable $e) {
$this->addFlash('error', 'scan.format_unknown'); // 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', [ return $this->render('label_system/scanner/scanner.html.twig', [
'form' => $form, '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 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) { } catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found'); $this->addFlash('success', 'scan.qr_not_found');
return $this->redirectToRoute('homepage'); 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) {
// Dont 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;
}
} }

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

@ -77,6 +77,7 @@ class ScanDialogType extends AbstractType
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user', BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp', BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp',
BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin', BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin',
BarcodeSourceType::LCSC => 'scan_dialog.mode.lcsc',
}, },
]); ]);

View file

@ -389,4 +389,69 @@ class PartRepository extends NamedDBElementRepository
return $baseIpn . '_' . ($maxSuffix + 1); 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();
}
} }

View file

@ -24,10 +24,15 @@ 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\Exceptions\OAuthReconnectRequiredException;
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;
use Psr\Http\Client\ClientExceptionInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; 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\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; 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[]|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
* @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 public function searchByKeyword(string $keyword, array $providers): array
{ {
@ -61,7 +71,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 +107,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 +115,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

@ -1,180 +0,0 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 <https://www.gnu.org/licenses/>.
*/
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();
}
}

View file

@ -92,10 +92,15 @@ final class BarcodeScanHelper
if ($type === BarcodeSourceType::EIGP114) { if ($type === BarcodeSourceType::EIGP114) {
return $this->parseEIGP114Barcode($input); return $this->parseEIGP114Barcode($input);
} }
if ($type === BarcodeSourceType::GTIN) { if ($type === BarcodeSourceType::GTIN) {
return $this->parseGTINBarcode($input); return $this->parseGTINBarcode($input);
} }
if ($type === BarcodeSourceType::LCSC) {
return $this->parseLCSCBarcode($input);
}
//Null means auto and we try the different formats //Null means auto and we try the different formats
$result = $this->parseInternalBarcode($input); $result = $this->parseInternalBarcode($input);
@ -125,6 +130,11 @@ final class BarcodeScanHelper
return $this->parseGTINBarcode($input); return $this->parseGTINBarcode($input);
} }
// Try LCSC barcode
if (LCSCBarcodeScanResult::isLCSCBarcode($input)) {
return $this->parseLCSCBarcode($input);
}
throw new InvalidArgumentException('Unknown barcode'); throw new InvalidArgumentException('Unknown barcode');
} }
@ -138,6 +148,11 @@ final class BarcodeScanHelper
return EIGP114BarcodeScanResult::parseFormat06Code($input); return EIGP114BarcodeScanResult::parseFormat06Code($input);
} }
private function parseLCSCBarcode(string $input): LCSCBarcodeScanResult
{
return LCSCBarcodeScanResult::parse($input);
}
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
{ {
$lot_repo = $this->entityManager->getRepository(PartLot::class); $lot_repo = $this->entityManager->getRepository(PartLot::class);

View file

@ -0,0 +1,315 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 <https://www.gnu.org/licenses/>.
*/
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;
}
}

View file

@ -33,4 +33,4 @@ interface BarcodeScanResultInterface
* @return array<string, string|int|float|null> * @return array<string, string|int|float|null>
*/ */
public function getDecodedForInfoMode(): array; public function getDecodedForInfoMode(): array;
} }

View file

@ -26,25 +26,28 @@ namespace App\Services\LabelSystem\BarcodeScanner;
/** /**
* This enum represents the different types, where a barcode/QR-code can be generated from * 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 */ /** 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) */ /** 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 * 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. * 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. * 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';
} }

View file

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Services\LabelSystem\BarcodeScanner;
use InvalidArgumentException;
/**
* This class represents the content of a lcsc.com barcode
* Its data structure is represented by {pbn:...,on:...,pc:...,pm:...,qty:...}
*/
readonly class LCSCBarcodeScanResult implements BarcodeScanResultInterface
{
/** @var string|null (pbn) */
public ?string $pickBatchNumber;
/** @var string|null (on) */
public ?string $orderNumber;
/** @var string|null LCSC Supplier part number (pc) */
public ?string $lcscCode;
/** @var string|null (pm) */
public ?string $mpn;
/** @var int|null (qty) */
public ?int $quantity;
/** @var string|null Country Channel as raw value (CC) */
public ?string $countryChannel;
/**
* @var string|null Warehouse code as raw value (WC)
*/
public ?string $warehouseCode;
/**
* @var string|null Unknown numeric code (pdi)
*/
public ?string $pdi;
/**
* @var string|null Unknown value (hp)
*/
public ?string $hp;
/**
* @param array<string, string> $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);
}
}

View file

@ -23,7 +23,10 @@ declare(strict_types=1);
namespace App\Twig; namespace App\Twig;
use App\Entity\Attachments\Attachment; use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Parts\Part;
use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\PartPreviewGenerator;
use App\Services\Misc\FAIconGenerator; use App\Services\Misc\FAIconGenerator;
use Twig\Attribute\AsTwigFunction; use Twig\Attribute\AsTwigFunction;
use Twig\Extension\AbstractExtension; use Twig\Extension\AbstractExtension;
@ -31,7 +34,7 @@ use Twig\TwigFunction;
final readonly class AttachmentExtension 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); 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. * 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 * Null is allowed for files withot extension

View file

@ -1,14 +1,20 @@
{# Insert flashes #} {% block flashes %}
<div class="toasts-global d-none"> {# Insert flashes #}
{% for label, messages in app.flashes() %} <turbo-stream action="replace" action="morph" target="toast-container">
{% for message in messages %} <template>
{{ include('_toast.html.twig', { <div class="toast-container" id="toast-container">
'label': label, {% for label, messages in app.flashes() %}
'message': message {% for message in messages %}
}) }} {{ include('_toast.html.twig', {
{% endfor %} 'label': label,
{% endfor %} 'message': message
</div> }) }}
{% endfor %}
{% endfor %}
</div>
</template>
</turbo-stream>
{% endblock %}
{# Allow pages to request a fully reload of everything #} {# Allow pages to request a fully reload of everything #}
{% if global_reload_needed is defined and global_reload_needed %} {% if global_reload_needed is defined and global_reload_needed %}

View file

@ -0,0 +1,154 @@
{% import "helper.twig" as helper %}
{% if decoded is not empty %}
<hr>
{% if part %} {# Show detailed info when it is a part #}
<div class="card border-success">
<h5 class="card-header text-bg-success">
<small>{% trans %}label_scanner.db_part_found{% endtrans %}</small>
{% if openUrl %}
<div class="btn-group float-end">
<a href="{{ openUrl }}" target="_blank" class="btn btn-sm btn-outline-light"
title="{% trans %}label_scanner.open{% endtrans %}">
<i class="fa-solid fa-eye"></i>
</a>
</div>
{% endif %}
</h5>
<div class="card-body row">
<div class="col-sm-2">
<img class="d-block w-100 img-fluid img-thumbnail bg-light part-info-image"
src="{{ entity_thumbnail(part) ?? asset('img/part_placeholder.svg') }}" alt="">
</div>
<div class="col-sm-10">
<h4 class="card-title mb-0">{{ part.name }}</h4>
<div class="card-text text-muted">{{ part.description | format_markdown(true) }}</div>
<div>
<dt class="d-inline-block">
<span class="visually-hidden">{% trans %}category.label{% endtrans %}</span>
<i class="fas fa-tag fa-fw" title="{% trans %}category.label{% endtrans %}"></i>
</dt>
<dd class="d-inline">
<span class="text-muted">{{ helper.structural_entity_link(part.category) }}</span>
</dd>
</div>
<div>
<dt class="d-inline-block">
<span class="visually-hidden">{% trans %}footprint.label{% endtrans %}</span>
<i class="fas fa-microchip fa-fw" title="{% trans %}footprint.label{% endtrans %}"></i>
</dt>
<dd class="d-inline">
<span class="text-muted">{{ helper.structural_entity_link(part.footprint) }}</span>
</dd>
</div>
{# Show part lots / locations #}
{% if part.partLots is not empty %}
<table class="table table-sm table-striped mb-2 w-auto">
<thead>
<tr>
<th scope="col">{% trans %}part_lots.storage_location{% endtrans %}</th>
<th scope="col" class="text-end" style="width: 6rem;">
{% trans %}part_lots.amount{% endtrans %}
</th>
</tr>
</thead>
<tbody>
{% for lot in part.partLots %}
<tr>
<td>
{% if lot.storageLocation %}
{{ helper.structural_entity_link(lot.storageLocation) }}
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td>
<td class="text-end" style="width: 6rem;">
{% if lot.instockUnknown %}
<span class="text-muted">?</span>
{% else %}
{{ lot.amount | format_amount(part.partUnit, {'decimals': 5}) }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-muted">{% trans %}label_scanner.no_locations{% endtrans %}</div>
{% endif %}
</div>
</div>
</div>
{% elseif entity %} {# If we have an entity but that is not an part #}
<div class="card border-success">
<h5 class="card-header text-bg-success">
<small>{% trans %}label_scanner.target_found{% endtrans %}: {{ type_label(entity) }}</small>
{% if openUrl %}
<div class="btn-group float-end">
<a href="{{ openUrl }}" target="_blank" class="btn btn-sm btn-outline-light"
title="{% trans %}label_scanner.open{% endtrans %}">
<i class="fa-solid fa-eye"></i>
</a>
</div>
{% endif %}
</h5>
<div class="card-body row">
<div class="col-sm-2">
<img class="d-block w-100 img-fluid img-thumbnail bg-light part-info-image"
src="{{ entity_thumbnail(entity) ?? asset('img/part_placeholder.svg') }}" alt="">
</div>
<div class="col-sm-10">
<h4 class="card-title mb-0">{{ entity.name }}</h4>
<p>{% trans %}id.label{% endtrans %}: {{ entity.id }} ({{ type_label(entity) }})</p>
{% if entity.fullPath is defined %}
{{ helper.breadcrumb_entity_link(entity)}}
{% endif %}
</div>
</div>
</div>
{% endif %}
{% if createUrl %}
<div class="alert alert-info mb-2">
<h4 class="alert-heading mb-0">{% trans %}label_scanner.part_can_be_created{% endtrans %}</h4>
<p class="text-muted mb-0"><small >{% trans %}label_scanner.part_can_be_created.help{% endtrans %}</small></p>
<hr>
<a class="btn btn-outline-success" href="{{ createUrl }}" target="_blank"><i class="fas fa-plus-square"></i> {% trans %}label_scanner.part_create_btn{% endtrans %}</a>
</div>
{% endif %}
<h4 class="mt-2">
{% trans %}label_scanner.scan_result.title{% endtrans %}
</h4>
{# Decoded barcode fields #}
<table class="table table-striped table-hover table-bordered table-sm">
<tbody>
{% for key, value in decoded %}
<tr>
<th class="text-nowrap">{{ key }}</th>
<td><code>{{ value }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{# Whitespace under table and Input form fields #}
<hr>
{% endif %}

View file

@ -10,35 +10,28 @@
<div class=""> <div class="">
<div class="form-group row"> <div class="form-group row">
<div class="offset-sm-3 col-sm-9"> <div class="offset-sm-3 col-sm-9">
<div class="img-thumbnail" style="max-width: 600px;"> <div class="img-thumbnail" style="max-width: 600px;">
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}></div> <div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}></div>
</div> </div>
</div> </div>
</div> </div>
<div id="scan-augmented-result" class="mt-3">
{% include "label_system/scanner/_info_mode.html.twig" %}
</div>
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
{{ form_end(form) }}
</div> </div>
{% endblock %}
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
{% block scan_results %}
{{ form_end(form) }} <turbo-stream action="replace" action="morph" target="scan-augmented-result">
<template>
<div id="scan-augmented-result" class="mt-3">
{% if infoModeData %} {% include "label_system/scanner/_info_mode.html.twig" %}
<hr> </div>
<h4>{% trans %}label_scanner.decoded_info.title{% endtrans %}</h4> </template>
</turbo-stream>
<table class="table table-striped table-hover table-bordered table-sm">
<tbody>
{% for key, value in infoModeData %}
<tr>
<td>{{ key }}</td>
<td><code>{{ value }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -1,85 +0,0 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 <https://www.gnu.org/licenses/>.
*/
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));
}
}

View file

@ -49,6 +49,7 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
final class BarcodeScanHelperTest extends WebTestCase 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"]; 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 public static function invalidDataProvider(): \Iterator
@ -153,4 +162,33 @@ final class BarcodeScanHelperTest extends WebTestCase
$this->expectException(\InvalidArgumentException::class); $this->expectException(\InvalidArgumentException::class);
$this->service->scanBarcodeContent($input); $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);
}
} }

View file

@ -0,0 +1,183 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 <https://www.gnu.org/licenses/>.
*/
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']);
}
}

View file

@ -0,0 +1,86 @@
<?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/>.
*/
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)']);
}
}

View file

@ -9500,6 +9500,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>EIGP 114 barcode (e.g. the datamatrix codes on digikey and mouser orders)</target> <target>EIGP 114 barcode (e.g. the datamatrix codes on digikey and mouser orders)</target>
</segment> </segment>
</unit> </unit>
<unit id="BnqcKWx" name="scan_dialog.mode.lcsc">
<segment>
<source>scan_dialog.mode.lcsc</source>
<target>LCSC.com barcode</target>
</segment>
</unit>
<unit id="QSMS_Bd" name="scan_dialog.info_mode"> <unit id="QSMS_Bd" name="scan_dialog.info_mode">
<segment state="translated"> <segment state="translated">
<source>scan_dialog.info_mode</source> <source>scan_dialog.info_mode</source>
@ -9512,6 +9518,24 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>Decoded information</target> <target>Decoded information</target>
</segment> </segment>
</unit> </unit>
<unit id="kQnodbA" name="label_scanner.target_found">
<segment>
<source>label_scanner.target_found</source>
<target>Item found in database</target>
</segment>
</unit>
<unit id="7Arfw2q" name="label_scanner.scan_result.title">
<segment>
<source>label_scanner.scan_result.title</source>
<target>Scan result</target>
</segment>
</unit>
<unit id="PTh4EK_" name="label_scanner.no_locations">
<segment>
<source>label_scanner.no_locations</source>
<target>Part is not stored at any location.</target>
</segment>
</unit>
<unit id="nmXQWcS" name="label_generator.edit_profiles"> <unit id="nmXQWcS" name="label_generator.edit_profiles">
<segment state="translated"> <segment state="translated">
<source>label_generator.edit_profiles</source> <source>label_generator.edit_profiles</source>
@ -12509,5 +12533,35 @@ Buerklin-API Authentication server:
<target>Last stocktake</target> <target>Last stocktake</target>
</segment> </segment>
</unit> </unit>
<unit id="aEgd0if" name="label_scanner.open">
<segment>
<source>label_scanner.open</source>
<target>View details</target>
</segment>
</unit>
<unit id="vw_0Qws" name="label_scanner.db_part_found">
<segment>
<source>label_scanner.db_part_found</source>
<target>Database [part] found for barcode</target>
</segment>
</unit>
<unit id="zntajcd" name="label_scanner.part_can_be_created">
<segment>
<source>label_scanner.part_can_be_created</source>
<target>[Part] can be created</target>
</segment>
</unit>
<unit id="cLTbd9w" name="label_scanner.part_can_be_created.help">
<segment>
<source>label_scanner.part_can_be_created.help</source>
<target>No matching [part] was found in the database, but you can create a new [part] based of this barcode.</target>
</segment>
</unit>
<unit id="FfHA3Yf" name="label_scanner.part_create_btn">
<segment>
<source>label_scanner.part_create_btn</source>
<target>Create [part] from barcode</target>
</segment>
</unit>
</file> </file>
</xliff> </xliff>