From 2e793e89fab30e854ef8f98ecfa8bd5d5065875c Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 13:48:59 +1300 Subject: [PATCH 01/10] added handling of LCSC barcode decoding and part loading on Label Scanner --- src/Form/LabelSystem/ScanDialogType.php | 3 +- .../BarcodeScanner/BarcodeRedirector.php | 53 ++++++++ .../BarcodeScanner/BarcodeScanHelper.php | 13 ++ .../BarcodeScanner/BarcodeSourceType.php | 5 +- .../BarcodeScanner/LCSCBarcodeScanResult.php | 127 ++++++++++++++++++ translations/messages.en.xlf | 6 + 6 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php 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..1364e6c1 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 */ 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..9a87951f --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php @@ -0,0 +1,127 @@ + $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))); + + 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/translations/messages.en.xlf b/translations/messages.en.xlf index f7f10146..0e79a6ed 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 From 4325f9e0e617af207586883043b7558a6d156a14 Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 13:49:26 +1300 Subject: [PATCH 02/10] 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 --- .../pages/barcode_scan_controller.js | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index 200dd2a7..29db5de5 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -23,15 +23,14 @@ import {Controller} from "@hotwired/stimulus"; import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode"; /* stimulusFetch: 'lazy' */ + export default class extends Controller { - - //codeReader = null; - _scanner = null; - + _submitting = false; connect() { - console.log('Init Scanner'); + // Prevent double init if connect fires twice + if (this._scanner) return; //This function ensures, that the qrbox is 70% of the total viewport let qrboxFunction = function(viewfinderWidth, viewfinderHeight) { @@ -45,8 +44,8 @@ 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, { @@ -54,22 +53,49 @@ export default class extends Controller { qrbox: qrboxFunction, 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; + + 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 (this._submitting) return; + this._submitting = true; + //Put our decoded Text into the input box - document.getElementById('scan_dialog_input').value = decodedText; + const input = document.getElementById("scan_dialog_input"); + if (input) input.value = decodedText; + + // Stop scanner BEFORE submitting to avoid camera transition races + try { + if (this._scanner?.clear) { + await this._scanner.clear(); + } + } catch (_) { + // ignore + } finally { + this._scanner = null; + } + //Submit form - document.getElementById('scan_dialog_form').requestSubmit(); + document.getElementById("scan_dialog_form")?.requestSubmit(); } } From f3cd32cc91325d066aa3d134691fc6a8d905d9c5 Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 13:59:26 +1300 Subject: [PATCH 03/10] 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 --- src/Controller/ScanController.php | 105 +++++++++++++++++- .../label_system/scanner/scanner.html.twig | 11 +- 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index aebadd89..63787767 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,8 @@ 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; /** * @see \App\Tests\Controller\ScanControllerTest @@ -60,9 +64,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,26 +79,113 @@ class ScanController extends AbstractController $form = $this->createForm(ScanDialogType::class); $form->handleRequest($request); + $mode = null; if ($input === null && $form->isSubmitted() && $form->isValid()) { $input = $form['input']->getData(); $mode = $form['mode']->getData(); } $infoModeData = null; + $createUrl = 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 { + // redirect user to part page return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); } catch (EntityNotFoundException) { - $this->addFlash('success', 'scan.qr_not_found'); + // Fallback: show decoded info like info-mode as part does not exist + $infoModeData = $scan_result->getDecodedForInfoMode(); + + $locale = $request->getLocale(); + + // If it's an LCSC scan, offer "create part" link + if ($scan_result instanceof LCSCBarcodeScanResult) { + $lcscCode = $scan_result->getPC(); + + if (is_string($lcscCode) && $lcscCode !== '') { + // Prefer generating a relative URL; browser will use current host + $createUrl = "/{$locale}/part/from_info_provider/lcsc/{$lcscCode}/create"; + } + } + + // If EIGP114 (Mouser / Digi-Key), offer "create part" link + if ($scan_result instanceof EIGP114BarcodeScanResult) { + // Use guessed vendor and supplierPartNumber. + $vendor = $scan_result->guessBarcodeVendor(); + + if ($vendor === 'mouser' && is_string($scan_result->supplierPartNumber) + && $scan_result->supplierPartNumber !== '') { + + try { + $mouserProvider = $this->providerRegistry->getProviderByKey('mouser'); + + if (!$mouserProvider->isActive()) { + $this->addFlash('warning', 'Mouser provider is disabled / not configured.'); + } else { + // Search Mouser using the MPN + $dtos = $this->infoRetriever->searchByKeyword( + keyword: $scan_result->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 !== '') { + $createUrl = '/' + . rawurlencode($locale) + . '/part/from_info_provider/mouser/' + . rawurlencode($best->provider_id) + . '/create'; + } else { + $this->addFlash('warning', 'No Mouser match found for this MPN.'); + } + } + } catch (\InvalidArgumentException $e) { + // provider key not found in registry + $this->addFlash('warning', 'Mouser provider is not installed/enabled.'); + } catch (\Throwable $e) { + // Don’t break scanning UX if provider lookup fails + $this->addFlash('warning', 'Mouser lookup failed: ' . $e->getMessage()); + } + } + + // Digikey can keep using customerPartNumber if present (it is in their barcode) + 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).'); + } else { + $id = $scan_result->customerPartNumber ?: $scan_result->supplierPartNumber; + + if (is_string($id) && $id !== '') { + $createUrl = '/' + . rawurlencode($locale) + . '/part/from_info_provider/digikey/' + . rawurlencode($id) + . '/create'; + } + } + } catch (\InvalidArgumentException $e) { + $this->addFlash('warning', 'Digi-Key provider is not installed/enabled'); + } + } + } + + if ($createUrl === null) { + $this->addFlash('warning', 'scan.qr_not_found'); + } } } else { //Otherwise retrieve infoModeData $infoModeData = $scan_result->getDecodedForInfoMode(); - } } catch (InvalidArgumentException) { $this->addFlash('error', 'scan.format_unknown'); @@ -101,6 +195,7 @@ class ScanController extends AbstractController return $this->render('label_system/scanner/scanner.html.twig', [ 'form' => $form, 'infoModeData' => $infoModeData, + 'createUrl' => $createUrl, ]); } diff --git a/templates/label_system/scanner/scanner.html.twig b/templates/label_system/scanner/scanner.html.twig index 1f978a9b..ef293d1a 100644 --- a/templates/label_system/scanner/scanner.html.twig +++ b/templates/label_system/scanner/scanner.html.twig @@ -26,7 +26,16 @@ {% if infoModeData %}
-

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

