diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js
index 200dd2a7..b5a96834 100644
--- a/assets/controllers/pages/barcode_scan_controller.js
+++ b/assets/controllers/pages/barcode_scan_controller.js
@@ -21,17 +21,30 @@ 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,31 +58,135 @@ 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._submitting = false;
+ 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) {
+
+ async onScanSuccess(decodedText) {
+ if (!decodedText) return;
+
+ const normalized = String(decodedText).trim();
+ if (!normalized) return;
+
+ // scan once per barcode
+ if (normalized === this._lastDecodedText) return;
+
+ // If a request/submit is in-flight, ignore scans.
+ if (this._submitting) return;
+
+ // Mark as handled immediately (prevents spam even if callback fires repeatedly)
+ this._lastDecodedText = normalized;
+ this._submitting = true;
+
//Put our decoded Text into the input box
- document.getElementById('scan_dialog_input').value = decodedText;
- //Submit form
- document.getElementById('scan_dialog_form').requestSubmit();
+ const input = document.getElementById("scan_dialog_input");
+ if (input) input.value = decodedText;
+
+ const infoMode = !!document.getElementById("scan_dialog_info_mode")?.checked;
+
+ try {
+ const data = await this.lookup(normalized, infoMode);
+
+ // ok:false = transient junk decode; ignore without wiping UI
+ if (!data || data.ok !== true) {
+ this._lastDecodedText = ""; // allow retry
+ return;
+ }
+
+ // If info mode is OFF and part was found -> redirect
+ if (!infoMode && data.found && data.redirectUrl) {
+ window.location.assign(data.redirectUrl);
+ return;
+ }
+
+ // Otherwise render returned fragment HTML
+ if (typeof data.html === "string" && data.html !== "") {
+ const el = document.getElementById("scan-augmented-result");
+ if (el) el.innerHTML = data.html;
+ }
+ } catch (e) {
+ console.warn("[barcode_scan] lookup failed", e);
+ // allow retry on failure
+ this._lastDecodedText = "";
+ } finally {
+ this._submitting = false;
+ }
+ }
+
+
+ async lookup(decodedText, infoMode) {
+ const form = document.getElementById("scan_dialog_form");
+ if (!form) return { ok: false };
+
+ generateCsrfToken(form);
+
+ const mode =
+ document.querySelector('input[name="scan_dialog[mode]"]:checked')?.value ?? "";
+
+ const body = new URLSearchParams();
+ body.set("input", decodedText);
+ if (mode !== "") body.set("mode", mode);
+ body.set("info_mode", infoMode ? "1" : "0");
+
+ const headers = {
+ "Accept": "application/json",
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
+ ...generateCsrfHeaders(form),
+ };
+
+ const url = this.element.dataset.lookupUrl;
+ if (!url) throw new Error("Missing data-lookup-url on #reader-box");
+
+ const resp = await fetch(url, {
+ method: "POST",
+ headers,
+ body: body.toString(),
+ credentials: "same-origin",
+ });
+
+ if (!resp.ok) {
+ throw new Error(`lookup failed: HTTP ${resp.status}`);
+ }
+
+ return await resp.json();
}
}
diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php
index aebadd89..537be473 100644
--- a/src/Controller/ScanController.php
+++ b/src/Controller/ScanController.php
@@ -46,6 +46,8 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper;
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 +55,12 @@ 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;
/**
* @see \App\Tests\Controller\ScanControllerTest
@@ -60,9 +68,12 @@ 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 BarcodeRedirector $barcodeParser,
+ protected BarcodeScanHelper $barcodeNormalizer,
+ private readonly ProviderRegistry $providerRegistry,
+ private readonly PartInfoRetriever $infoRetriever,
+ ) {}
#[Route(path: '', name: 'scan_dialog')]
public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response
@@ -72,29 +83,33 @@ 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) {
- 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');
- }
- } else { //Otherwise retrieve infoModeData
- $infoModeData = $scan_result->getDecodedForInfoMode();
+ if ($input !== null && $input !== '') {
+ $mode = $form->isSubmitted() ? $form['mode']->getData() : null;
+ $infoMode = $form->isSubmitted() ? (bool) $form['info_mode']->getData() : false;
+ try {
+ $scan = $this->barcodeNormalizer->scanBarcodeContent((string) $input, $mode ?? null);
+
+ // If not in info mode, mimic “normal scan” behavior: redirect if possible.
+ if (!$infoMode) {
+ $url = $this->barcodeParser->getRedirectURL($scan);
+ return $this->redirect($url);
}
- } catch (InvalidArgumentException) {
- $this->addFlash('error', 'scan.format_unknown');
+
+ // Info mode fallback: render page with prefilled result
+ $infoModeData = $scan->getDecodedForInfoMode();
+
+ } catch (\Throwable $e) {
+ // Keep fallback user-friendly; avoid 500
+ $this->addFlash('warning', 'scan.format_unknown');
}
}
@@ -132,4 +147,232 @@ class ScanController extends AbstractController
return $this->redirectToRoute('homepage');
}
}
+
+ /**
+ * Builds a URL for creating a new part based on the barcode data
+ * @param object $scanResult
+ * @param string $locale
+ * @return string|null
+ */
+ private function buildCreateUrlForScanResult(object $scanResult, string $locale): ?string
+ {
+ // LCSC
+ if ($scanResult instanceof LCSCBarcodeScanResult) {
+ $lcscCode = $scanResult->getPC();
+ if (is_string($lcscCode) && $lcscCode !== '') {
+ return '/'
+ . rawurlencode($locale)
+ . '/part/from_info_provider/lcsc/'
+ . rawurlencode($lcscCode)
+ . '/create';
+ }
+ }
+
+ // Mouser / Digi-Key (EIGP114)
+ if ($scanResult instanceof EIGP114BarcodeScanResult) {
+ $vendor = $scanResult->guessBarcodeVendor();
+
+ // Mouser: use supplierPartNumber -> search provider -> provider_id
+ if ($vendor === 'mouser'
+ && is_string($scanResult->supplierPartNumber)
+ && $scanResult->supplierPartNumber !== ''
+ ) {
+ try {
+ $mouserProvider = $this->providerRegistry->getProviderByKey('mouser');
+
+ if (!$mouserProvider->isActive()) {
+ $this->addFlash('warning', 'Mouser provider is disabled / not configured.');
+ return null;
+ }
+ // Search Mouser using the MPN
+ $dtos = $this->infoRetriever->searchByKeyword(
+ keyword: $scanResult->supplierPartNumber,
+ providers: [$mouserProvider]
+ );
+
+ // If there are results, provider_id is MouserPartNumber (per MouserProvider.php)
+ $best = $dtos[0] ?? null;
+
+ if ($best !== null && is_string($best->provider_id) && $best->provider_id !== '') {
+ return '/'
+ . rawurlencode($locale)
+ . '/part/from_info_provider/mouser/'
+ . rawurlencode($best->provider_id)
+ . '/create';
+ }
+
+ $this->addFlash('warning', 'No Mouser match found for this MPN.');
+ return null;
+ } catch (\InvalidArgumentException) {
+ // provider key not found in registry
+ $this->addFlash('warning', 'Mouser provider is not installed/enabled.');
+ return null;
+ } catch (\Throwable $e) {
+ // Don’t break scanning UX if provider lookup fails
+ $this->addFlash('warning', 'Mouser lookup failed: ' . $e->getMessage());
+ return null;
+ }
+ }
+
+ // Digi-Key: can use customerPartNumber or supplierPartNumber directly
+ if ($vendor === 'digikey') {
+ try {
+ $provider = $this->providerRegistry->getProviderByKey('digikey');
+
+ if (!$provider->isActive()) {
+ $this->addFlash('warning', 'Digi-Key provider is disabled / not configured (API key missing).');
+ return null;
+ }
+
+ $id = $scanResult->customerPartNumber ?: $scanResult->supplierPartNumber;
+
+ if (is_string($id) && $id !== '') {
+ return '/'
+ . rawurlencode($locale)
+ . '/part/from_info_provider/digikey/'
+ . rawurlencode($id)
+ . '/create';
+ }
+ } catch (\InvalidArgumentException) {
+ $this->addFlash('warning', 'Digi-Key provider is not installed/enabled');
+ return null;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private function buildLocationsForPart(Part $part): array
+ {
+ $byLocationId = [];
+
+ foreach ($part->getPartLots() as $lot) {
+ $loc = $lot->getStorageLocation();
+ if ($loc === null) {
+ continue;
+ }
+
+ $locId = $loc->getID();
+ $qty = $lot->getAmount();
+
+ if (!isset($byLocationId[$locId])) {
+ $byLocationId[$locId] = [
+ 'breadcrumb' => $this->buildStorageBreadcrumb($loc),
+ 'qty' => $qty,
+ ];
+ } else {
+ $byLocationId[$locId]['qty'] += $qty;
+ }
+ }
+
+ return array_values($byLocationId);
+ }
+
+ private function buildStorageBreadcrumb(StorageLocation $loc): array
+ {
+ $items = [];
+ $cur = $loc;
+
+ // 20 is the overflow limit in src/Entity/Base/AbstractStructuralDBElement.php line ~273
+ for ($i = 0; $i < 20 && $cur !== null; $i++) {
+ $items[] = [
+ 'name' => $cur->getName(),
+ 'url' => $this->generateUrl('part_list_store_location', ['id' => $cur->getID()]),
+ ];
+
+ $parent = $cur->getParent(); // inherited from AbstractStructuralDBElement
+ $cur = ($parent instanceof StorageLocation) ? $parent : null;
+ }
+
+ return array_reverse($items);
+ }
+
+ /**
+ * Provides XHR endpoint for looking up barcode information and return JSON response
+ * @param Request $request
+ * @return JsonResponse
+ */
+ #[Route(path: '/lookup', name: 'scan_lookup', methods: ['POST'])]
+ public function lookup(Request $request): JsonResponse
+ {
+ $this->denyAccessUnlessGranted('@tools.label_scanner');
+
+ $input = trim((string) $request->request->get('input', ''));
+ $mode = (string) ($request->request->get('mode') ?? '');
+ $infoMode = (bool) filter_var($request->request->get('info_mode', false), FILTER_VALIDATE_BOOL);
+ $locale = $request->getLocale();
+
+ if ($input === '') {
+ return new JsonResponse(['ok' => false], 200);
+ }
+
+ $modeEnum = null;
+ if ($mode !== '') {
+ $i = (int) $mode;
+ $cases = BarcodeSourceType::cases();
+ $modeEnum = $cases[$i] ?? null; // null if out of range
+ }
+
+ try {
+ $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $modeEnum);
+ } catch (InvalidArgumentException) {
+ // Camera sometimes produces garbage decodes for a frame; ignore those.
+ return new JsonResponse(['ok' => false], 200);
+ }
+
+ $decoded = $scan->getDecodedForInfoMode();
+
+ // Determine if this barcode resolves to *anything* (part, lot->part, storelocation)
+ $redirectUrl = null;
+ $targetFound = false;
+
+ try {
+ $redirectUrl = $this->barcodeParser->getRedirectURL($scan);
+ $targetFound = true;
+ } catch (EntityNotFoundException) {
+ $targetFound = false;
+ }
+
+ // Only resolve Part for part-like targets. Storelocation scans should remain null here.
+ $part = null;
+ $partName = null;
+ $partUrl = null;
+ $locations = [];
+
+ if ($targetFound) {
+ $part = $this->barcodeParser->resolvePartOrNull($scan);
+
+ if ($part instanceof Part) {
+ $partName = $part->getName();
+ $partUrl = $this->generateUrl('app_part_show', ['id' => $part->getID()]);
+ $locations = $this->buildLocationsForPart($part);
+ }
+ }
+
+ // Create link only when NOT found (vendor codes)
+ $createUrl = null;
+ if (!$targetFound) {
+ $createUrl = $this->buildCreateUrlForScanResult($scan, $locale);
+ }
+
+ // Render fragment (use openUrl for universal "Open" link)
+ $html = $this->renderView('label_system/scanner/augmented_result.html.twig', [
+ 'decoded' => $decoded,
+ 'found' => $targetFound,
+ 'openUrl' => $redirectUrl,
+ 'partName' => $partName,
+ 'partUrl' => $partUrl,
+ 'locations' => $locations,
+ 'createUrl' => $createUrl,
+ ]);
+
+ return new JsonResponse([
+ 'ok' => true,
+ 'found' => $targetFound,
+ 'redirectUrl' => $redirectUrl, // client redirects only when infoMode=false
+ 'html' => $html,
+ 'infoMode' => $infoMode,
+ ], 200);
+ }
}
diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php
index 13ff8e6f..0a67467f 100644
--- a/src/Form/LabelSystem/ScanDialogType.php
+++ b/src/Form/LabelSystem/ScanDialogType.php
@@ -75,7 +75,8 @@ class ScanDialogType extends AbstractType
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
- BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp'
+ BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp',
+ BarcodeSourceType::LCSC => 'scan_dialog.mode.lcsc',
},
]);
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php
index 2de7c035..3ce25071 100644
--- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php
@@ -45,6 +45,7 @@ use App\Entity\LabelSystem\LabelSupportedElement;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
+use App\Repository\Parts\PartRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException;
@@ -77,6 +78,10 @@ final class BarcodeRedirector
return $this->getURLVendorBarcode($barcodeScan);
}
+ if ($barcodeScan instanceof LCSCBarcodeScanResult) {
+ return $this->getURLLCSCBarcode($barcodeScan);
+ }
+
throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan));
}
@@ -102,6 +107,54 @@ final class BarcodeRedirector
}
}
+ /**
+ * Gets the URL to a part from a scan of the LCSC Barcode
+ */
+ private function getURLLCSCBarcode(LCSCBarcodeScanResult $barcodeScan): string
+ {
+ $part = $this->getPartFromLCSC($barcodeScan);
+ return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
+ }
+
+ /**
+ * 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 getPartFromLCSC(LCSCBarcodeScanResult $barcodeScan): Part
+ {
+ // Try LCSC code (pc) as provider id if available
+ $pc = $barcodeScan->getPC(); // e.g. C138033
+ if ($pc) {
+ $qb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
+ $qb->where($qb->expr()->like('LOWER(part.providerReference.provider_id)', 'LOWER(:vendor_id)'));
+ $qb->setParameter('vendor_id', $pc);
+ $results = $qb->getQuery()->getResult();
+ if ($results) {
+ return $results[0];
+ }
+ }
+
+ // Fallback to MPN (pm)
+ $pm = $barcodeScan->getPM(); // e.g. RC0402FR-071ML
+ if (!$pm) {
+ throw new EntityNotFoundException();
+ }
+
+ $mpnQb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
+ $mpnQb->where($mpnQb->expr()->like('LOWER(part.manufacturer_product_number)', 'LOWER(:mpn)'));
+ $mpnQb->setParameter('mpn', $pm);
+
+ $results = $mpnQb->getQuery()->getResult();
+ if ($results) {
+ return $results[0];
+ }
+
+ throw new EntityNotFoundException();
+ }
+
/**
* Gets the URL to a part from a scan of a Vendor Barcode
*/
@@ -163,4 +216,46 @@ final class BarcodeRedirector
}
throw new EntityNotFoundException();
}
+
+ public function resolvePartOrNull(BarcodeScanResultInterface $barcodeScan): ?Part
+ {
+ try {
+ if ($barcodeScan instanceof LocalBarcodeScanResult) {
+ return $this->resolvePartFromLocal($barcodeScan);
+ }
+
+ if ($barcodeScan instanceof EIGP114BarcodeScanResult) {
+ return $this->getPartFromVendor($barcodeScan);
+ }
+
+ if ($barcodeScan instanceof LCSCBarcodeScanResult) {
+ return $this->getPartFromLCSC($barcodeScan);
+ }
+
+ return null;
+ } catch (EntityNotFoundException) {
+ return null;
+ }
+ }
+
+ private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): ?Part
+ {
+ switch ($barcodeScan->target_type) {
+ case LabelSupportedElement::PART:
+ $part = $this->em->find(Part::class, $barcodeScan->target_id);
+ return $part instanceof Part ? $part : null;
+
+ case LabelSupportedElement::PART_LOT:
+ $lot = $this->em->find(PartLot::class, $barcodeScan->target_id);
+ if (!$lot instanceof PartLot) {
+ return null;
+ }
+ return $lot->getPart();
+
+ default:
+ // STORELOCATION etc. doesn't map to a Part
+ return null;
+ }
+ }
+
}
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php
index e5930b36..c9cce95a 100644
--- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php
@@ -92,6 +92,9 @@ final class BarcodeScanHelper
if ($type === BarcodeSourceType::EIGP114) {
return $this->parseEIGP114Barcode($input);
}
+ if ($type === BarcodeSourceType::LCSC) {
+ return $this->parseLCSCBarcode($input);
+ }
//Null means auto and we try the different formats
$result = $this->parseInternalBarcode($input);
@@ -117,6 +120,11 @@ final class BarcodeScanHelper
return $result;
}
+ // Try LCSC barcode
+ if (LCSCBarcodeScanResult::looksLike($input)) {
+ return $this->parseLCSCBarcode($input);
+ }
+
throw new InvalidArgumentException('Unknown barcode');
}
@@ -125,6 +133,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/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php
index 40f707de..8f1dc72e 100644
--- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php
@@ -42,4 +42,7 @@ enum BarcodeSourceType
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
*/
case EIGP114;
-}
\ No newline at end of file
+
+ /** For LCSC.com formatted QR codes */
+ case LCSC;
+}
diff --git a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php
new file mode 100644
index 00000000..236bad48
--- /dev/null
+++ b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php
@@ -0,0 +1,130 @@
+ $fields
+ */
+ public function __construct(
+ public readonly array $fields,
+ public readonly string $raw_input,
+ ) {}
+
+ public function getSourceType(): BarcodeSourceType
+ {
+ return BarcodeSourceType::LCSC;
+ }
+
+ /**
+ * @return string|null The manufactures part number
+ */
+ public function getPM(): ?string
+ {
+ $v = $this->fields['pm'] ?? null;
+ $v = $v !== null ? trim($v) : null;
+ return ($v === '') ? null : $v;
+ }
+
+ /**
+ * @return string|null The lcsc.com part number
+ */
+ public function getPC(): ?string
+ {
+ $v = $this->fields['pc'] ?? null;
+ $v = $v !== null ? trim($v) : null;
+ return ($v === '') ? null : $v;
+ }
+
+ /**
+ * @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->getPM() ?? '',
+ 'LCSC code (pc)' => $this->getPC() ?? '',
+ 'Qty' => $this->fields['qty'] ?? '',
+ 'Order No (on)' => $this->fields['on'] ?? '',
+ 'Pick Batch (pbn)' => $this->fields['pbn'] ?? '',
+ 'Warehouse (wc)' => $this->fields['wc'] ?? '',
+ 'Country/Channel (cc)' => $this->fields['cc'] ?? '',
+ ];
+ }
+
+ /**
+ * 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 looksLike(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::looksLike($raw)) {
+ throw new InvalidArgumentException('Not an LCSC barcode');
+ }
+
+ $inner = trim($raw);
+ $inner = substr($inner, 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/templates/label_system/scanner/augmented_result.html.twig b/templates/label_system/scanner/augmented_result.html.twig
new file mode 100644
index 00000000..c31e336a
--- /dev/null
+++ b/templates/label_system/scanner/augmented_result.html.twig
@@ -0,0 +1,97 @@
+{% if decoded is not empty %}
+
+
+
+
+ {% if found and partName %}
+ {% trans %}label_scanner.part_info.title{% endtrans %}
+ {% else %}
+ {% trans %}label_scanner.scan_result.title{% endtrans %}
+ {% endif %}
+
+
+
+ {% if createUrl %}
+
+
+
+ {% endif %}
+
+
+ {% if found %}
+
+
+ {% if partName %}
+ {{ partName }}
+ {% else %}
+ {% trans %}label_scanner.target_found{% endtrans %}
+ {% endif %}
+
+
+ {% if openUrl %}
+
+ {% trans %}open{% endtrans %}
+
+ {% endif %}
+
+
+ {% if partName %}
+ {% if locations is not empty %}
+
+
+
+ | {% trans %}part_lots.storage_location{% endtrans %} |
+
+ {% trans %}part_lots.amount{% endtrans %}
+ |
+
+
+
+ {% for loc in locations %}
+
+ |
+
+ |
+
+ {% if loc.qty is not null %}{{ loc.qty }}{% else %}—{% endif %}
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+ {% trans %}label_scanner.no_locations{% endtrans %}
+ {% endif %}
+ {% endif %}
+ {% else %}
+
+ {% trans %}label_scanner.qr_part_no_found{% endtrans %}
+
+ {% endif %}
+
+ {# Decoded barcode fields #}
+
+
+ {% for key, value in decoded %}
+
+ | {{ key }} |
+ {{ value }} |
+
+ {% endfor %}
+
+
+
+ {# 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..ed657839 100644
--- a/templates/label_system/scanner/scanner.html.twig
+++ b/templates/label_system/scanner/scanner.html.twig
@@ -12,13 +12,16 @@
+
+
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
{{ form_end(form) }}
@@ -26,7 +29,16 @@
{% if infoModeData %}
- {% trans %}label_scanner.decoded_info.title{% endtrans %}
+
+
{% trans %}label_scanner.decoded_info.title{% endtrans %}
+
+ {% if createUrl %}
+
+
+
+ {% endif %}
+
diff --git a/tests/Controller/ScanControllerTest.php b/tests/Controller/ScanControllerTest.php
index 98992e09..e907e739 100644
--- a/tests/Controller/ScanControllerTest.php
+++ b/tests/Controller/ScanControllerTest.php
@@ -51,4 +51,59 @@ class ScanControllerTest extends WebTestCase
$this->client->request('GET', '/scan/part/1');
$this->assertResponseRedirects('/en/part/1');
}
+
+ public function testLookupReturnsFoundOnKnownPart(): void
+ {
+ $this->client->request('POST', '/en/scan/lookup', [
+ 'input' => '0000001',
+ 'mode' => '',
+ 'info_mode' => 'true',
+ ]);
+
+ $this->assertResponseIsSuccessful();
+
+ $data = json_decode((string) $this->client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
+
+ $this->assertTrue($data['ok']);
+ $this->assertTrue($data['found']);
+ $this->assertSame('/en/part/1', $data['redirectUrl']);
+ $this->assertTrue($data['infoMode']);
+ $this->assertIsString($data['html']);
+ $this->assertNotSame('', trim($data['html']));
+ }
+
+ public function testLookupReturnsNotFoundOnUnknownPart(): void
+ {
+ $this->client->request('POST', '/en/scan/lookup', [
+ // Use a valid LCSC barcode
+ 'input' => '{pbn:PICK2407080035,on:WM2407080118,pc:C365735,pm:ES8316,qty:12,mc:,cc:1,pdi:120044290,hp:null,wc:ZH}',
+ 'mode' => '',
+ 'info_mode' => 'true',
+ ]);
+
+ $this->assertResponseIsSuccessful();
+
+ $data = json_decode((string)$this->client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
+
+ $this->assertTrue($data['ok']);
+ $this->assertFalse($data['found']);
+ $this->assertSame(null, $data['redirectUrl']);
+ $this->assertTrue($data['infoMode']);
+ $this->assertIsString($data['html']);
+ $this->assertNotSame('', trim($data['html']));
+ }
+
+ public function testLookupReturnsFalseOnGarbageInput(): void
+ {
+ $this->client->request('POST', '/en/scan/lookup', [
+ 'input' => 'not-a-real-barcode',
+ 'mode' => '',
+ 'info_mode' => 'false',
+ ]);
+
+ $this->assertResponseIsSuccessful();
+
+ $data = json_decode((string) $this->client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
+ $this->assertFalse($data['ok']);
+ }
}
diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php
index c40e141d..563f23cf 100644
--- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php
+++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php
@@ -49,6 +49,11 @@ 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 BarcodeRedirectorTest extends KernelTestCase
{
@@ -82,4 +87,74 @@ final class BarcodeRedirectorTest extends KernelTestCase
$this->service->getRedirectURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT,
12_345_678, BarcodeSourceType::INTERNAL));
}
+
+ public function testGetRedirectURLThrowsOnUnknownScanType(): void
+ {
+ $unknown = new class implements BarcodeScanResultInterface {
+ public function getDecodedForInfoMode(): array
+ {
+ return [];
+ }
+ };
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->getRedirectURL($unknown);
+ }
+
+ public function testEIGPBarcodeWithoutSupplierPartNumberThrowsEntityNotFound(): void
+ {
+ $scan = new EIGP114BarcodeScanResult([]);
+
+ $this->expectException(EntityNotFoundException::class);
+ $this->service->getRedirectURL($scan);
+ }
+
+ public function testEIGPBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void
+ {
+ $scan = new EIGP114BarcodeScanResult([]);
+
+ $this->assertNull($this->service->resolvePartOrNull($scan));
+ }
+
+ public function testLCSCBarcodeMissingPmThrowsEntityNotFound(): void
+ {
+ // pc present but no pm => getPartFromLCSC() will throw EntityNotFoundException
+ // because it falls back to PM when PC doesn't match anything.
+ $scan = new LCSCBarcodeScanResult(
+ fields: ['pc' => 'C0000000', 'pm' => ''], // pm becomes null via getPM()
+ raw_input: '{pc:C0000000,pm:}'
+ );
+
+ $this->expectException(EntityNotFoundException::class);
+ $this->service->getRedirectURL($scan);
+ }
+
+ public function testLCSCBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void
+ {
+ $scan = new LCSCBarcodeScanResult(
+ fields: ['pc' => 'C0000000', 'pm' => ''],
+ raw_input: '{pc:C0000000,pm:}'
+ );
+
+ $this->assertNull($this->service->resolvePartOrNull($scan));
+ }
+
+ public function testLCSCParseRejectsNonLCSCFormat(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ LCSCBarcodeScanResult::parse('not-an-lcsc-barcode');
+ }
+
+ public function testLCSCParseExtractsFields(): void
+ {
+ $scan = LCSCBarcodeScanResult::parse('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}');
+
+ $this->assertSame('RC0402FR-071ML', $scan->getPM());
+ $this->assertSame('C138033', $scan->getPC());
+
+ $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/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php
index fcea7730..6655f510 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;
class BarcodeScanHelperTest extends WebTestCase
{
@@ -124,6 +125,14 @@ 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 @@ 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->getPC());
+ $this->assertSame('RC0402FR-071ML', $result->getPM());
+ }
+
+ 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->getPC());
+ $this->assertSame('RC0402FR-071ML', $result->getPM());
+ }
+
+ public function testLcscExplicitTypeRejectsNonLcsc(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ $this->service->scanBarcodeContent('not-an-lcsc', BarcodeSourceType::LCSC);
+ }
}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index f7f10146..8ae6b777 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -12027,6 +12027,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
@@ -12039,6 +12045,36 @@ Please note, that you can not impersonate a disabled user. If you try you will g
Decoded information
+
+
+ label_scanner.part_info.title
+ Part information
+
+
+
+
+ label_scanner.target_found
+ Item Found
+
+
+
+
+ label_scanner.scan_result.title
+ Scan result
+
+
+
+
+ label_scanner.no_locations
+ Part is not stored at any locations
+
+
+
+
+ label_scanner.qr_part_no_found
+ No part found for scanned barcode, click button above to Create Part
+
+
label_generator.edit_profiles
@@ -14262,7 +14298,7 @@ Please note that this system is currently experimental, and the synonyms defined
settings.ips.buerklin.help
- Buerklin-API access limits:
+ Buerklin-API access limits:
100 requests/minute per IP address
Buerklin-API Authentication server:
10 requests/minute per IP address