+
+

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

+ + {% if createUrl %} + + + + {% endif %} +
From d6eae90e04d2790a12c59e3ae022dab89491cc3c Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 22:42:20 +1300 Subject: [PATCH 04/10] added augmented mode to label scanner to use vendor labels for part lookup to see part storage location quickly --- .../pages/barcode_scan_controller.js | 106 ++++++- src/Controller/ScanController.php | 283 +++++++++++++----- src/Form/LabelSystem/ScanDialogType.php | 5 + .../BarcodeScanner/BarcodeRedirector.php | 42 +++ .../scanner/augmented_result.html.twig | 66 ++++ .../label_system/scanner/scanner.html.twig | 5 +- translations/messages.en.xlf | 20 +- 7 files changed, 445 insertions(+), 82 deletions(-) create mode 100644 templates/label_system/scanner/augmented_result.html.twig diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index 29db5de5..e12abce8 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -21,17 +21,21 @@ 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 { _scanner = null; _submitting = false; + _lastDecodedText = ""; connect() { // Prevent double init if connect fires twice if (this._scanner) return; + this.bindModeToggles(); + //This function ensures, that the qrbox is 70% of the total viewport let qrboxFunction = function(viewfinderWidth, viewfinderHeight) { let minEdgePercentage = 0.7; // 70% @@ -65,6 +69,8 @@ export default class extends Controller { const scanner = this._scanner; this._scanner = null; this._submitting = false; + this._lastDecodedText = ""; + this.unbindModeToggles(); if (!scanner) return; @@ -76,15 +82,80 @@ export default class extends Controller { } } + /** + * Add events to Mode checkboxes so they both can't be selected at the same time + */ + bindModeToggles() { + const info = document.getElementById("scan_dialog_info_mode"); + const aug = document.getElementById("scan_dialog_augmented_mode"); + if (!info || !aug) return; + + const onInfoChange = () => { + if (info.checked) aug.checked = false; + }; + const onAugChange = () => { + if (aug.checked) info.checked = false; + }; + + info.addEventListener("change", onInfoChange); + aug.addEventListener("change", onAugChange); + + // Save references so we can remove listeners on disconnect + this._onInfoChange = onInfoChange; + this._onAugChange = onAugChange; + } + + unbindModeToggles() { + const info = document.getElementById("scan_dialog_info_mode"); + const aug = document.getElementById("scan_dialog_augmented_mode"); + if (!info || !aug) return; + + if (this._onInfoChange) info.removeEventListener("change", this._onInfoChange); + if (this._onAugChange) aug.removeEventListener("change", this._onAugChange); + + this._onInfoChange = null; + this._onAugChange = null; + } + + + async onScanSuccess(decodedText) { + if (!decodedText) return; + + const normalized = String(decodedText).trim(); + + // If we already handled this exact barcode and it's still showing, ignore. + 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 const input = document.getElementById("scan_dialog_input"); if (input) input.value = decodedText; - // Stop scanner BEFORE submitting to avoid camera transition races + const augmented = !!document.getElementById("scan_dialog_augmented_mode")?.checked; + + // If augmented mode: do NOT submit the form. + if (augmented) { + try { + await this.lookupAndRender(decodedText); + } catch (e) { + console.warn("[barcode_scan] augmented lookup failed", e); + // Allow retry on failure by clearing last decoded text + this._lastDecodedText = ""; + } finally { + // allow scanning again + this._submitting = false; + } + return; + } + + // Non-augmented: Stop scanner BEFORE submitting to avoid camera transition races try { if (this._scanner?.clear) { await this._scanner.clear(); @@ -98,4 +169,37 @@ export default class extends Controller { //Submit form document.getElementById("scan_dialog_form")?.requestSubmit(); } + + async lookupAndRender(decodedText) { + const form = document.getElementById("scan_dialog_form"); + if (!form) return; + + // Ensure the hidden csrf field has been converted from placeholder -> real token + cookie set + 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); + + const headers = { + "Accept": "text/html", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + ...generateCsrfHeaders(form), // adds the special CSRF header Symfony expects (if enabled) + }; + + const resp = await fetch(this.element.dataset.augmentedUrl, { + method: "POST", + headers, + body: body.toString(), + credentials: "same-origin", + }); + + const html = await resp.text(); + + const el = document.getElementById("scan-augmented-result"); + if (el) el.innerHTML = html; + } } diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 63787767..e9f6bafa 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -57,6 +57,10 @@ 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 @@ -98,87 +102,10 @@ class ScanController extends AbstractController // redirect user to part page return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); } catch (EntityNotFoundException) { - // Fallback: show decoded info like info-mode as part does not exist + // Part not found -> show decoded info + optional "create part" link $infoModeData = $scan_result->getDecodedForInfoMode(); - $locale = $request->getLocale(); - - // If it's an LCSC scan, offer "create part" link - if ($scan_result instanceof LCSCBarcodeScanResult) { - $lcscCode = $scan_result->getPC(); - - if (is_string($lcscCode) && $lcscCode !== '') { - // Prefer generating a relative URL; browser will use current host - $createUrl = "/{$locale}/part/from_info_provider/lcsc/{$lcscCode}/create"; - } - } - - // If EIGP114 (Mouser / Digi-Key), offer "create part" link - if ($scan_result instanceof EIGP114BarcodeScanResult) { - // Use guessed vendor and supplierPartNumber. - $vendor = $scan_result->guessBarcodeVendor(); - - if ($vendor === 'mouser' && is_string($scan_result->supplierPartNumber) - && $scan_result->supplierPartNumber !== '') { - - try { - $mouserProvider = $this->providerRegistry->getProviderByKey('mouser'); - - if (!$mouserProvider->isActive()) { - $this->addFlash('warning', 'Mouser provider is disabled / not configured.'); - } else { - // Search Mouser using the MPN - $dtos = $this->infoRetriever->searchByKeyword( - keyword: $scan_result->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 !== '') { - $createUrl = '/' - . rawurlencode($locale) - . '/part/from_info_provider/mouser/' - . rawurlencode($best->provider_id) - . '/create'; - } else { - $this->addFlash('warning', 'No Mouser match found for this MPN.'); - } - } - } catch (\InvalidArgumentException $e) { - // provider key not found in registry - $this->addFlash('warning', 'Mouser provider is not installed/enabled.'); - } catch (\Throwable $e) { - // Don’t break scanning UX if provider lookup fails - $this->addFlash('warning', 'Mouser lookup failed: ' . $e->getMessage()); - } - } - - // Digikey can keep using customerPartNumber if present (it is in their barcode) - 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).'); - } else { - $id = $scan_result->customerPartNumber ?: $scan_result->supplierPartNumber; - - if (is_string($id) && $id !== '') { - $createUrl = '/' - . rawurlencode($locale) - . '/part/from_info_provider/digikey/' - . rawurlencode($id) - . '/create'; - } - } - } catch (\InvalidArgumentException $e) { - $this->addFlash('warning', 'Digi-Key provider is not installed/enabled'); - } - } - } + $createUrl = $this->buildCreateUrlForScanResult($scan_result, $request->getLocale()); if ($createUrl === null) { $this->addFlash('warning', 'scan.qr_not_found'); @@ -227,4 +154,202 @@ 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); + } + + + + + #[Route(path: '/augmented', name: 'scan_augmented', methods: ['POST'])] + public function augmented(Request $request): Response + { + $this->denyAccessUnlessGranted('@tools.label_scanner'); + + $input = (string) $request->request->get('input', ''); + $mode = $request->request->get('mode'); // string|null + + if ($input === '') { + // Return empty fragment or an error fragment; your choice + return new Response('', 200); + } + + $modeEnum = null; + if ($mode !== null && $mode !== '') { + // Radio values are enum integers in your form + $modeEnum = BarcodeSourceType::from((int) $mode); + } + + $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $modeEnum); + $decoded = $scan->getDecodedForInfoMode(); + + $locale = $request->getLocale(); + $part = $this->barcodeParser->resolvePartOrNull($scan); + + $found = $part !== null; + $partName = null; + $partUrl = null; + $locations = []; + $createUrl = null; + + if ($found) { + $partName = $part->getName(); + + // This is the same route BarcodeRedirector uses + $partUrl = $this->generateUrl('app_part_show', ['id' => $part->getID()]); + + // Build locations (see below) + $locations = $this->buildLocationsForPart($part); + + } else { + // Reuse your centralized create-url logic (the helper you already extracted) + $createUrl = $this->buildCreateUrlForScanResult($scan, $locale); + } + + return $this->render('label_system/scanner/augmented_result.html.twig', [ + 'decoded' => $decoded, + 'found' => $found, + 'partName' => $partName, + 'partUrl' => $partUrl, + 'locations' => $locations, + 'createUrl' => $createUrl, + ]); + } } diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php index 0a67467f..d0d609d3 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -85,6 +85,11 @@ class ScanDialogType extends AbstractType 'required' => false, ]); + $builder->add('augmented_mode', CheckboxType::class, [ + 'label' => 'scan_dialog.augmented_mode', + 'required' => false, + ]); + $builder->add('submit', SubmitType::class, [ 'label' => 'scan_dialog.submit', ]); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php index 1364e6c1..3ce25071 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php @@ -216,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/templates/label_system/scanner/augmented_result.html.twig b/templates/label_system/scanner/augmented_result.html.twig new file mode 100644 index 00000000..20eec82b --- /dev/null +++ b/templates/label_system/scanner/augmented_result.html.twig @@ -0,0 +1,66 @@ +{% if decoded is not empty %} +
+ +
+

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

+ + {% if createUrl %} + + + + {% endif %} +
+ + {% if found %} +
+ {{ partName }} + {% if partUrl %} + — {% trans %}open{% endtrans %} + {% endif %} +
+ + {% if locations is not empty %} +
+ + + + + + + + {% for loc in locations %} + + + + + {% endfor %} + +
{% trans %}part_lots.storage_location{% endtrans %} + {% trans %}part_lots.amount{% endtrans %} +
+ + + {% if loc.qty is not null %}{{ loc.qty }}{% else %}{% endif %} +
+ {% else %} +
{% trans %}label_scanner.no_locations{% endtrans %}
+ {% endif %} + {% else %} +
+ {% trans %}scan.qr_not_found{% endtrans %} +
+ {% endif %} + +
+
+
+
+ +{% endif %} diff --git a/templates/label_system/scanner/scanner.html.twig b/templates/label_system/scanner/scanner.html.twig index ef293d1a..ffac8234 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) }} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 0e79a6ed..482d0c15 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12039,12 +12039,30 @@ Please note, that you can not impersonate a disabled user. If you try you will g Info mode (Decode barcode and show its contents, but do not redirect to part)
+ + + scan_dialog.augmented_mode + Augmented mode (Decode barcode, look up, and display database part information) + + label_scanner.decoded_info.title Decoded information + + + label_scanner.part_info.title + Part information + + + + + label_scanner.no_locations + Part is not stored at any locations + + label_generator.edit_profiles @@ -14268,7 +14286,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 From 4eb96b03671271c5949dd127bc24acf45756df41 Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 23:31:21 +1300 Subject: [PATCH 05/10] shrink camera height on mobile so augmented information can been viewed onscreen --- assets/controllers/pages/barcode_scan_controller.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index e12abce8..c8aeac80 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -36,6 +36,8 @@ export default class extends Controller { this.bindModeToggles(); + 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) { let minEdgePercentage = 0.7; // 70% @@ -55,6 +57,8 @@ export default class extends Controller { 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, From 03d1fc55ceb52e59761e06e3611658fc7602e52d Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 23:32:04 +1300 Subject: [PATCH 06/10] handle momentarily bad reads from qrcode library --- src/Controller/ScanController.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index e9f6bafa..cc5e0bf2 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -295,9 +295,6 @@ class ScanController extends AbstractController return array_reverse($items); } - - - #[Route(path: '/augmented', name: 'scan_augmented', methods: ['POST'])] public function augmented(Request $request): Response { @@ -317,7 +314,13 @@ class ScanController extends AbstractController $modeEnum = BarcodeSourceType::from((int) $mode); } - $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $modeEnum); + try { + $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $modeEnum); + } catch (InvalidArgumentException) { + // When the camera/barcode reader momentarily misreads a barcode whilst scanning + // return and empty result, so the good read data still remains visible + return new Response('', 200); + } $decoded = $scan->getDecodedForInfoMode(); $locale = $request->getLocale(); From 052780c8654821d8cedf032448e11ef0cf7b9ba3 Mon Sep 17 00:00:00 2001 From: swdee Date: Sat, 17 Jan 2026 17:52:20 +1300 Subject: [PATCH 07/10] 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. --- .../pages/barcode_scan_controller.js | 135 ++++++++---------- src/Controller/ScanController.php | 104 ++++++++------ src/Form/LabelSystem/ScanDialogType.php | 5 - .../scanner/augmented_result.html.twig | 15 +- .../label_system/scanner/scanner.html.twig | 2 +- translations/messages.en.xlf | 12 +- 6 files changed, 137 insertions(+), 136 deletions(-) diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index c8aeac80..b5a96834 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -29,12 +29,20 @@ export default class extends Controller { _scanner = null; _submitting = false; _lastDecodedText = ""; + _onInfoChange = null; connect() { // Prevent double init if connect fires twice if (this._scanner) return; - this.bindModeToggles(); + // 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; @@ -74,7 +82,13 @@ export default class extends Controller { this._scanner = null; this._submitting = false; this._lastDecodedText = ""; - this.unbindModeToggles(); + + // 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; @@ -86,49 +100,14 @@ export default class extends Controller { } } - /** - * Add events to Mode checkboxes so they both can't be selected at the same time - */ - bindModeToggles() { - const info = document.getElementById("scan_dialog_info_mode"); - const aug = document.getElementById("scan_dialog_augmented_mode"); - if (!info || !aug) return; - - const onInfoChange = () => { - if (info.checked) aug.checked = false; - }; - const onAugChange = () => { - if (aug.checked) info.checked = false; - }; - - info.addEventListener("change", onInfoChange); - aug.addEventListener("change", onAugChange); - - // Save references so we can remove listeners on disconnect - this._onInfoChange = onInfoChange; - this._onAugChange = onAugChange; - } - - unbindModeToggles() { - const info = document.getElementById("scan_dialog_info_mode"); - const aug = document.getElementById("scan_dialog_augmented_mode"); - if (!info || !aug) return; - - if (this._onInfoChange) info.removeEventListener("change", this._onInfoChange); - if (this._onAugChange) aug.removeEventListener("change", this._onAugChange); - - this._onInfoChange = null; - this._onAugChange = null; - } - - async onScanSuccess(decodedText) { if (!decodedText) return; const normalized = String(decodedText).trim(); + if (!normalized) return; - // If we already handled this exact barcode and it's still showing, ignore. + // scan once per barcode if (normalized === this._lastDecodedText) return; // If a request/submit is in-flight, ignore scans. @@ -142,43 +121,42 @@ export default class extends Controller { const input = document.getElementById("scan_dialog_input"); if (input) input.value = decodedText; - const augmented = !!document.getElementById("scan_dialog_augmented_mode")?.checked; + const infoMode = !!document.getElementById("scan_dialog_info_mode")?.checked; - // If augmented mode: do NOT submit the form. - if (augmented) { - try { - await this.lookupAndRender(decodedText); - } catch (e) { - console.warn("[barcode_scan] augmented lookup failed", e); - // Allow retry on failure by clearing last decoded text - this._lastDecodedText = ""; - } finally { - // allow scanning again - this._submitting = false; - } - return; - } - - // Non-augmented: Stop scanner BEFORE submitting to avoid camera transition races try { - if (this._scanner?.clear) { - await this._scanner.clear(); - } - } catch (_) { - // ignore - } finally { - this._scanner = null; - } + const data = await this.lookup(normalized, infoMode); - //Submit form - document.getElementById("scan_dialog_form")?.requestSubmit(); + // 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 lookupAndRender(decodedText) { - const form = document.getElementById("scan_dialog_form"); - if (!form) return; - // Ensure the hidden csrf field has been converted from placeholder -> real token + cookie set + async lookup(decodedText, infoMode) { + const form = document.getElementById("scan_dialog_form"); + if (!form) return { ok: false }; + generateCsrfToken(form); const mode = @@ -187,23 +165,28 @@ export default class extends Controller { const body = new URLSearchParams(); body.set("input", decodedText); if (mode !== "") body.set("mode", mode); + body.set("info_mode", infoMode ? "1" : "0"); const headers = { - "Accept": "text/html", + "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - ...generateCsrfHeaders(form), // adds the special CSRF header Symfony expects (if enabled) + ...generateCsrfHeaders(form), }; - const resp = await fetch(this.element.dataset.augmentedUrl, { + 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", }); - const html = await resp.text(); + if (!resp.ok) { + throw new Error(`lookup failed: HTTP ${resp.status}`); + } - const el = document.getElementById("scan-augmented-result"); - if (el) el.innerHTML = html; + return await resp.json(); } } diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index cc5e0bf2..8676095f 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -83,46 +83,39 @@ class ScanController extends AbstractController $form = $this->createForm(ScanDialogType::class); $form->handleRequest($request); - $mode = null; + // 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; - $createUrl = null; - if ($input !== null) { + if ($input !== null && $input !== '') { + $mode = $form->isSubmitted() ? $form['mode']->getData() : null; + $infoMode = $form->isSubmitted() ? (bool) $form['info_mode']->getData() : false; + try { - $scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null); + $scan = $this->barcodeNormalizer->scanBarcodeContent((string) $input, $mode ?? null); - //Perform a redirect if the info mode is not enabled - if (!$form['info_mode']->getData()) { - try { - // redirect user to part page - return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); - } catch (EntityNotFoundException) { - // Part not found -> show decoded info + optional "create part" link - $infoModeData = $scan_result->getDecodedForInfoMode(); - - $createUrl = $this->buildCreateUrlForScanResult($scan_result, $request->getLocale()); - - if ($createUrl === null) { - $this->addFlash('warning', 'scan.qr_not_found'); - } - } - } else { //Otherwise retrieve infoModeData - $infoModeData = $scan_result->getDecodedForInfoMode(); + // 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'); } } return $this->render('label_system/scanner/scanner.html.twig', [ 'form' => $form, 'infoModeData' => $infoModeData, - 'createUrl' => $createUrl, ]); } @@ -295,64 +288,81 @@ class ScanController extends AbstractController return array_reverse($items); } - #[Route(path: '/augmented', name: 'scan_augmented', methods: ['POST'])] - public function augmented(Request $request): Response + /** + * 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 = (string) $request->request->get('input', ''); - $mode = $request->request->get('mode'); // string|null + $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 empty fragment or an error fragment; your choice - return new Response('', 200); + return new JsonResponse(['ok' => false], 200); } $modeEnum = null; - if ($mode !== null && $mode !== '') { - // Radio values are enum integers in your form + if ($mode !== '') { $modeEnum = BarcodeSourceType::from((int) $mode); } try { $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $modeEnum); } catch (InvalidArgumentException) { - // When the camera/barcode reader momentarily misreads a barcode whilst scanning - // return and empty result, so the good read data still remains visible - return new Response('', 200); + // Camera sometimes produces garbage decodes for a frame; ignore those. + return new JsonResponse(['ok' => false], 200); } + $decoded = $scan->getDecodedForInfoMode(); - $locale = $request->getLocale(); + // Resolve part (or null) $part = $this->barcodeParser->resolvePartOrNull($scan); - $found = $part !== null; + $redirectUrl = null; + if ($part !== null) { + // Redirector knows how to route parts, lots, and storelocations. + $redirectUrl = $this->barcodeParser->getRedirectURL($scan); + } + + // Build template vars $partName = null; $partUrl = null; $locations = []; $createUrl = null; - if ($found) { + if ($part !== null) { $partName = $part->getName(); - - // This is the same route BarcodeRedirector uses $partUrl = $this->generateUrl('app_part_show', ['id' => $part->getID()]); - - // Build locations (see below) $locations = $this->buildLocationsForPart($part); - } else { - // Reuse your centralized create-url logic (the helper you already extracted) $createUrl = $this->buildCreateUrlForScanResult($scan, $locale); } - return $this->render('label_system/scanner/augmented_result.html.twig', [ + // Render one fragment that shows: + // - decoded info (optional if you kept it) + // - part info + locations when found + // - create link when not found + $html = $this->renderView('label_system/scanner/augmented_result.html.twig', [ 'decoded' => $decoded, - 'found' => $found, + 'found' => ($part !== null), 'partName' => $partName, 'partUrl' => $partUrl, 'locations' => $locations, 'createUrl' => $createUrl, ]); + + return new JsonResponse([ + 'ok' => true, + 'found' => ($part !== null), + '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 d0d609d3..0a67467f 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -85,11 +85,6 @@ class ScanDialogType extends AbstractType 'required' => false, ]); - $builder->add('augmented_mode', CheckboxType::class, [ - 'label' => 'scan_dialog.augmented_mode', - 'required' => false, - ]); - $builder->add('submit', SubmitType::class, [ 'label' => 'scan_dialog.submit', ]); diff --git a/templates/label_system/scanner/augmented_result.html.twig b/templates/label_system/scanner/augmented_result.html.twig index 20eec82b..044d4ac6 100644 --- a/templates/label_system/scanner/augmented_result.html.twig +++ b/templates/label_system/scanner/augmented_result.html.twig @@ -54,10 +54,23 @@ {% endif %} {% else %}
- {% trans %}scan.qr_not_found{% endtrans %} + {% trans %}label_scanner.qr_part_no_found{% endtrans %}
{% endif %} + {# Decoded barcode fields #} + + + {% for key, value in decoded %} + + + + + {% endfor %} + +
{{ key }}{{ value }}
+ + {# Whitespace under table and Input form fields #}

diff --git a/templates/label_system/scanner/scanner.html.twig b/templates/label_system/scanner/scanner.html.twig index ffac8234..ed657839 100644 --- a/templates/label_system/scanner/scanner.html.twig +++ b/templates/label_system/scanner/scanner.html.twig @@ -13,7 +13,7 @@
+ data-lookup-url="{{ path('scan_lookup') }}">
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 482d0c15..37b39085 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12039,12 +12039,6 @@ Please note, that you can not impersonate a disabled user. If you try you will g Info mode (Decode barcode and show its contents, but do not redirect to part)
- - - scan_dialog.augmented_mode - Augmented mode (Decode barcode, look up, and display database part information) - - label_scanner.decoded_info.title @@ -12063,6 +12057,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g 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 From 3b1ea8275f16f8cc49bc6955bbc8d3e48bfce3e4 Mon Sep 17 00:00:00 2001 From: swdee Date: Sat, 17 Jan 2026 19:51:40 +1300 Subject: [PATCH 08/10] 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 --- src/Controller/ScanController.php | 44 ++++++---- .../scanner/augmented_result.html.twig | 86 +++++++++++-------- translations/messages.en.xlf | 12 +++ 3 files changed, 90 insertions(+), 52 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 8676095f..4e9dc8b2 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -321,36 +321,44 @@ class ScanController extends AbstractController $decoded = $scan->getDecodedForInfoMode(); - // Resolve part (or null) - $part = $this->barcodeParser->resolvePartOrNull($scan); - + // Determine if this barcode resolves to *anything* (part, lot->part, storelocation) $redirectUrl = null; - if ($part !== null) { - // Redirector knows how to route parts, lots, and storelocations. + $targetFound = false; + + try { $redirectUrl = $this->barcodeParser->getRedirectURL($scan); + $targetFound = true; + } catch (EntityNotFoundException) { + $targetFound = false; } - // Build template vars + // Only resolve Part for part-like targets. Storelocation scans should remain null here. + $part = null; $partName = null; $partUrl = null; $locations = []; - $createUrl = null; - if ($part !== null) { - $partName = $part->getName(); - $partUrl = $this->generateUrl('app_part_show', ['id' => $part->getID()]); - $locations = $this->buildLocationsForPart($part); - } else { + 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 one fragment that shows: - // - decoded info (optional if you kept it) - // - part info + locations when found - // - create link when not found + // Render fragment (use openUrl for universal "Open" link) $html = $this->renderView('label_system/scanner/augmented_result.html.twig', [ 'decoded' => $decoded, - 'found' => ($part !== null), + 'found' => $targetFound, + 'openUrl' => $redirectUrl, 'partName' => $partName, 'partUrl' => $partUrl, 'locations' => $locations, @@ -359,7 +367,7 @@ class ScanController extends AbstractController return new JsonResponse([ 'ok' => true, - 'found' => ($part !== null), + 'found' => $targetFound, 'redirectUrl' => $redirectUrl, // client redirects only when infoMode=false 'html' => $html, 'infoMode' => $infoMode, diff --git a/templates/label_system/scanner/augmented_result.html.twig b/templates/label_system/scanner/augmented_result.html.twig index 044d4ac6..c31e336a 100644 --- a/templates/label_system/scanner/augmented_result.html.twig +++ b/templates/label_system/scanner/augmented_result.html.twig @@ -2,7 +2,14 @@
-

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

+

+ {% if found and partName %} + {% trans %}label_scanner.part_info.title{% endtrans %} + {% else %} + {% trans %}label_scanner.scan_result.title{% endtrans %} + {% endif %} +

+ {% if createUrl %} {% if found %} - +
+
+ {% if partName %} + {{ partName }} + {% else %} + {% trans %}label_scanner.target_found{% endtrans %} + {% endif %} +
- {% if locations is not empty %} - - - - - - - - - {% for loc in locations %} + {% if openUrl %} + + {% trans %}open{% endtrans %} + + {% endif %} + + + {% if partName %} + {% if locations is not empty %} +
{% trans %}part_lots.storage_location{% endtrans %} - {% trans %}part_lots.amount{% endtrans %} -
+ - - + + - {% endfor %} - -
- - - {% if loc.qty is not null %}{{ loc.qty }}{% else %}{% endif %} - {% trans %}part_lots.storage_location{% endtrans %} + {% trans %}part_lots.amount{% endtrans %} +
- {% else %} -
{% trans %}label_scanner.no_locations{% 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 %}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 37b39085..8ae6b777 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12051,6 +12051,18 @@ Please note, that you can not impersonate a disabled user. If you try you will g Part information + + + label_scanner.target_found + Item Found + + + + + label_scanner.scan_result.title + Scan result + + label_scanner.no_locations From 7fb46c9516e566f4c5a1ed22323eea6ebf93219a Mon Sep 17 00:00:00 2001 From: swdee Date: Mon, 19 Jan 2026 16:33:58 +1300 Subject: [PATCH 09/10] fix static analysis errors --- src/Controller/ScanController.php | 4 +++- .../LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 4e9dc8b2..537be473 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -309,7 +309,9 @@ class ScanController extends AbstractController $modeEnum = null; if ($mode !== '') { - $modeEnum = BarcodeSourceType::from((int) $mode); + $i = (int) $mode; + $cases = BarcodeSourceType::cases(); + $modeEnum = $cases[$i] ?? null; // null if out of range } try { diff --git a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php index 9a87951f..236bad48 100644 --- a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php +++ b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php @@ -100,7 +100,10 @@ class LCSCBarcodeScanResult implements BarcodeScanResultInterface $fields = []; // This format is comma-separated pairs, values do not contain commas in your sample. - $pairs = array_filter(array_map('trim', explode(',', $inner))); + $pairs = array_filter( + array_map('trim', explode(',', $inner)), + static fn(string $s): bool => $s !== '' + ); foreach ($pairs as $pair) { $pos = strpos($pair, ':'); From 7336bc8114d7f1fe3cb0c524dfc5ff51b2f510d8 Mon Sep 17 00:00:00 2001 From: swdee Date: Mon, 19 Jan 2026 18:49:22 +1300 Subject: [PATCH 10/10] added unit tests for meeting code coverage report --- tests/Controller/ScanControllerTest.php | 55 ++++++++++++++ .../BarcodeScanner/BarcodeRedirectorTest.php | 75 +++++++++++++++++++ .../BarcodeScanner/BarcodeScanHelperTest.php | 38 ++++++++++ 3 files changed, 168 insertions(+) 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); + } }