From 0e70f2f672ee29630d3420e5ae58b0126e6fb598 Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 13:48:59 +1300 Subject: [PATCH 01/40] added handling of LCSC barcode decoding and part loading on Label Scanner --- src/Form/LabelSystem/ScanDialogType.php | 1 + .../BarcodeScanner/BarcodeRedirector.php | 53 ++++++++ .../BarcodeScanner/BarcodeScanHelper.php | 15 +++ .../BarcodeScanner/BarcodeSourceType.php | 3 + .../BarcodeScanner/LCSCBarcodeScanResult.php | 127 ++++++++++++++++++ translations/messages.en.xlf | 6 + 6 files changed, 205 insertions(+) create mode 100644 src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php index 9199c31dd..d9c1de0e5 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -77,6 +77,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user', BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp', BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin', + BarcodeSourceType::LCSC => 'scan_dialog.mode.lcsc', }, ]); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php index 1a3c29c25..855df7361 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php @@ -45,6 +45,7 @@ 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; @@ -81,6 +82,10 @@ public function getRedirectURL(BarcodeScanResultInterface $barcodeScan): string return $this->getURLGTINBarcode($barcodeScan); } + if ($barcodeScan instanceof LCSCBarcodeScanResult) { + return $this->getURLLCSCBarcode($barcodeScan); + } + throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan)); } @@ -106,6 +111,54 @@ private function getURLLocalBarcode(LocalBarcodeScanResult $barcodeScan): string } } + /** + * 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 520c9f3bc..393a0911c 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php @@ -92,10 +92,15 @@ public function scanBarcodeContent(string $input, ?BarcodeSourceType $type = nul if ($type === BarcodeSourceType::EIGP114) { return $this->parseEIGP114Barcode($input); } + if ($type === BarcodeSourceType::GTIN) { return $this->parseGTINBarcode($input); } + if ($type === BarcodeSourceType::LCSC) { + return $this->parseLCSCBarcode($input); + } + //Null means auto and we try the different formats $result = $this->parseInternalBarcode($input); @@ -125,6 +130,11 @@ public function scanBarcodeContent(string $input, ?BarcodeSourceType $type = nul return $this->parseGTINBarcode($input); } + // Try LCSC barcode + if (LCSCBarcodeScanResult::looksLike($input)) { + return $this->parseLCSCBarcode($input); + } + throw new InvalidArgumentException('Unknown barcode'); } @@ -138,6 +148,11 @@ private function parseEIGP114Barcode(string $input): EIGP114BarcodeScanResult 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 43643d126..330706f14 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php @@ -47,4 +47,7 @@ enum BarcodeSourceType * GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part. */ case GTIN; + + /** 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 000000000..9a87951ff --- /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 d9418563d..2762a0200 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9500,6 +9500,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g EIGP 114 barcode (e.g. the datamatrix codes on digikey and mouser orders) + + + scan_dialog.mode.lcsc + LCSC.com barcode + + scan_dialog.info_mode From 86a6342b86afdfec17d55c5e34d6b0b102fce291 Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 13:49:26 +1300 Subject: [PATCH 02/40] 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 200dd2a75..29db5de59 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; +export default class extends Controller { _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 7900d309c5c737742b792101a6f57dc680067303 Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 13:59:26 +1300 Subject: [PATCH 03/40] 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 aebadd899..637877670 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -46,6 +46,8 @@ 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\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 @@ #[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 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n $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 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n 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 1f978a9b1..ef293d1a6 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 1571e7565ebc25c5b0fc56077f01c42314259c93 Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 22:42:20 +1300 Subject: [PATCH 04/40] 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 | 18 ++ 7 files changed, 444 insertions(+), 81 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 29db5de59..e12abce81 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 637877670..e9f6bafaf 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -57,6 +57,10 @@ 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 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n // 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 @@ public function scanQRCode(string $type, int $id): Response 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 d9c1de0e5..9b45f32c3 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -86,6 +86,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void '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 855df7361..b8819d89d 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php @@ -230,4 +230,46 @@ private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part } 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 000000000..20eec82bd --- /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 ef293d1a6..ffac8234c 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 2762a0200..41fc4ba2c 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9512,12 +9512,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 From df68e3ac80f953c76e6395c572dee52fb7c98cc0 Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 23:31:21 +1300 Subject: [PATCH 05/40] 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 e12abce81..c8aeac808 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 c07d4ab23a1f1592a7abec8e6582393affe3fe70 Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 16 Jan 2026 23:32:04 +1300 Subject: [PATCH 06/40] 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 e9f6bafaf..cc5e0bf2a 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -295,9 +295,6 @@ private function buildStorageBreadcrumb(StorageLocation $loc): array return array_reverse($items); } - - - #[Route(path: '/augmented', name: 'scan_augmented', methods: ['POST'])] public function augmented(Request $request): Response { @@ -317,7 +314,13 @@ public function augmented(Request $request): Response $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 5885ac130c1f096722c1c3158d189bf2636f69f3 Mon Sep 17 00:00:00 2001 From: swdee Date: Sat, 17 Jan 2026 17:52:20 +1300 Subject: [PATCH 07/40] 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 | 127 ++++++++---------- src/Controller/ScanController.php | 106 ++++++++------- 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, 134 insertions(+), 133 deletions(-) diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index c8aeac808..b5a96834f 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; + 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; } - return; - } - // Non-augmented: Stop scanner BEFORE submitting to avoid camera transition races - try { - if (this._scanner?.clear) { - await this._scanner.clear(); + // If info mode is OFF and part was found -> redirect + if (!infoMode && data.found && data.redirectUrl) { + window.location.assign(data.redirectUrl); + return; } - } catch (_) { - // ignore + + // 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._scanner = null; + this._submitting = false; } - - //Submit form - document.getElementById("scan_dialog_form")?.requestSubmit(); } - async lookupAndRender(decodedText) { + + async lookup(decodedText, infoMode) { const form = document.getElementById("scan_dialog_form"); - if (!form) return; + if (!form) return { ok: false }; - // Ensure the hidden csrf field has been converted from placeholder -> real token + cookie set 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 cc5e0bf2a..8676095f3 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -83,46 +83,39 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n $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); - - //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(); + $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'); } } return $this->render('label_system/scanner/scanner.html.twig', [ 'form' => $form, 'infoModeData' => $infoModeData, - 'createUrl' => $createUrl, ]); } @@ -295,64 +288,81 @@ private function buildStorageBreadcrumb(StorageLocation $loc): array 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 9b45f32c3..d9c1de0e5 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -86,11 +86,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void '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 20eec82bd..044d4ac60 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 ffac8234c..ed6578390 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 41fc4ba2c..b839873b2 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9512,12 +9512,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 @@ -9536,6 +9530,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 8f63a9fb9e51419afc886902b359462791f7b0fa Mon Sep 17 00:00:00 2001 From: swdee Date: Sat, 17 Jan 2026 19:51:40 +1300 Subject: [PATCH 08/40] 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 8676095f3..4e9dc8b2c 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -321,36 +321,44 @@ public function lookup(Request $request): JsonResponse $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 @@ public function lookup(Request $request): JsonResponse 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 044d4ac60..c31e336a1 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 openUrl %} + + {% trans %}open{% endtrans %} + + {% endif %} +
- {% if locations is not empty %} - - - - - - - - - {% for loc in locations %} + {% 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 b839873b2..33bcb628a 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9524,6 +9524,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 1484cea458805d3a17231758a605689f6182228e Mon Sep 17 00:00:00 2001 From: swdee Date: Mon, 19 Jan 2026 16:33:58 +1300 Subject: [PATCH 09/40] 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 4e9dc8b2c..537be4735 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -309,7 +309,9 @@ public function lookup(Request $request): JsonResponse $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 9a87951ff..236bad48f 100644 --- a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php +++ b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php @@ -100,7 +100,10 @@ public static function parse(string $input): self $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 4881418af33bdcbc80c8608ea6ab709aae19f760 Mon Sep 17 00:00:00 2001 From: swdee Date: Mon, 19 Jan 2026 18:49:22 +1300 Subject: [PATCH 10/40] 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 b504cd292..64065878e 100644 --- a/tests/Controller/ScanControllerTest.php +++ b/tests/Controller/ScanControllerTest.php @@ -51,4 +51,59 @@ public function testScanQRCode(): void $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 c5bdb02d0..b9fd95828 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php @@ -49,6 +49,11 @@ 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 @@ public function testGetRedirectEntityNotFount(): void $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 248f1ae9d..b67110553 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php @@ -49,6 +49,7 @@ use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; final class BarcodeScanHelperTest extends WebTestCase { @@ -124,6 +125,14 @@ public static function dataProvider(): \Iterator ]); 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 @@ public function testInvalidFormats(string $input): void $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); + } } From 2d55b903118bb79db385381e2905159d8a63b53d Mon Sep 17 00:00:00 2001 From: swdee Date: Wed, 18 Feb 2026 21:53:54 +1300 Subject: [PATCH 11/40] fix @MayNiklas reported bug: when manually submitting the form (from a barcode scan or manual input) redirect to Create New part screen for the decoded information instead of showing 'Format Unknown' popup error message --- src/Controller/ScanController.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 537be4735..5cbdb6a5f 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -100,8 +100,19 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n // If not in info mode, mimic “normal scan” behavior: redirect if possible. if (!$infoMode) { - $url = $this->barcodeParser->getRedirectURL($scan); - return $this->redirect($url); + try { + $url = $this->barcodeParser->getRedirectURL($scan); + return $this->redirect($url); + } catch (EntityNotFoundException) { + // Decoded OK, but no part is found. If it’s a vendor code, redirect to create. + $createUrl = $this->buildCreateUrlForScanResult($scan, $request->getLocale()); + if ($createUrl !== null) { + return $this->redirect($createUrl); + } + + // Otherwise: show “not found” (not “format unknown”) + $this->addFlash('warning', 'scan.qr_not_found'); + } } // Info mode fallback: render page with prefilled result From a39eeb47bed4c6d0d6c503176623b4773968bcad Mon Sep 17 00:00:00 2001 From: swdee Date: Wed, 18 Feb 2026 23:00:02 +1300 Subject: [PATCH 12/40] fix @d-buchmann bug: clear 'scan-augmented-result' field upon rescan of new barcode --- assets/controllers/pages/barcode_scan_controller.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index b5a96834f..c0e244381 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -117,6 +117,11 @@ export default class extends Controller { this._lastDecodedText = normalized; this._submitting = true; + // Clear previous augmented result immediately to avoid stale info + // lingering when the next scan is not augmented (or is transient/junk). + const el = document.getElementById("scan-augmented-result"); + if (el) el.innerHTML = ""; + //Put our decoded Text into the input box const input = document.getElementById("scan_dialog_input"); if (input) input.value = decodedText; From b31cbf8234d77c2e9584217e4cf90ddaade02e4a Mon Sep 17 00:00:00 2001 From: swdee Date: Thu, 19 Feb 2026 10:55:12 +1300 Subject: [PATCH 13/40] fix @d-buchmann bug: after scanning in Info mode, if Info mode is turned off when scanning a part that did not exist, it now redirects user to create part page --- assets/controllers/pages/barcode_scan_controller.js | 6 ++++++ src/Controller/ScanController.php | 1 + 2 files changed, 7 insertions(+) diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index c0e244381..423f7b13f 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -143,6 +143,12 @@ export default class extends Controller { return; } + // If info mode is OFF and part was NOT found, redirect to create part URL + if (!infoMode && !data.found && data.createUrl) { + window.location.assign(data.createUrl); + return; + } + // Otherwise render returned fragment HTML if (typeof data.html === "string" && data.html !== "") { const el = document.getElementById("scan-augmented-result"); diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 5cbdb6a5f..d1140bc23 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -382,6 +382,7 @@ public function lookup(Request $request): JsonResponse 'ok' => true, 'found' => $targetFound, 'redirectUrl' => $redirectUrl, // client redirects only when infoMode=false + 'createUrl' => $createUrl, 'html' => $html, 'infoMode' => $infoMode, ], 200); From 4865f07a09e597439c50a3ef78629a87e8dbb5dc Mon Sep 17 00:00:00 2001 From: swdee Date: Thu, 19 Feb 2026 11:15:01 +1300 Subject: [PATCH 14/40] fix @d-buchmann bug: make barcode decode table 100% width of page --- templates/label_system/scanner/augmented_result.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/label_system/scanner/augmented_result.html.twig b/templates/label_system/scanner/augmented_result.html.twig index c31e336a1..ad57881eb 100644 --- a/templates/label_system/scanner/augmented_result.html.twig +++ b/templates/label_system/scanner/augmented_result.html.twig @@ -77,7 +77,7 @@ {% endif %} {# Decoded barcode fields #} - +
{% for key, value in decoded %} From c5ea4d243f7b7bb0eee3828454b53a3b3f1460e1 Mon Sep 17 00:00:00 2001 From: swdee Date: Thu, 19 Feb 2026 12:19:46 +1300 Subject: [PATCH 15/40] fix bug with manual form submission where a part does not exist but decodes properly which causes the camera to not redraw on page reload due to unclean shutdown. this is an existing bug in the scanner interface. steps to produce the issue: - have camera active - put in code in Input - info mode ticked - click submit button on page reload the camera does not reactivate --- .../pages/barcode_scan_controller.js | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index 423f7b13f..352c527cd 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -30,6 +30,7 @@ export default class extends Controller { _submitting = false; _lastDecodedText = ""; _onInfoChange = null; + _onFormSubmit = null; connect() { // Prevent double init if connect fires twice @@ -44,6 +45,22 @@ export default class extends Controller { info.addEventListener("change", this._onInfoChange); } + // Stop camera cleanly before manual form submit (prevents broken camera after reload) + const form = document.getElementById("scan_dialog_form"); + if (form) { + this._onFormSubmit = () => { + try { + const p = this._scanner?.clear?.(); + if (p && typeof p.then === "function") p.catch(() => {}); + } catch (_) { + // ignore + } + }; + + // capture=true so we run before other handlers / navigation + form.addEventListener("submit", this._onFormSubmit, { capture: true }); + } + const isMobile = window.matchMedia("(max-width: 768px)").matches; //This function ensures, that the qrbox is 70% of the total viewport @@ -90,6 +107,13 @@ export default class extends Controller { } this._onInfoChange = null; + // remove the onForm submit handler + const form = document.getElementById("scan_dialog_form"); + if (form && this._onFormSubmit) { + form.removeEventListener("submit", this._onFormSubmit, { capture: true }); + } + this._onFormSubmit = null; + if (!scanner) return; try { From 0010ee8de1cc02be407ebc257dc5c55ea871c1c9 Mon Sep 17 00:00:00 2001 From: swdee Date: Fri, 20 Feb 2026 11:02:50 +1300 Subject: [PATCH 16/40] fixed translation messages --- translations/messages.en.xlf | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 33bcb628a..f97908831 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9500,8 +9500,8 @@ 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 @@ -9518,32 +9518,32 @@ 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 From 510046975170e6b25e024e1b9fd953fa9b9f394d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 21 Feb 2026 22:43:42 +0100 Subject: [PATCH 17/40] Use symfony native functions to generate the routes for part creation --- src/Controller/ScanController.php | 46 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index d1140bc23..a39d92534 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -42,8 +42,10 @@ namespace App\Controller; use App\Form\LabelSystem\ScanDialogType; +use App\Services\InfoProviderSystem\Providers\LCSCProvider; use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector; use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface; use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; @@ -105,7 +107,7 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n return $this->redirect($url); } catch (EntityNotFoundException) { // Decoded OK, but no part is found. If it’s a vendor code, redirect to create. - $createUrl = $this->buildCreateUrlForScanResult($scan, $request->getLocale()); + $createUrl = $this->buildCreateUrlForScanResult($scan); if ($createUrl !== null) { return $this->redirect($createUrl); } @@ -161,21 +163,19 @@ public function scanQRCode(string $type, int $id): Response /** * Builds a URL for creating a new part based on the barcode data - * @param object $scanResult - * @param string $locale + * @param BarcodeScanResultInterface $scanResult * @return string|null */ - private function buildCreateUrlForScanResult(object $scanResult, string $locale): ?string + private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?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'; + if ($lcscCode !== null && $lcscCode !== '') { + return $this->generateUrl('info_providers_create_part', [ + 'providerKey' => 'lcsc', + 'providerId' => $lcscCode, + ]); } } @@ -185,7 +185,7 @@ private function buildCreateUrlForScanResult(object $scanResult, string $locale) // Mouser: use supplierPartNumber -> search provider -> provider_id if ($vendor === 'mouser' - && is_string($scanResult->supplierPartNumber) + && $scanResult->supplierPartNumber !== null && $scanResult->supplierPartNumber !== '' ) { try { @@ -204,12 +204,12 @@ private function buildCreateUrlForScanResult(object $scanResult, string $locale) // 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'; + if ($best !== null && $best->provider_id !== '') { + + return $this->generateUrl('info_providers_create_part', [ + 'providerKey' => 'mouser', + 'providerId' => $best->provider_id, + ]); } $this->addFlash('warning', 'No Mouser match found for this MPN.'); @@ -238,11 +238,10 @@ private function buildCreateUrlForScanResult(object $scanResult, string $locale) $id = $scanResult->customerPartNumber ?: $scanResult->supplierPartNumber; if (is_string($id) && $id !== '') { - return '/' - . rawurlencode($locale) - . '/part/from_info_provider/digikey/' - . rawurlencode($id) - . '/create'; + return $this->generateUrl('info_providers_create_part', [ + 'providerKey' => 'digikey', + 'providerId' => $id, + ]); } } catch (\InvalidArgumentException) { $this->addFlash('warning', 'Digi-Key provider is not installed/enabled'); @@ -312,7 +311,6 @@ public function lookup(Request $request): JsonResponse $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); @@ -364,7 +362,7 @@ public function lookup(Request $request): JsonResponse // Create link only when NOT found (vendor codes) $createUrl = null; if (!$targetFound) { - $createUrl = $this->buildCreateUrlForScanResult($scan, $locale); + $createUrl = $this->buildCreateUrlForScanResult($scan); } // Render fragment (use openUrl for universal "Open" link) From 76584c3d991f87265fadb53377bf3b716b520f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 21 Feb 2026 22:52:08 +0100 Subject: [PATCH 18/40] Use native request functions for request param parsing --- src/Controller/ScanController.php | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index a39d92534..94c16b0b1 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -308,21 +308,14 @@ 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); + $input = trim($request->request->getString('input', '')); + $modeEnum = $request->request->getEnum('mode', BarcodeSourceType::class); + $infoMode = $request->request->getBoolean('info_mode', false); 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) { @@ -340,7 +333,6 @@ public function lookup(Request $request): JsonResponse $redirectUrl = $this->barcodeParser->getRedirectURL($scan); $targetFound = true; } catch (EntityNotFoundException) { - $targetFound = false; } // Only resolve Part for part-like targets. Storelocation scans should remain null here. From 338c5ebf0bf548365ed96be9408e280f88ca8310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 21 Feb 2026 23:26:25 +0100 Subject: [PATCH 19/40] Refactored LCSCBarcocdeScanResult to be an value object like the other Barcode results --- src/Controller/ScanController.php | 2 +- .../BarcodeScanner/BarcodeRedirector.php | 4 +- .../BarcodeScanner/BarcodeScanHelper.php | 2 +- .../BarcodeScanResultInterface.php | 2 +- .../BarcodeScanner/LCSCBarcodeScanResult.php | 95 ++++++++++++------- .../BarcodeScanner/BarcodeRedirectorTest.php | 4 +- .../BarcodeScanner/BarcodeScanHelperTest.php | 8 +- 7 files changed, 72 insertions(+), 45 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 94c16b0b1..330c50d54 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -170,7 +170,7 @@ private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanRes { // LCSC if ($scanResult instanceof LCSCBarcodeScanResult) { - $lcscCode = $scanResult->getPC(); + $lcscCode = $scanResult->lcscCode; if ($lcscCode !== null && $lcscCode !== '') { return $this->generateUrl('info_providers_create_part', [ 'providerKey' => 'lcsc', diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php index b8819d89d..d4b7bed91 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php @@ -130,7 +130,7 @@ private function getURLLCSCBarcode(LCSCBarcodeScanResult $barcodeScan): string private function getPartFromLCSC(LCSCBarcodeScanResult $barcodeScan): Part { // Try LCSC code (pc) as provider id if available - $pc = $barcodeScan->getPC(); // e.g. C138033 + $pc = $barcodeScan->lcscCode; // 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)')); @@ -142,7 +142,7 @@ private function getPartFromLCSC(LCSCBarcodeScanResult $barcodeScan): Part } // Fallback to MPN (pm) - $pm = $barcodeScan->getPM(); // e.g. RC0402FR-071ML + $pm = $barcodeScan->mpn; // e.g. RC0402FR-071ML if (!$pm) { throw new EntityNotFoundException(); } diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php index 393a0911c..b2363ec86 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php @@ -131,7 +131,7 @@ public function scanBarcodeContent(string $input, ?BarcodeSourceType $type = nul } // Try LCSC barcode - if (LCSCBarcodeScanResult::looksLike($input)) { + if (LCSCBarcodeScanResult::isLCSCBarcode($input)) { return $this->parseLCSCBarcode($input); } diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php index 881303514..befa91b6c 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php @@ -33,4 +33,4 @@ interface BarcodeScanResultInterface * @return array */ public function getDecodedForInfoMode(): array; -} \ No newline at end of file +} diff --git a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php index 236bad48f..02f87b47c 100644 --- a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php +++ b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php @@ -10,39 +10,65 @@ * This class represents the content of a lcsc.com barcode * Its data structure is represented by {pbn:...,on:...,pc:...,pm:...,qty:...} */ -class LCSCBarcodeScanResult implements BarcodeScanResultInterface +readonly class LCSCBarcodeScanResult implements BarcodeScanResultInterface { + + /** @var string|null (pbn) */ + public ?string $pickBatchNumber; + + /** @var string|null (on) */ + public ?string $orderNumber; + + /** @var string|null LCSC Supplier part number (pc) */ + public ?string $lcscCode; + + /** @var string|null (pm) */ + public ?string $mpn; + + /** @var int|null (qty) */ + public ?int $quantity; + + /** @var string|null Country Channel as raw value (CC) */ + public ?string $countryChannel; + /** - * @param array $fields + * @var string|null Warehouse code as raw value (WC) */ - public function __construct( - public readonly array $fields, - public readonly string $raw_input, - ) {} + public ?string $warehouseCode; - public function getSourceType(): BarcodeSourceType - { - return BarcodeSourceType::LCSC; - } + /** + * @var string|null Unknown numeric code (pdi) + */ + public ?string $pdi; /** - * @return string|null The manufactures part number + * @var string|null Unknown value (hp) */ - public function getPM(): ?string - { - $v = $this->fields['pm'] ?? null; - $v = $v !== null ? trim($v) : null; - return ($v === '') ? null : $v; - } + public ?string $hp; /** - * @return string|null The lcsc.com part number + * @param array $fields */ - public function getPC(): ?string + public function __construct( + public array $fields, + public string $raw_input, + ) { + + $this->pickBatchNumber = $this->fields['pbn'] ?? null; + $this->orderNumber = $this->fields['on'] ?? null; + $this->lcscCode = $this->fields['pc'] ?? null; + $this->mpn = $this->fields['pm'] ?? null; + $this->quantity = isset($this->fields['qty']) ? (int)$this->fields['qty'] : null; + $this->countryChannel = $this->fields['cc'] ?? null; + $this->warehouseCode = $this->fields['wc'] ?? null; + $this->pdi = $this->fields['pdi'] ?? null; + $this->hp = $this->fields['hp'] ?? null; + + } + + public function getSourceType(): BarcodeSourceType { - $v = $this->fields['pc'] ?? null; - $v = $v !== null ? trim($v) : null; - return ($v === '') ? null : $v; + return BarcodeSourceType::LCSC; } /** @@ -53,13 +79,15 @@ 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'] ?? '', + 'MPN (pm)' => $this->mpn ?? '', + 'LCSC code (pc)' => $this->lcscCode ?? '', + 'Qty' => $this->quantity !== null ? (string) $this->quantity : '', + 'Order No (on)' => $this->orderNumber ?? '', + 'Pick Batch (pbn)' => $this->pickBatchNumber ?? '', + 'Warehouse (wc)' => $this->warehouseCode ?? '', + 'Country/Channel (cc)' => $this->countryChannel ?? '', + 'PDI (unknown meaning)' => $this->pdi ?? '', + 'HP (unknown meaning)' => $this->hp ?? '', ]; } @@ -68,7 +96,7 @@ public function getDecodedForInfoMode(): array * @param string $input * @return bool */ - public static function looksLike(string $input): bool + public static function isLCSCBarcode(string $input): bool { $s = trim($input); @@ -90,18 +118,17 @@ public static function parse(string $input): self { $raw = trim($input); - if (!self::looksLike($raw)) { + if (!self::isLCSCBarcode($raw)) { throw new InvalidArgumentException('Not an LCSC barcode'); } - $inner = trim($raw); - $inner = substr($inner, 1, -1); // remove { } + $inner = substr($raw, 1, -1); // remove { } $fields = []; // This format is comma-separated pairs, values do not contain commas in your sample. $pairs = array_filter( - array_map('trim', explode(',', $inner)), + array_map(trim(...), explode(',', $inner)), static fn(string $s): bool => $s !== '' ); diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php index b9fd95828..f13604d3d 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php @@ -149,8 +149,8 @@ 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()); + $this->assertSame('RC0402FR-071ML', $scan->mpn); + $this->assertSame('C138033', $scan->lcscCode); $decoded = $scan->getDecodedForInfoMode(); $this->assertSame('LCSC', $decoded['Barcode type']); diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php index b67110553..8f8c7a185 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php @@ -170,8 +170,8 @@ public function testAutoDetectLcscBarcode(): void $result = $this->service->scanBarcodeContent($input); $this->assertInstanceOf(LCSCBarcodeScanResult::class, $result); - $this->assertSame('C138033', $result->getPC()); - $this->assertSame('RC0402FR-071ML', $result->getPM()); + $this->assertSame('C138033', $result->lcscCode); + $this->assertSame('RC0402FR-071ML', $result->mpn); } public function testLcscExplicitTypeParses(): void @@ -181,8 +181,8 @@ public function testLcscExplicitTypeParses(): void $result = $this->service->scanBarcodeContent($input, BarcodeSourceType::LCSC); $this->assertInstanceOf(LCSCBarcodeScanResult::class, $result); - $this->assertSame('C138033', $result->getPC()); - $this->assertSame('RC0402FR-071ML', $result->getPM()); + $this->assertSame('C138033', $result->lcscCode); + $this->assertSame('RC0402FR-071ML', $result->mpn); } public function testLcscExplicitTypeRejectsNonLcsc(): void From a8520b7870c923263eb5441b6e8269249ab24bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 21 Feb 2026 23:37:46 +0100 Subject: [PATCH 20/40] Added test for LCSCBarcodeScanResult --- .../BarcodeScanner/LCSCBarcodeScanResult.php | 2 +- .../BarcodeScanner/BarcodeRedirectorTest.php | 38 +++----- .../LCSCBarcodeScanResultTest.php | 86 +++++++++++++++++++ 3 files changed, 97 insertions(+), 29 deletions(-) create mode 100644 tests/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResultTest.php diff --git a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php index 02f87b47c..0151cffa2 100644 --- a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php +++ b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php @@ -51,7 +51,7 @@ */ public function __construct( public array $fields, - public string $raw_input, + public string $rawInput, ) { $this->pickBatchNumber = $this->fields['pbn'] ?? null; diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php index f13604d3d..8a2810ba9 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php @@ -116,45 +116,27 @@ public function testEIGPBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void $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:}' + rawInput: '{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 + public function testLCSCBarcodeMissingPmThrowsEntityNotFound(): void { - $scan = LCSCBarcodeScanResult::parse('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'); - - $this->assertSame('RC0402FR-071ML', $scan->mpn); - $this->assertSame('C138033', $scan->lcscCode); + // 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() + rawInput: '{pc:C0000000,pm:}' + ); - $decoded = $scan->getDecodedForInfoMode(); - $this->assertSame('LCSC', $decoded['Barcode type']); - $this->assertSame('RC0402FR-071ML', $decoded['MPN (pm)']); - $this->assertSame('C138033', $decoded['LCSC code (pc)']); + $this->expectException(EntityNotFoundException::class); + $this->service->getRedirectURL($scan); } } diff --git a/tests/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResultTest.php b/tests/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResultTest.php new file mode 100644 index 000000000..2128f1133 --- /dev/null +++ b/tests/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResultTest.php @@ -0,0 +1,86 @@ +. + */ + +namespace App\Tests\Services\LabelSystem\BarcodeScanner; + +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; + +class LCSCBarcodeScanResultTest extends TestCase +{ + public function testIsLCSCBarcode(): void + { + $this->assertFalse(LCSCBarcodeScanResult::isLCSCBarcode('invalid')); + $this->assertFalse(LCSCBarcodeScanResult::isLCSCBarcode('LCSC-12345')); + $this->assertFalse(LCSCBarcodeScanResult::isLCSCBarcode('')); + + $this->assertTrue(LCSCBarcodeScanResult::isLCSCBarcode('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}')); + $this->assertTrue(LCSCBarcodeScanResult::isLCSCBarcode('{pbn:PICK2506270148,on:GB2506270877,pc:C22437266,pm:IA0509S-2W,qty:3,mc:,cc:1,pdi:164234874,hp:null,wc:ZH}')); + } + + public function testConstruct(): void + { + $raw = '{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'; + $fields = ['pbn' => 'PB1', 'on' => 'ON1', 'pc' => 'C138033', 'pm' => 'RC0402FR-071ML', 'qty' => '10']; + $scan = new LCSCBarcodeScanResult($fields, $raw); + //Splitting up should work and assign the correct values to the properties: + $this->assertSame('RC0402FR-071ML', $scan->mpn); + $this->assertSame('C138033', $scan->lcscCode); + + //Fields and raw input should be preserved + $this->assertSame($fields, $scan->fields); + $this->assertSame($raw, $scan->rawInput); + } + + public function testLCSCParseInvalidFormatThrows(): void + { + $this->expectException(InvalidArgumentException::class); + LCSCBarcodeScanResult::parse('not-an-lcsc-barcode'); + } + + public function testParse(): void + { + $scan = LCSCBarcodeScanResult::parse('{pbn:PICK2506270148,on:GB2506270877,pc:C22437266,pm:IA0509S-2W,qty:3,mc:,cc:1,pdi:164234874,hp:null,wc:ZH}'); + + $this->assertSame('IA0509S-2W', $scan->mpn); + $this->assertSame('C22437266', $scan->lcscCode); + $this->assertSame('PICK2506270148', $scan->pickBatchNumber); + $this->assertSame('GB2506270877', $scan->orderNumber); + $this->assertSame(3, $scan->quantity); + $this->assertSame('1', $scan->countryChannel); + $this->assertSame('164234874', $scan->pdi); + $this->assertSame('null', $scan->hp); + $this->assertSame('ZH', $scan->warehouseCode); + } + + public function testLCSCParseExtractsFields(): void + { + $scan = LCSCBarcodeScanResult::parse('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'); + + $this->assertSame('RC0402FR-071ML', $scan->mpn); + $this->assertSame('C138033', $scan->lcscCode); + + $decoded = $scan->getDecodedForInfoMode(); + $this->assertSame('LCSC', $decoded['Barcode type']); + $this->assertSame('RC0402FR-071ML', $decoded['MPN (pm)']); + $this->assertSame('C138033', $decoded['LCSC code (pc)']); + } +} From 851061aae3519c7b11c4325371d329318a687d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 21 Feb 2026 23:53:25 +0100 Subject: [PATCH 21/40] Fixed exception when submitting form for info mode --- templates/label_system/scanner/scanner.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/label_system/scanner/scanner.html.twig b/templates/label_system/scanner/scanner.html.twig index ed6578390..afc4a2be7 100644 --- a/templates/label_system/scanner/scanner.html.twig +++ b/templates/label_system/scanner/scanner.html.twig @@ -32,7 +32,7 @@

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

- {% if createUrl %} + {% if createUrl is defined and createUrl %} From a9a1f1d265b1a27529b4aecb7ac2d43dfad46bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 21 Feb 2026 23:58:05 +0100 Subject: [PATCH 22/40] Made BarcodeSourceType a backed enum, so that it can be used in Request::getEnum() --- .../BarcodeScanner/BarcodeSourceType.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php index 330706f14..13ab4bf36 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php @@ -26,28 +26,28 @@ /** * This enum represents the different types, where a barcode/QR-code can be generated from */ -enum BarcodeSourceType +enum BarcodeSourceType: string { /** This Barcode was generated using Part-DB internal recommended barcode generator */ - case INTERNAL; + case INTERNAL = 'internal'; /** This barcode is containing an internal part number (IPN) */ - case IPN; + case IPN = 'ipn'; /** * This barcode is a user defined barcode defined on a part lot */ - case USER_DEFINED; + case USER_DEFINED = 'user_defined'; /** * EIGP114 formatted barcodes like used by digikey, mouser, etc. */ - case EIGP114; + case EIGP114 = 'eigp114'; /** * GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part. */ - case GTIN; + case GTIN = 'gtin'; /** For LCSC.com formatted QR codes */ - case LCSC; + case LCSC = 'lcsc'; } From f77d2015638eb5af2a25a190ec3f7324b521c2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 00:27:09 +0100 Subject: [PATCH 23/40] Moved database queries from BarcodeRedirector to PartRepository --- src/Repository/PartRepository.php | 65 +++++++++++++++++++ .../BarcodeScanner/BarcodeRedirector.php | 57 +++++----------- 2 files changed, 81 insertions(+), 41 deletions(-) diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index 9d5fee5ea..03d6a8cc1 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -389,4 +389,69 @@ private function getNextIpnSuggestion(array $givenIpns): ?string { return $baseIpn . '_' . ($maxSuffix + 1); } + /** + * Finds a part based on the provided info provider key and ID, with an option for case sensitivity. + * If no part is found with the given provider key and ID, null is returned. + * @param string $providerID + * @param string|null $providerKey If null, the provider key will not be included in the search criteria, and only the provider ID will be used for matching. + * @param bool $caseInsensitive If true, the provider ID comparison will be case-insensitive. Default is true. + * @return Part|null + */ + public function getPartByProviderInfo(string $providerID, ?string $providerKey = null, bool $caseInsensitive = true): ?Part + { + $qb = $this->createQueryBuilder('part'); + $qb->select('part'); + + if ($providerKey) { + $qb->where("part.providerReference.provider_key = :providerKey"); + $qb->setParameter('providerKey', $providerKey); + } + + + if ($caseInsensitive) { + $qb->andWhere("LOWER(part.providerReference.provider_id) = LOWER(:providerID)"); + } else { + $qb->andWhere("part.providerReference.provider_id = :providerID"); + } + + $qb->setParameter('providerID', $providerID); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * Finds a part based on the provided MPN (Manufacturer Part Number), with an option for case sensitivity. + * If no part is found with the given MPN, null is returned. + * @param string $mpn + * @param string|null $manufacturerName If provided, the search will also include a match for the manufacturer's name. If null, the manufacturer name will not be included in the search criteria. + * @param bool $caseInsensitive If true, the MPN comparison will be case-insensitive. Default is true (case-insensitive). + * @return Part|null + */ + public function getPartByMPN(string $mpn, ?string $manufacturerName = null, bool $caseInsensitive = true): ?Part + { + $qb = $this->createQueryBuilder('part'); + $qb->select('part'); + + if ($caseInsensitive) { + $qb->where("LOWER(part.mpn) = LOWER(:mpn)"); + } else { + $qb->where("part.mpn = :mpn"); + } + + if ($manufacturerName !== null) { + $qb->leftJoin('part.manufacturer', 'manufacturer'); + + if ($caseInsensitive) { + $qb->andWhere("LOWER(part.manufacturer.name) = LOWER(:manufacturerName)"); + } else { + $qb->andWhere("manufacturer.name = :manufacturerName"); + } + $qb->setParameter('manufacturerName', $manufacturerName); + } + + $qb->setParameter('mpn', $mpn); + + return $qb->getQuery()->getOneOrNullResult(); + } + } diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php index d4b7bed91..32e5fb773 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php @@ -132,13 +132,10 @@ private function getPartFromLCSC(LCSCBarcodeScanResult $barcodeScan): Part // Try LCSC code (pc) as provider id if available $pc = $barcodeScan->lcscCode; // 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]; - } + $part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pc); + if ($part !== null) { + return $part; + } } // Fallback to MPN (pm) @@ -147,14 +144,10 @@ private function getPartFromLCSC(LCSCBarcodeScanResult $barcodeScan): Part 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]; - } + $part = $this->em->getRepository(Part::class)->getPartByMPN($pm); + if ($part !== null) { + return $part; + } throw new EntityNotFoundException(); } @@ -189,17 +182,14 @@ private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part // the info provider system or if the part was bought from a different vendor than the data was retrieved // from. if($barcodeScan->digikeyPartNumber) { - $qb = $this->em->getRepository(Part::class)->createQueryBuilder('part'); - //Lower() to be case insensitive - $qb->where($qb->expr()->like('LOWER(part.providerReference.provider_id)', 'LOWER(:vendor_id)')); - $qb->setParameter('vendor_id', $barcodeScan->digikeyPartNumber); - $results = $qb->getQuery()->getResult(); - if ($results) { - return $results[0]; + + $part = $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->digikeyPartNumber); + if ($part !== null) { + return $part; } } - if(!$barcodeScan->supplierPartNumber){ + if (!$barcodeScan->supplierPartNumber){ throw new EntityNotFoundException(); } @@ -207,27 +197,12 @@ private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part //multiple manufacturers to use the same part number for their version of a common product //We assume the user is able to realize when this returns the wrong part //If the barcode specifies the manufacturer we try to use that as well - $mpnQb = $this->em->getRepository(Part::class)->createQueryBuilder('part'); - $mpnQb->where($mpnQb->expr()->like('LOWER(part.manufacturer_product_number)', 'LOWER(:mpn)')); - $mpnQb->setParameter('mpn', $barcodeScan->supplierPartNumber); - - if($barcodeScan->mouserManufacturer){ - $manufacturerQb = $this->em->getRepository(Manufacturer::class)->createQueryBuilder("manufacturer"); - $manufacturerQb->where($manufacturerQb->expr()->like("LOWER(manufacturer.name)", "LOWER(:manufacturer_name)")); - $manufacturerQb->setParameter("manufacturer_name", $barcodeScan->mouserManufacturer); - $manufacturers = $manufacturerQb->getQuery()->getResult(); - - if($manufacturers) { - $mpnQb->andWhere($mpnQb->expr()->eq("part.manufacturer", ":manufacturer")); - $mpnQb->setParameter("manufacturer", $manufacturers); - } + $part = $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->supplierPartNumber, $barcodeScan->mouserManufacturer); + if($part !== null) { + return $part; } - $results = $mpnQb->getQuery()->getResult(); - if($results){ - return $results[0]; - } throw new EntityNotFoundException(); } From f22bff7adc3199e56d544050d010de3769e27fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 00:28:58 +0100 Subject: [PATCH 24/40] Fixed modeEnum parsing --- src/Controller/ScanController.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 330c50d54..903a7d01d 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -309,7 +309,15 @@ public function lookup(Request $request): JsonResponse $this->denyAccessUnlessGranted('@tools.label_scanner'); $input = trim($request->request->getString('input', '')); - $modeEnum = $request->request->getEnum('mode', BarcodeSourceType::class); + + // We cannot use getEnum here, because we get an empty string for mode, when auto mode is selected + $mode = $request->request->getString('mode', BarcodeSourceType::class, ''); + if ($mode === '') { + $modeEnum = null; + } else { + $modeEnum = BarcodeSourceType::from($mode); // validate enum value; will throw if invalid + } + $infoMode = $request->request->getBoolean('info_mode', false); if ($input === '') { From e0345076c14d6c5efe05cc6a4c44b0649ec4652a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 00:42:50 +0100 Subject: [PATCH 25/40] Fixed test errors --- src/Controller/ScanController.php | 2 +- src/Repository/PartRepository.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 903a7d01d..1b339e9d4 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -311,7 +311,7 @@ public function lookup(Request $request): JsonResponse $input = trim($request->request->getString('input', '')); // We cannot use getEnum here, because we get an empty string for mode, when auto mode is selected - $mode = $request->request->getString('mode', BarcodeSourceType::class, ''); + $mode = $request->request->getString('mode', ''); if ($mode === '') { $modeEnum = null; } else { diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index 03d6a8cc1..49342301a 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -433,16 +433,16 @@ public function getPartByMPN(string $mpn, ?string $manufacturerName = null, bool $qb->select('part'); if ($caseInsensitive) { - $qb->where("LOWER(part.mpn) = LOWER(:mpn)"); + $qb->where("LOWER(part.manufacturer_product_number) = LOWER(:mpn)"); } else { - $qb->where("part.mpn = :mpn"); + $qb->where("part.manufacturer_product_number = :mpn"); } if ($manufacturerName !== null) { $qb->leftJoin('part.manufacturer', 'manufacturer'); if ($caseInsensitive) { - $qb->andWhere("LOWER(part.manufacturer.name) = LOWER(:manufacturerName)"); + $qb->andWhere("LOWER(manufacturer.name) = LOWER(:manufacturerName)"); } else { $qb->andWhere("manufacturer.name = :manufacturerName"); } From f45960e4bebf58448f0e3cc76629c63590d64d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 01:28:41 +0100 Subject: [PATCH 26/40] Refactored BarcodeRedirector logic to be more universal --- src/Controller/ScanController.php | 12 +- ...ector.php => BarcodeScanResultHandler.php} | 168 ++++++++---------- ...t.php => BarcodeScanResultHandlerTest.php} | 8 +- 3 files changed, 81 insertions(+), 107 deletions(-) rename src/Services/LabelSystem/BarcodeScanner/{BarcodeRedirector.php => BarcodeScanResultHandler.php} (68%) rename tests/Services/LabelSystem/BarcodeScanner/{BarcodeRedirectorTest.php => BarcodeScanResultHandlerTest.php} (95%) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 1b339e9d4..55bf2e221 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -43,7 +43,7 @@ use App\Form\LabelSystem\ScanDialogType; use App\Services\InfoProviderSystem\Providers\LCSCProvider; -use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler; use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper; use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface; use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; @@ -71,7 +71,7 @@ class ScanController extends AbstractController { public function __construct( - protected BarcodeRedirector $barcodeParser, + protected BarcodeScanResultHandler $barcodeParser, protected BarcodeScanHelper $barcodeNormalizer, private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever, @@ -103,7 +103,7 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n // If not in info mode, mimic “normal scan” behavior: redirect if possible. if (!$infoMode) { try { - $url = $this->barcodeParser->getRedirectURL($scan); + $url = $this->barcodeParser->getInfoURL($scan); return $this->redirect($url); } catch (EntityNotFoundException) { // Decoded OK, but no part is found. If it’s a vendor code, redirect to create. @@ -153,7 +153,7 @@ public function scanQRCode(string $type, int $id): Response source_type: BarcodeSourceType::INTERNAL ); - return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); + return $this->redirect($this->barcodeParser->getInfoURL($scan_result)); } catch (EntityNotFoundException) { $this->addFlash('success', 'scan.qr_not_found'); @@ -338,7 +338,7 @@ public function lookup(Request $request): JsonResponse $targetFound = false; try { - $redirectUrl = $this->barcodeParser->getRedirectURL($scan); + $redirectUrl = $this->barcodeParser->getInfoURL($scan); $targetFound = true; } catch (EntityNotFoundException) { } @@ -350,7 +350,7 @@ public function lookup(Request $request): JsonResponse $locations = []; if ($targetFound) { - $part = $this->barcodeParser->resolvePartOrNull($scan); + $part = $this->barcodeParser->resolvePart($scan); if ($part instanceof Part) { $partName = $part->getName(); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php similarity index 68% rename from src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php rename to src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php index 32e5fb773..229282362 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -52,11 +52,13 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** + * This class handles the result of a barcode scan and determines further actions, like which URL the user should be redirected to. + * * @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest */ -final class BarcodeRedirector +final readonly class BarcodeScanResultHandler { - public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em) + public function __construct(private UrlGeneratorInterface $urlGenerator, private EntityManagerInterface $em) { } @@ -68,25 +70,21 @@ public function __construct(private readonly UrlGeneratorInterface $urlGenerator * * @throws EntityNotFoundException */ - public function getRedirectURL(BarcodeScanResultInterface $barcodeScan): string + public function getInfoURL(BarcodeScanResultInterface $barcodeScan): string { + //For our internal barcode format we can directly determine the target without looking up the part + //Also here we can encounter different types of barcodes, like storage location barcodes, which are not resolvable to a part if($barcodeScan instanceof LocalBarcodeScanResult) { return $this->getURLLocalBarcode($barcodeScan); } - if ($barcodeScan instanceof EIGP114BarcodeScanResult) { - return $this->getURLVendorBarcode($barcodeScan); - } - - if ($barcodeScan instanceof GTINBarcodeScanResult) { - return $this->getURLGTINBarcode($barcodeScan); + //For other barcodes try to resolve the part first and then redirect to the part page + $localPart = $this->resolvePart($barcodeScan); + if ($localPart !== null) { + return $this->urlGenerator->generate('app_part_show', ['id' => $localPart->getID()]); } - if ($barcodeScan instanceof LCSCBarcodeScanResult) { - return $this->getURLLCSCBarcode($barcodeScan); - } - - throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan)); + throw new EntityNotFoundException('Could not resolve a local part for the given barcode scan result'); } private function getURLLocalBarcode(LocalBarcodeScanResult $barcodeScan): string @@ -112,63 +110,51 @@ private function getURLLocalBarcode(LocalBarcodeScanResult $barcodeScan): string } /** - * Gets the URL to a part from a scan of the LCSC Barcode + * Tries to resolve a Part from the given barcode scan result. Returns null if no part could be found for the given barcode, + * or the barcode doesn't contain information allowing to resolve to a local part. + * @param BarcodeScanResultInterface $barcodeScan + * @return Part|null + * @throws \InvalidArgumentException if the barcode scan result type is unknown and cannot be handled this function */ - private function getURLLCSCBarcode(LCSCBarcodeScanResult $barcodeScan): string + public function resolvePart(BarcodeScanResultInterface $barcodeScan): ?Part { - $part = $this->getPartFromLCSC($barcodeScan); - return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]); - } + if ($barcodeScan instanceof LocalBarcodeScanResult) { + return $this->resolvePartFromLocal($barcodeScan); + } - /** - * 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->lcscCode; // e.g. C138033 - if ($pc) { - $part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pc); - if ($part !== null) { - return $part; - } - } + if ($barcodeScan instanceof EIGP114BarcodeScanResult) { + return $this->resolvePartFromVendor($barcodeScan); + } - // Fallback to MPN (pm) - $pm = $barcodeScan->mpn; // e.g. RC0402FR-071ML - if (!$pm) { - throw new EntityNotFoundException(); - } + if ($barcodeScan instanceof GTINBarcodeScanResult) { + return $this->resolvePartFromGTIN($barcodeScan); + } - $part = $this->em->getRepository(Part::class)->getPartByMPN($pm); - if ($part !== null) { - return $part; + if ($barcodeScan instanceof LCSCBarcodeScanResult) { + return $this->resolvePartFromLCSC($barcodeScan); } - throw new EntityNotFoundException(); + throw new \InvalidArgumentException("Unknown barcode scan result type: ".get_class($barcodeScan)); } - /** - * Gets the URL to a part from a scan of a Vendor Barcode - */ - private function getURLVendorBarcode(EIGP114BarcodeScanResult $barcodeScan): string + private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): ?Part { - $part = $this->getPartFromVendor($barcodeScan); - return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]); - } + switch ($barcodeScan->target_type) { + case LabelSupportedElement::PART: + $part = $this->em->find(Part::class, $barcodeScan->target_id); + return $part instanceof Part ? $part : null; - private function getURLGTINBarcode(GTINBarcodeScanResult $barcodeScan): string - { - $part = $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]); - if (!$part instanceof Part) { - throw new EntityNotFoundException(); - } + case LabelSupportedElement::PART_LOT: + $lot = $this->em->find(PartLot::class, $barcodeScan->target_id); + if (!$lot instanceof PartLot) { + return null; + } + return $lot->getPart(); - return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]); + default: + // STORELOCATION etc. doesn't map to a Part + return null; + } } /** @@ -176,7 +162,7 @@ private function getURLGTINBarcode(GTINBarcodeScanResult $barcodeScan): string * with the same Info Provider Id or, if that fails, by looking for parts with a * matching manufacturer product number. Only returns the first matching part. */ - private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part + private function resolvePartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : ?Part { // first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via // the info provider system or if the part was bought from a different vendor than the data was retrieved @@ -190,7 +176,7 @@ private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part } if (!$barcodeScan->supplierPartNumber){ - throw new EntityNotFoundException(); + return null; } //Fallback to the manufacturer part number. This may return false positives, since it is common for @@ -198,53 +184,41 @@ private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part //We assume the user is able to realize when this returns the wrong part //If the barcode specifies the manufacturer we try to use that as well - $part = $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->supplierPartNumber, $barcodeScan->mouserManufacturer); - if($part !== null) { - return $part; - } - - throw new EntityNotFoundException(); + return $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->supplierPartNumber, $barcodeScan->mouserManufacturer); } - public function resolvePartOrNull(BarcodeScanResultInterface $barcodeScan): ?Part + /** + * Resolve LCSC barcode -> Part. + * Strategy: + * 1) Try providerReference.provider_id == pc (LCSC "Cxxxxxx") if you store it there + * 2) Fallback to manufacturer_product_number == pm (MPN) + * Returns first match (consistent with EIGP114 logic) + */ + private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part { - try { - if ($barcodeScan instanceof LocalBarcodeScanResult) { - return $this->resolvePartFromLocal($barcodeScan); - } - - if ($barcodeScan instanceof EIGP114BarcodeScanResult) { - return $this->getPartFromVendor($barcodeScan); - } - - if ($barcodeScan instanceof LCSCBarcodeScanResult) { - return $this->getPartFromLCSC($barcodeScan); + // Try LCSC code (pc) as provider id if available + $pc = $barcodeScan->lcscCode; // e.g. C138033 + if ($pc) { + $part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pc); + if ($part !== null) { + return $part; } + } - return null; - } catch (EntityNotFoundException) { + // Fallback to MPN (pm) + $pm = $barcodeScan->mpn; // e.g. RC0402FR-071ML + if (!$pm) { return null; } + + return $this->em->getRepository(Part::class)->getPartByMPN($pm); } - private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): ?Part + private function resolvePartFromGTIN(GTINBarcodeScanResult $barcodeScan): ?Part { - switch ($barcodeScan->target_type) { - case LabelSupportedElement::PART: - $part = $this->em->find(Part::class, $barcodeScan->target_id); - return $part instanceof Part ? $part : null; + return $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]); + } - 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/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php similarity index 95% rename from tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php rename to tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php index 8a2810ba9..27c13f98d 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php @@ -41,10 +41,10 @@ namespace App\Tests\Services\LabelSystem\BarcodeScanner; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use App\Entity\LabelSystem\LabelSupportedElement; -use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector; use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; use Doctrine\ORM\EntityNotFoundException; @@ -55,14 +55,14 @@ use InvalidArgumentException; -final class BarcodeRedirectorTest extends KernelTestCase +final class BarcodeScanResultHandlerTest extends KernelTestCase { - private ?BarcodeRedirector $service = null; + private ?BarcodeScanResultHandler $service = null; protected function setUp(): void { self::bootKernel(); - $this->service = self::getContainer()->get(BarcodeRedirector::class); + $this->service = self::getContainer()->get(BarcodeScanResultHandler::class); } public static function urlDataProvider(): \Iterator From 35222f19ccf942a5cb7a2ddabdad9b66c8a5ef02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 01:38:41 +0100 Subject: [PATCH 27/40] Fixed BarcodeScanResultHandler test --- .../BarcodeScanResultHandlerTest.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php index 27c13f98d..9bcc40917 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php @@ -77,14 +77,14 @@ public static function urlDataProvider(): \Iterator #[Group('DB')] public function testGetRedirectURL(LocalBarcodeScanResult $scanResult, string $url): void { - $this->assertSame($url, $this->service->getRedirectURL($scanResult)); + $this->assertSame($url, $this->service->getInfoURL($scanResult)); } public function testGetRedirectEntityNotFount(): void { $this->expectException(EntityNotFoundException::class); //If we encounter an invalid lot, we must throw an exception - $this->service->getRedirectURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, + $this->service->getInfoURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 12_345_678, BarcodeSourceType::INTERNAL)); } @@ -98,7 +98,7 @@ public function getDecodedForInfoMode(): array }; $this->expectException(InvalidArgumentException::class); - $this->service->getRedirectURL($unknown); + $this->service->getInfoURL($unknown); } public function testEIGPBarcodeWithoutSupplierPartNumberThrowsEntityNotFound(): void @@ -106,14 +106,14 @@ public function testEIGPBarcodeWithoutSupplierPartNumberThrowsEntityNotFound(): $scan = new EIGP114BarcodeScanResult([]); $this->expectException(EntityNotFoundException::class); - $this->service->getRedirectURL($scan); + $this->service->getInfoURL($scan); } public function testEIGPBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void { $scan = new EIGP114BarcodeScanResult([]); - $this->assertNull($this->service->resolvePartOrNull($scan)); + $this->assertNull($this->service->resolvePart($scan)); } public function testLCSCBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void @@ -123,7 +123,7 @@ public function testLCSCBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void rawInput: '{pc:C0000000,pm:}' ); - $this->assertNull($this->service->resolvePartOrNull($scan)); + $this->assertNull($this->service->resolvePart($scan)); } @@ -137,6 +137,6 @@ public function testLCSCBarcodeMissingPmThrowsEntityNotFound(): void ); $this->expectException(EntityNotFoundException::class); - $this->service->getRedirectURL($scan); + $this->service->getInfoURL($scan); } } From caa71bbddadf2c085279ad35893508185c334fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 11:50:33 +0100 Subject: [PATCH 28/40] Refactored BarcodeScanResultHandler to be able to resolve arbitary entities from scans --- .../BarcodeScanResultHandler.php | 98 +++++++++---------- 1 file changed, 47 insertions(+), 51 deletions(-) diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php index 229282362..b7293ca7f 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -45,6 +45,7 @@ use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; +use App\Entity\Parts\StorageLocation; use App\Repository\Parts\PartRepository; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityNotFoundException; @@ -72,51 +73,36 @@ public function __construct(private UrlGeneratorInterface $urlGenerator, private */ public function getInfoURL(BarcodeScanResultInterface $barcodeScan): string { - //For our internal barcode format we can directly determine the target without looking up the part - //Also here we can encounter different types of barcodes, like storage location barcodes, which are not resolvable to a part - if($barcodeScan instanceof LocalBarcodeScanResult) { - return $this->getURLLocalBarcode($barcodeScan); + //For other barcodes try to resolve the part first and then redirect to the part page + $entity = $this->resolveEntity($barcodeScan); + + if ($entity === null) { + throw new EntityNotFoundException("No entity could be resolved for the given barcode scan result"); } - //For other barcodes try to resolve the part first and then redirect to the part page - $localPart = $this->resolvePart($barcodeScan); - if ($localPart !== null) { - return $this->urlGenerator->generate('app_part_show', ['id' => $localPart->getID()]); + if ($entity instanceof Part) { + return $this->urlGenerator->generate('app_part_show', ['id' => $entity->getID()]); } - throw new EntityNotFoundException('Could not resolve a local part for the given barcode scan result'); - } + if ($entity instanceof PartLot) { + return $this->urlGenerator->generate('app_part_show', ['id' => $entity->getPart()->getID(), 'highlightLot' => $entity->getID()]); + } - private function getURLLocalBarcode(LocalBarcodeScanResult $barcodeScan): string - { - switch ($barcodeScan->target_type) { - case LabelSupportedElement::PART: - return $this->urlGenerator->generate('app_part_show', ['id' => $barcodeScan->target_id]); - case LabelSupportedElement::PART_LOT: - //Try to determine the part to the given lot - $lot = $this->em->find(PartLot::class, $barcodeScan->target_id); - if (!$lot instanceof PartLot) { - throw new EntityNotFoundException(); - } - - return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID(), 'highlightLot' => $lot->getID()]); - - case LabelSupportedElement::STORELOCATION: - return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]); - - default: - throw new InvalidArgumentException('Unknown $type: '.$barcodeScan->target_type->name); + if ($entity instanceof StorageLocation) { + return $this->urlGenerator->generate('part_list_store_location', ['id' => $entity->getID()]); } + + //@phpstan-ignore-next-line This should never happen, since resolveEntity should only return Part, PartLot or StorageLocation + throw new \LogicException("Resolved entity is of unknown type: ".get_class($entity)); } /** - * Tries to resolve a Part from the given barcode scan result. Returns null if no part could be found for the given barcode, - * or the barcode doesn't contain information allowing to resolve to a local part. + * Tries to resolve the given barcode scan result to a local entity. This can be a Part, a PartLot or a StorageLocation, depending on the type of the barcode and the information contained in it. + * Returns null if no matching entity could be found. * @param BarcodeScanResultInterface $barcodeScan - * @return Part|null - * @throws \InvalidArgumentException if the barcode scan result type is unknown and cannot be handled this function + * @return Part|PartLot|StorageLocation */ - public function resolvePart(BarcodeScanResultInterface $barcodeScan): ?Part + public function resolveEntity(BarcodeScanResultInterface $barcodeScan): Part|PartLot|StorageLocation|null { if ($barcodeScan instanceof LocalBarcodeScanResult) { return $this->resolvePartFromLocal($barcodeScan); @@ -134,27 +120,37 @@ public function resolvePart(BarcodeScanResultInterface $barcodeScan): ?Part return $this->resolvePartFromLCSC($barcodeScan); } - throw new \InvalidArgumentException("Unknown barcode scan result type: ".get_class($barcodeScan)); + throw new \InvalidArgumentException("Barcode does not support resolving to a local entity: ".get_class($barcodeScan)); } - private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): ?Part + /** + * Tries to resolve a Part from the given barcode scan result. Returns null if no part could be found for the given barcode, + * or the barcode doesn't contain information allowing to resolve to a local part. + * @param BarcodeScanResultInterface $barcodeScan + * @return Part|null + * @throws \InvalidArgumentException if the barcode scan result type is unknown and cannot be handled this function + */ + public function resolvePart(BarcodeScanResultInterface $barcodeScan): ?Part { - 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; + $entity = $this->resolveEntity($barcodeScan); + if ($entity instanceof Part) { + return $entity; + } + if ($entity instanceof PartLot) { + return $entity->getPart(); } + //Storage locations are not associated with a specific part, so we cannot resolve a part for + //a storage location barcode + return null; + } + + private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): Part|PartLot|StorageLocation|null + { + return match ($barcodeScan->target_type) { + LabelSupportedElement::PART => $this->em->find(Part::class, $barcodeScan->target_id), + LabelSupportedElement::PART_LOT => $this->em->find(PartLot::class, $barcodeScan->target_id), + LabelSupportedElement::STORELOCATION => $this->em->find(StorageLocation::class, $barcodeScan->target_id), + }; } /** From 8dd972f1adb9f1984eb3107134ec71016b1fe564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 12:37:59 +0100 Subject: [PATCH 29/40] Moved barcode to info provider logic from Controller to BarcodeScanResultHandler service --- src/Controller/ScanController.php | 102 +++-------------- .../InfoProviderNotActiveException.php | 48 ++++++++ .../InfoProviderSystem/PartInfoRetriever.php | 7 +- .../BarcodeScanResultHandler.php | 107 +++++++++++++++++- 4 files changed, 170 insertions(+), 94 deletions(-) create mode 100644 src/Exceptions/InfoProviderNotActiveException.php diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 55bf2e221..55f6429b9 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -41,6 +41,7 @@ namespace App\Controller; +use App\Exceptions\InfoProviderNotActiveException; use App\Form\LabelSystem\ScanDialogType; use App\Services\InfoProviderSystem\Providers\LCSCProvider; use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler; @@ -71,11 +72,11 @@ class ScanController extends AbstractController { public function __construct( - protected BarcodeScanResultHandler $barcodeParser, - protected BarcodeScanHelper $barcodeNormalizer, + protected BarcodeScanResultHandler $resultHandler, + 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 @@ -103,7 +104,7 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n // If not in info mode, mimic “normal scan” behavior: redirect if possible. if (!$infoMode) { try { - $url = $this->barcodeParser->getInfoURL($scan); + $url = $this->resultHandler->getInfoURL($scan); return $this->redirect($url); } catch (EntityNotFoundException) { // Decoded OK, but no part is found. If it’s a vendor code, redirect to create. @@ -153,7 +154,7 @@ public function scanQRCode(string $type, int $id): Response source_type: BarcodeSourceType::INTERNAL ); - return $this->redirect($this->barcodeParser->getInfoURL($scan_result)); + return $this->redirect($this->resultHandler->getInfoURL($scan_result)); } catch (EntityNotFoundException) { $this->addFlash('success', 'scan.qr_not_found'); @@ -168,86 +169,13 @@ public function scanQRCode(string $type, int $id): Response */ private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?string { - // LCSC - if ($scanResult instanceof LCSCBarcodeScanResult) { - $lcscCode = $scanResult->lcscCode; - if ($lcscCode !== null && $lcscCode !== '') { - return $this->generateUrl('info_providers_create_part', [ - 'providerKey' => 'lcsc', - 'providerId' => $lcscCode, - ]); - } - } - - // Mouser / Digi-Key (EIGP114) - if ($scanResult instanceof EIGP114BarcodeScanResult) { - $vendor = $scanResult->guessBarcodeVendor(); - - // Mouser: use supplierPartNumber -> search provider -> provider_id - if ($vendor === 'mouser' - && $scanResult->supplierPartNumber !== null - && $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 && $best->provider_id !== '') { - - return $this->generateUrl('info_providers_create_part', [ - 'providerKey' => 'mouser', - 'providerId' => $best->provider_id, - ]); - } - - $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 $this->generateUrl('info_providers_create_part', [ - 'providerKey' => 'digikey', - 'providerId' => $id, - ]); - } - } catch (\InvalidArgumentException) { - $this->addFlash('warning', 'Digi-Key provider is not installed/enabled'); - return null; - } - } + try { + return $this->resultHandler->getCreationURL($scanResult); + } catch (InfoProviderNotActiveException $e) { + $this->addFlash('error', $e->getMessage()); + } catch (\Throwable) { + $this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.'); + // Don’t break scanning UX if provider lookup fails } return null; @@ -338,7 +266,7 @@ public function lookup(Request $request): JsonResponse $targetFound = false; try { - $redirectUrl = $this->barcodeParser->getInfoURL($scan); + $redirectUrl = $this->resultHandler->getInfoURL($scan); $targetFound = true; } catch (EntityNotFoundException) { } @@ -350,7 +278,7 @@ public function lookup(Request $request): JsonResponse $locations = []; if ($targetFound) { - $part = $this->barcodeParser->resolvePart($scan); + $part = $this->resultHandler->resolvePart($scan); if ($part instanceof Part) { $partName = $part->getName(); diff --git a/src/Exceptions/InfoProviderNotActiveException.php b/src/Exceptions/InfoProviderNotActiveException.php new file mode 100644 index 000000000..02f7cfb77 --- /dev/null +++ b/src/Exceptions/InfoProviderNotActiveException.php @@ -0,0 +1,48 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Exceptions; + +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; + +/** + * An exception denoting that a required info provider is not active. This can be used to display a user-friendly error message, + * when a user tries to use an info provider that is not active. + */ +class InfoProviderNotActiveException extends \RuntimeException +{ + public function __construct(public readonly string $providerKey, public readonly string $friendlyName) + { + parent::__construct(sprintf('The info provider "%s" (%s) is not active.', $this->friendlyName, $this->providerKey)); + } + + /** + * Creates an instance of this exception from an info provider instance + * @param InfoProviderInterface $provider + * @return self + */ + public static function fromProvider(InfoProviderInterface $provider): self + { + return new self($provider->getProviderKey(), $provider->getProviderInfo()['name'] ?? '???'); + } +} diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 9a24f3aeb..27474b926 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -24,6 +24,7 @@ namespace App\Services\InfoProviderSystem; use App\Entity\Parts\Part; +use App\Exceptions\InfoProviderNotActiveException; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; @@ -49,6 +50,7 @@ public function __construct(private readonly ProviderRegistry $provider_registry * @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances * @param string $keyword The keyword to search for * @return SearchResultDTO[] The search results + * @throws InfoProviderNotActiveException if any of the given providers is not active */ public function searchByKeyword(string $keyword, array $providers): array { @@ -61,7 +63,7 @@ public function searchByKeyword(string $keyword, array $providers): array //Ensure that the provider is active if (!$provider->isActive()) { - throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!"); + throw InfoProviderNotActiveException::fromProvider($provider); } if (!$provider instanceof InfoProviderInterface) { @@ -97,6 +99,7 @@ protected function searchInProvider(InfoProviderInterface $provider, string $key * @param string $provider_key * @param string $part_id * @return PartDetailDTO + * @throws InfoProviderNotActiveException if the the given providers is not active */ public function getDetails(string $provider_key, string $part_id): PartDetailDTO { @@ -104,7 +107,7 @@ public function getDetails(string $provider_key, string $part_id): PartDetailDTO //Ensure that the provider is active if (!$provider->isActive()) { - throw new \RuntimeException("The provider with key $provider_key is not active!"); + throw InfoProviderNotActiveException::fromProvider($provider); } //Generate key and escape reserved characters from the provider id diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php index b7293ca7f..3f868cf7d 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -46,7 +46,10 @@ use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; use App\Entity\Parts\StorageLocation; +use App\Exceptions\InfoProviderNotActiveException; use App\Repository\Parts\PartRepository; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\ProviderRegistry; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityNotFoundException; use InvalidArgumentException; @@ -59,7 +62,8 @@ */ final readonly class BarcodeScanResultHandler { - public function __construct(private UrlGeneratorInterface $urlGenerator, private EntityManagerInterface $em) + public function __construct(private UrlGeneratorInterface $urlGenerator, private EntityManagerInterface $em, private PartInfoRetriever $infoRetriever, + private ProviderRegistry $providerRegistry) { } @@ -96,11 +100,33 @@ public function getInfoURL(BarcodeScanResultInterface $barcodeScan): string throw new \LogicException("Resolved entity is of unknown type: ".get_class($entity)); } + /** + * Returns a URL to create a new part based on this barcode scan result, if possible. + * @param BarcodeScanResultInterface $scanResult + * @return string|null + * @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system + */ + public function getCreationURL(BarcodeScanResultInterface $scanResult): ?string + { + $infos = $this->getCreateInfos($scanResult); + if ($infos === null) { + return null; + } + + //Ensure that the provider is active, otherwise we should not generate a creation URL for it + $provider = $this->providerRegistry->getProviderByKey($infos['providerKey']); + if (!$provider->isActive()) { + throw InfoProviderNotActiveException::fromProvider($provider); + } + + return $this->urlGenerator->generate('info_providers_create_part', ['providerKey' => $infos['providerKey'], 'providerId' => $infos['providerId']]); + } + /** * Tries to resolve the given barcode scan result to a local entity. This can be a Part, a PartLot or a StorageLocation, depending on the type of the barcode and the information contained in it. * Returns null if no matching entity could be found. * @param BarcodeScanResultInterface $barcodeScan - * @return Part|PartLot|StorageLocation + * @return Part|PartLot|StorageLocation|null */ public function resolveEntity(BarcodeScanResultInterface $barcodeScan): Part|PartLot|StorageLocation|null { @@ -113,7 +139,7 @@ public function resolveEntity(BarcodeScanResultInterface $barcodeScan): Part|Par } if ($barcodeScan instanceof GTINBarcodeScanResult) { - return $this->resolvePartFromGTIN($barcodeScan); + return $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]); } if ($barcodeScan instanceof LCSCBarcodeScanResult) { @@ -210,11 +236,82 @@ private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part return $this->em->getRepository(Part::class)->getPartByMPN($pm); } - private function resolvePartFromGTIN(GTINBarcodeScanResult $barcodeScan): ?Part + + /** + * Tries to extract creation information for a part from the given barcode scan result. This can be used to + * automatically fill in the info provider reference of a part, when creating a new part based on the scan result. + * Returns null if no provider information could be extracted from the scan result, or if the scan result type is unknown and cannot be handled by this function. + * It is not necessarily checked that the provider is active, or that the result actually exists on the provider side. + * @param BarcodeScanResultInterface $scanResult + * @return array{providerKey: string, providerId: string}|null + * @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system + */ + public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array { - return $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]); + // LCSC + if ($scanResult instanceof LCSCBarcodeScanResult) { + return [ + 'providerKey' => 'lcsc', + 'providerId' => $scanResult->lcscCode, + ]; + } + + if ($scanResult instanceof EIGP114BarcodeScanResult) { + return $this->getCreationInfoForEIGP114($scanResult); + } + + return null; + } + /** + * @param EIGP114BarcodeScanResult $scanResult + * @return array{providerKey: string, providerId: string}|null + */ + private function getCreationInfoForEIGP114(EIGP114BarcodeScanResult $scanResult): ?array + { + $vendor = $scanResult->guessBarcodeVendor(); + + // Mouser: use supplierPartNumber -> search provider -> provider_id + if ($vendor === 'mouser' && $scanResult->supplierPartNumber !== null + ) { + // Search Mouser using the MPN + $dtos = $this->infoRetriever->searchByKeyword( + keyword: $scanResult->supplierPartNumber, + providers: ["mouser"] + ); + + // If there are results, provider_id is MouserPartNumber (per MouserProvider.php) + $best = $dtos[0] ?? null; + + if ($best !== null) { + return [ + 'providerKey' => 'mouser', + 'providerId' => $best->provider_id, + ]; + } + + return null; + } + + // Digi-Key: can use customerPartNumber or supplierPartNumber directly + if ($vendor === 'digikey') { + return [ + 'providerKey' => 'digikey', + 'providerId' => $scanResult->customerPartNumber ?? $scanResult->supplierPartNumber, + ]; + } + + // Element14: can use supplierPartNumber directly + if ($vendor === 'element14') { + return [ + 'providerKey' => 'element14', + 'providerId' => $scanResult->supplierPartNumber, + ]; + } + + return null; + } } From bfa9b9eee0645e2b8f38f69c400e64ebf01fef4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 14:16:01 +0100 Subject: [PATCH 30/40] Improved augmentented info styling and allow to use it with normal form submit too --- src/Controller/ScanController.php | 113 +++++------------ .../BarcodeScanResultHandler.php | 8 +- src/Twig/AttachmentExtension.php | 25 +++- .../label_system/scanner/_info_mode.html.twig | 119 ++++++++++++++++++ .../scanner/augmented_result.html.twig | 97 -------------- .../label_system/scanner/scanner.html.twig | 32 +---- translations/messages.en.xlf | 32 ++++- 7 files changed, 211 insertions(+), 215 deletions(-) create mode 100644 templates/label_system/scanner/_info_mode.html.twig delete mode 100644 templates/label_system/scanner/augmented_result.html.twig diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 55f6429b9..271ced5dd 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -92,7 +92,6 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n $input = $form['input']->getData(); } - $infoModeData = null; if ($input !== null && $input !== '') { $mode = $form->isSubmitted() ? $form['mode']->getData() : null; @@ -119,7 +118,18 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n } // Info mode fallback: render page with prefilled result - $infoModeData = $scan->getDecodedForInfoMode(); + $decoded = $scan->getDecodedForInfoMode(); + + //Try to resolve to an entity, to enhance info mode with entity-specific data + $dbEntity = $this->resultHandler->resolveEntity($scan); + $resolvedPart = $this->resultHandler->resolvePart($scan); + $openUrl = $this->resultHandler->getInfoURL($scan); + + //If no entity is found, try to create an URL for creating a new part (only for vendor codes) + $createUrl = null; + if ($dbEntity === null) { + $createUrl = $this->buildCreateUrlForScanResult($scan); + } } catch (\Throwable $e) { // Keep fallback user-friendly; avoid 500 @@ -129,7 +139,13 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n return $this->render('label_system/scanner/scanner.html.twig', [ 'form' => $form, - 'infoModeData' => $infoModeData, + + //Info mode + 'decoded' => $decoded ?? null, + 'entity' => $dbEntity ?? null, + 'part' => $resolvedPart ?? null, + 'openUrl' => $openUrl ?? null, + 'createUrl' => $createUrl ?? null, ]); } @@ -181,51 +197,6 @@ private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanRes 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 @@ -261,53 +232,31 @@ public function lookup(Request $request): JsonResponse $decoded = $scan->getDecodedForInfoMode(); - // Determine if this barcode resolves to *anything* (part, lot->part, storelocation) - $redirectUrl = null; - $targetFound = false; - try { - $redirectUrl = $this->resultHandler->getInfoURL($scan); - $targetFound = true; - } catch (EntityNotFoundException) { - } - - // Only resolve Part for part-like targets. Storelocation scans should remain null here. - $part = null; - $partName = null; - $partUrl = null; - $locations = []; - - if ($targetFound) { - $part = $this->resultHandler->resolvePart($scan); - - if ($part instanceof Part) { - $partName = $part->getName(); - $partUrl = $this->generateUrl('app_part_show', ['id' => $part->getID()]); - $locations = $this->buildLocationsForPart($part); - } - } + //Try to resolve to an entity, to enhance info mode with entity-specific data + $dbEntity = $this->resultHandler->resolveEntity($scan); + $resolvedPart = $this->resultHandler->resolvePart($scan); + $openUrl = $this->resultHandler->getInfoURL($scan); - // Create link only when NOT found (vendor codes) + //If no entity is found, try to create an URL for creating a new part (only for vendor codes) $createUrl = null; - if (!$targetFound) { + if ($dbEntity === null) { $createUrl = $this->buildCreateUrlForScanResult($scan); } // Render fragment (use openUrl for universal "Open" link) - $html = $this->renderView('label_system/scanner/augmented_result.html.twig', [ + $html = $this->renderView('label_system/scanner/_info_mode.html.twig', [ 'decoded' => $decoded, - 'found' => $targetFound, - 'openUrl' => $redirectUrl, - 'partName' => $partName, - 'partUrl' => $partUrl, - 'locations' => $locations, + 'entity' => $dbEntity, + 'part' => $resolvedPart, + 'openUrl' => $openUrl, 'createUrl' => $createUrl, ]); return new JsonResponse([ 'ok' => true, - 'found' => $targetFound, - 'redirectUrl' => $redirectUrl, // client redirects only when infoMode=false + 'found' => $openUrl !== null, // we consider the code "found", if we can at least show an info page (even if the part is not found, but we can show the decoded data and a "create" button) + 'redirectUrl' => $openUrl, // client redirects only when infoMode=false 'createUrl' => $createUrl, 'html' => $html, 'infoMode' => $infoMode, diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php index 3f868cf7d..372e976e5 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -71,17 +71,15 @@ public function __construct(private UrlGeneratorInterface $urlGenerator, private * Determines the URL to which the user should be redirected, when scanning a QR code. * * @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan - * @return string the URL to which should be redirected - * - * @throws EntityNotFoundException + * @return string|null the URL to which should be redirected, or null if no suitable URL could be determined for the given barcode scan result */ - public function getInfoURL(BarcodeScanResultInterface $barcodeScan): string + public function getInfoURL(BarcodeScanResultInterface $barcodeScan): ?string { //For other barcodes try to resolve the part first and then redirect to the part page $entity = $this->resolveEntity($barcodeScan); if ($entity === null) { - throw new EntityNotFoundException("No entity could be resolved for the given barcode scan result"); + return null; } if ($entity instanceof Part) { diff --git a/src/Twig/AttachmentExtension.php b/src/Twig/AttachmentExtension.php index 3d5ec6112..23ab7d6ea 100644 --- a/src/Twig/AttachmentExtension.php +++ b/src/Twig/AttachmentExtension.php @@ -23,7 +23,10 @@ namespace App\Twig; use App\Entity\Attachments\Attachment; +use App\Entity\Attachments\AttachmentContainingDBElement; +use App\Entity\Parts\Part; use App\Services\Attachments\AttachmentURLGenerator; +use App\Services\Attachments\PartPreviewGenerator; use App\Services\Misc\FAIconGenerator; use Twig\Attribute\AsTwigFunction; use Twig\Extension\AbstractExtension; @@ -31,7 +34,7 @@ final readonly class AttachmentExtension { - public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator) + public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator, private PartPreviewGenerator $partPreviewGenerator) { } @@ -44,6 +47,26 @@ public function attachmentThumbnail(Attachment $attachment, string $filter_name return $this->attachmentURLGenerator->getThumbnailURL($attachment, $filter_name); } + /** + * Returns the URL of the thumbnail of the given element. Returns null if no thumbnail is available. + * For parts, a special preview image is generated, for other entities, the master picture is used as preview (if available). + */ + #[AsTwigFunction("entity_thumbnail")] + public function entityThumbnail(AttachmentContainingDBElement $element, string $filter_name = 'thumbnail_sm'): ?string + { + if ($element instanceof Part) { + $preview_attachment = $this->partPreviewGenerator->getTablePreviewAttachment($element); + } else { // For other entities, we just use the master picture as preview, if available + $preview_attachment = $element->getMasterPictureAttachment(); + } + + if ($preview_attachment === null) { + return null; + } + + return $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, $filter_name); + } + /** * Return the font-awesome icon type for the given file extension. Returns "file" if no specific icon is available. * Null is allowed for files withot extension diff --git a/templates/label_system/scanner/_info_mode.html.twig b/templates/label_system/scanner/_info_mode.html.twig new file mode 100644 index 000000000..aa72c38bc --- /dev/null +++ b/templates/label_system/scanner/_info_mode.html.twig @@ -0,0 +1,119 @@ +{% import "helper.twig" as helper %} + +{% if decoded is not empty %} +
+ + {% if part %} {# Show detailed info when it is a part #} +
+
+ {% trans %}label_scanner.db_part_found{% endtrans %} + {% if openUrl %} + + {% endif %} + +
+
+
+ +
+ + +
+

{{ part.name }}

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

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

+

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

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

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

+ + {# Decoded barcode fields #} + + + {% for key, value in decoded %} + + + + + {% endfor %} + +
{{ key }}{{ value }}
+ + {# Whitespace under table and Input form fields #} +
+ +{% endif %} diff --git a/templates/label_system/scanner/augmented_result.html.twig b/templates/label_system/scanner/augmented_result.html.twig deleted file mode 100644 index ad57881eb..000000000 --- a/templates/label_system/scanner/augmented_result.html.twig +++ /dev/null @@ -1,97 +0,0 @@ -{% 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 %} - - - - - - - - - {% 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 %} - {% endif %} - {% else %} -
- {% 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 #} -
-
-
-
- -{% endif %} diff --git a/templates/label_system/scanner/scanner.html.twig b/templates/label_system/scanner/scanner.html.twig index afc4a2be7..7275f89d3 100644 --- a/templates/label_system/scanner/scanner.html.twig +++ b/templates/label_system/scanner/scanner.html.twig @@ -20,37 +20,11 @@ -
+
+ {% include "label_system/scanner/_info_mode.html.twig" %} +
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }} {{ form_end(form) }} - - - {% if infoModeData %} -
-
-

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

- - {% if createUrl is defined and createUrl %} - - - - {% endif %} -
- - - - {% for key, value in infoModeData %} - - - - - {% endfor %} - -
{{ key }}{{ value }}
- - {% endif %} - {% endblock %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index f97908831..5b97f32b3 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9539,7 +9539,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g label_scanner.no_locations - Part is not stored at any locations + Part is not stored at any location. @@ -12545,5 +12545,35 @@ Buerklin-API Authentication server: Last stocktake
+ + + label_scanner.open + View details + + + + + label_scanner.db_part_found + Database [part] found for barcode + + + + + label_scanner.part_can_be_created + [Part] can be created + + + + + label_scanner.part_can_be_created.help + No matching [part] was found in the database, but you can create a new [part] based of this barcode. + + + + + label_scanner.part_create_btn + Create [part] from barcode + + From 05ee3157fb6211c2f5bdba2c1359141f4c30c71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 14:33:44 +0100 Subject: [PATCH 31/40] Correctly handle nullable infoURL in ScanController --- src/Controller/ScanController.php | 56 +++++++++++++++---------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 271ced5dd..af043783a 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -95,42 +95,43 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n if ($input !== null && $input !== '') { $mode = $form->isSubmitted() ? $form['mode']->getData() : null; - $infoMode = $form->isSubmitted() ? (bool) $form['info_mode']->getData() : false; + $infoMode = $form->isSubmitted() && $form['info_mode']->getData(); try { - $scan = $this->barcodeNormalizer->scanBarcodeContent((string) $input, $mode ?? null); + $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null); // If not in info mode, mimic “normal scan” behavior: redirect if possible. if (!$infoMode) { - try { - $url = $this->resultHandler->getInfoURL($scan); + + // Try to get an Info URL if possible + $url = $this->resultHandler->getInfoURL($scan); + if ($url !== null) { return $this->redirect($url); - } catch (EntityNotFoundException) { - // Decoded OK, but no part is found. If it’s a vendor code, redirect to create. - $createUrl = $this->buildCreateUrlForScanResult($scan); - if ($createUrl !== null) { - return $this->redirect($createUrl); - } + } - // Otherwise: show “not found” (not “format unknown”) - $this->addFlash('warning', 'scan.qr_not_found'); + //Try to get an creation URL if possible (only for vendor codes) + $createUrl = $this->buildCreateUrlForScanResult($scan); + if ($createUrl !== null) { + return $this->redirect($createUrl); } - } - // Info mode fallback: render page with prefilled result - $decoded = $scan->getDecodedForInfoMode(); + //// Otherwise: show “not found” (not “format unknown”) + $this->addFlash('warning', 'scan.qr_not_found'); + } else { // Info mode + // Info mode fallback: render page with prefilled result + $decoded = $scan->getDecodedForInfoMode(); - //Try to resolve to an entity, to enhance info mode with entity-specific data - $dbEntity = $this->resultHandler->resolveEntity($scan); - $resolvedPart = $this->resultHandler->resolvePart($scan); - $openUrl = $this->resultHandler->getInfoURL($scan); + //Try to resolve to an entity, to enhance info mode with entity-specific data + $dbEntity = $this->resultHandler->resolveEntity($scan); + $resolvedPart = $this->resultHandler->resolvePart($scan); + $openUrl = $this->resultHandler->getInfoURL($scan); - //If no entity is found, try to create an URL for creating a new part (only for vendor codes) - $createUrl = null; - if ($dbEntity === null) { - $createUrl = $this->buildCreateUrlForScanResult($scan); + //If no entity is found, try to create an URL for creating a new part (only for vendor codes) + $createUrl = null; + if ($dbEntity === null) { + $createUrl = $this->buildCreateUrlForScanResult($scan); + } } - } catch (\Throwable $e) { // Keep fallback user-friendly; avoid 500 $this->addFlash('warning', 'scan.format_unknown'); @@ -170,7 +171,7 @@ public function scanQRCode(string $type, int $id): Response source_type: BarcodeSourceType::INTERNAL ); - return $this->redirect($this->resultHandler->getInfoURL($scan_result)); + return $this->redirect($this->resultHandler->getInfoURL($scan_result) ?? throw new EntityNotFoundException("Not found")); } catch (EntityNotFoundException) { $this->addFlash('success', 'scan.qr_not_found'); @@ -179,7 +180,7 @@ public function scanQRCode(string $type, int $id): Response } /** - * Builds a URL for creating a new part based on the barcode data + * Builds a URL for creating a new part based on the barcode data, handles exceptions and shows user-friendly error messages if the provider is not active or if there is an error during URL generation. * @param BarcodeScanResultInterface $scanResult * @return string|null */ @@ -190,8 +191,8 @@ private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanRes } catch (InfoProviderNotActiveException $e) { $this->addFlash('error', $e->getMessage()); } catch (\Throwable) { - $this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.'); // Don’t break scanning UX if provider lookup fails + $this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.'); } return null; @@ -232,7 +233,6 @@ public function lookup(Request $request): JsonResponse $decoded = $scan->getDecodedForInfoMode(); - //Try to resolve to an entity, to enhance info mode with entity-specific data $dbEntity = $this->resultHandler->resolveEntity($scan); $resolvedPart = $this->resultHandler->resolvePart($scan); From 910ad939df1b0e4ae7b3cda0f3113bda7d5801a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 19:14:35 +0100 Subject: [PATCH 32/40] Replaced the custom controller for fragment replacements with symfony streams This does not require a complete new endpoint --- assets/controllers/common/toast_controller.js | 6 +- .../pages/barcode_scan_controller.js | 118 +----------------- src/Controller/ScanController.php | 85 +++---------- templates/_turbo_control.html.twig | 28 +++-- .../label_system/scanner/scanner.html.twig | 22 ++-- 5 files changed, 63 insertions(+), 196 deletions(-) diff --git a/assets/controllers/common/toast_controller.js b/assets/controllers/common/toast_controller.js index 36b7f3cc6..196692fb0 100644 --- a/assets/controllers/common/toast_controller.js +++ b/assets/controllers/common/toast_controller.js @@ -20,6 +20,10 @@ import { Controller } from '@hotwired/stimulus'; import { Toast } from 'bootstrap'; +/** + * The purpose of this controller, is to show all containers. + * They should already be added via turbo-streams, but have to be called for to show them. + */ export default class extends Controller { connect() { //Move all toasts from the page into our toast container and show them @@ -33,4 +37,4 @@ export default class extends Controller { const toast = new Toast(this.element); toast.show(); } -} \ No newline at end of file +} diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index 352c527cd..ae51e9516 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -30,9 +30,9 @@ export default class extends Controller { _submitting = false; _lastDecodedText = ""; _onInfoChange = null; - _onFormSubmit = null; connect() { + // Prevent double init if connect fires twice if (this._scanner) return; @@ -45,22 +45,6 @@ export default class extends Controller { info.addEventListener("change", this._onInfoChange); } - // Stop camera cleanly before manual form submit (prevents broken camera after reload) - const form = document.getElementById("scan_dialog_form"); - if (form) { - this._onFormSubmit = () => { - try { - const p = this._scanner?.clear?.(); - if (p && typeof p.then === "function") p.catch(() => {}); - } catch (_) { - // ignore - } - }; - - // capture=true so we run before other handlers / navigation - form.addEventListener("submit", this._onFormSubmit, { capture: true }); - } - const isMobile = window.matchMedia("(max-width: 768px)").matches; //This function ensures, that the qrbox is 70% of the total viewport @@ -94,10 +78,10 @@ export default class extends Controller { } disconnect() { + // 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) @@ -107,13 +91,6 @@ export default class extends Controller { } this._onInfoChange = null; - // remove the onForm submit handler - const form = document.getElementById("scan_dialog_form"); - if (form && this._onFormSubmit) { - form.removeEventListener("submit", this._onFormSubmit, { capture: true }); - } - this._onFormSubmit = null; - if (!scanner) return; try { @@ -125,7 +102,7 @@ export default class extends Controller { } - async onScanSuccess(decodedText) { + onScanSuccess(decodedText) { if (!decodedText) return; const normalized = String(decodedText).trim(); @@ -134,94 +111,11 @@ export default class extends Controller { // 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; - - // Clear previous augmented result immediately to avoid stale info - // lingering when the next scan is not augmented (or is transient/junk). - const el = document.getElementById("scan-augmented-result"); - if (el) el.innerHTML = ""; - - //Put our decoded Text into the input box - 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; - } - - // If info mode is OFF and part was NOT found, redirect to create part URL - if (!infoMode && !data.found && data.createUrl) { - window.location.assign(data.createUrl); - 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(); + document.getElementById('scan_dialog_input').value = decodedText; + //Submit form + document.getElementById('scan_dialog_form').requestSubmit(); } } diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index af043783a..6acdc16c4 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -64,6 +64,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use App\Entity\Parts\Part; use \App\Entity\Parts\StorageLocation; +use Symfony\UX\Turbo\TurboBundle; /** * @see \App\Tests\Controller\ScanControllerTest @@ -131,6 +132,18 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n if ($dbEntity === null) { $createUrl = $this->buildCreateUrlForScanResult($scan); } + + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + return $this->renderBlock('label_system/scanner/scanner.html.twig', 'scan_results', [ + 'decoded' => $decoded, + 'entity' => $dbEntity, + 'part' => $resolvedPart, + 'openUrl' => $openUrl, + 'createUrl' => $createUrl, + ]); + } + } } catch (\Throwable $e) { // Keep fallback user-friendly; avoid 500 @@ -138,6 +151,13 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n } } + //When we reach here, only the flash messages are relevant, so if it's a Turbo request, only send the flash message fragment, so the client can show it without a full page reload + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + //Only send our flash message, so the client can show it without a full page reload + return $this->renderBlock('_turbo_control.html.twig', 'flashes'); + } + return $this->render('label_system/scanner/scanner.html.twig', [ 'form' => $form, @@ -197,69 +217,4 @@ private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanRes return null; } - - /** - * 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($request->request->getString('input', '')); - - // We cannot use getEnum here, because we get an empty string for mode, when auto mode is selected - $mode = $request->request->getString('mode', ''); - if ($mode === '') { - $modeEnum = null; - } else { - $modeEnum = BarcodeSourceType::from($mode); // validate enum value; will throw if invalid - } - - $infoMode = $request->request->getBoolean('info_mode', false); - - if ($input === '') { - return new JsonResponse(['ok' => false], 200); - } - - 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(); - - //Try to resolve to an entity, to enhance info mode with entity-specific data - $dbEntity = $this->resultHandler->resolveEntity($scan); - $resolvedPart = $this->resultHandler->resolvePart($scan); - $openUrl = $this->resultHandler->getInfoURL($scan); - - //If no entity is found, try to create an URL for creating a new part (only for vendor codes) - $createUrl = null; - if ($dbEntity === null) { - $createUrl = $this->buildCreateUrlForScanResult($scan); - } - - // Render fragment (use openUrl for universal "Open" link) - $html = $this->renderView('label_system/scanner/_info_mode.html.twig', [ - 'decoded' => $decoded, - 'entity' => $dbEntity, - 'part' => $resolvedPart, - 'openUrl' => $openUrl, - 'createUrl' => $createUrl, - ]); - - return new JsonResponse([ - 'ok' => true, - 'found' => $openUrl !== null, // we consider the code "found", if we can at least show an info page (even if the part is not found, but we can show the decoded data and a "create" button) - 'redirectUrl' => $openUrl, // client redirects only when infoMode=false - 'createUrl' => $createUrl, - 'html' => $html, - 'infoMode' => $infoMode, - ], 200); - } } diff --git a/templates/_turbo_control.html.twig b/templates/_turbo_control.html.twig index 90ae8d9a3..cf65f0dae 100644 --- a/templates/_turbo_control.html.twig +++ b/templates/_turbo_control.html.twig @@ -1,14 +1,20 @@ -{# Insert flashes #} -
- {% for label, messages in app.flashes() %} - {% for message in messages %} - {{ include('_toast.html.twig', { - 'label': label, - 'message': message - }) }} - {% endfor %} - {% endfor %} -
+{% block flashes %} + {# Insert flashes #} + + + +{% endblock %} {# Allow pages to request a fully reload of everything #} {% if global_reload_needed is defined and global_reload_needed %} diff --git a/templates/label_system/scanner/scanner.html.twig b/templates/label_system/scanner/scanner.html.twig index 7275f89d3..95059eb32 100644 --- a/templates/label_system/scanner/scanner.html.twig +++ b/templates/label_system/scanner/scanner.html.twig @@ -10,7 +10,6 @@
-
@@ -18,13 +17,22 @@
-
+
+ {% include "label_system/scanner/_info_mode.html.twig" %} +
-
- {% include "label_system/scanner/_info_mode.html.twig" %} -
+ {{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }} - {{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }} + {{ form_end(form) }} +
+{% endblock %} - {{ form_end(form) }} +{% block scan_results %} + + + {% endblock %} From 4fd78c04ebaa613d988271689ff96b7910d07b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 19:16:46 +0100 Subject: [PATCH 33/40] Removed data-lookup-url attribute from scan read box --- templates/label_system/scanner/scanner.html.twig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/label_system/scanner/scanner.html.twig b/templates/label_system/scanner/scanner.html.twig index 95059eb32..f9b513884 100644 --- a/templates/label_system/scanner/scanner.html.twig +++ b/templates/label_system/scanner/scanner.html.twig @@ -11,8 +11,7 @@
-
+
From 0805a8342a8cc67e1949c4d7ee9a426fc882aebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 19:19:29 +0100 Subject: [PATCH 34/40] Removed unused translations --- translations/messages.en.xlf | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 5b97f32b3..0ea7e745e 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9518,12 +9518,6 @@ 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 @@ -9542,12 +9536,6 @@ Please note, that you can not impersonate a disabled user. If you try you will g Part is not stored at any location. - - - label_scanner.qr_part_no_found - No part found for scanned barcode, click button above to Create Part - - label_generator.edit_profiles From be767fcacfcad28aa76dddbe70c2ae037e831eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 19:28:03 +0100 Subject: [PATCH 35/40] Added basic info block when an storage location was found for an barcode --- .../label_system/scanner/_info_mode.html.twig | 35 +++++++++++++++++++ translations/messages.en.xlf | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/templates/label_system/scanner/_info_mode.html.twig b/templates/label_system/scanner/_info_mode.html.twig index aa72c38bc..92da61c2f 100644 --- a/templates/label_system/scanner/_info_mode.html.twig +++ b/templates/label_system/scanner/_info_mode.html.twig @@ -86,8 +86,43 @@ + + {% elseif entity %} {# If we have an entity but that is not an part #} + +
+
+ {% trans %}label_scanner.target_found{% endtrans %}: {{ type_label(entity) }} + {% if openUrl %} +
+ + + +
+ {% endif %} + +
+
+
+ +
+ + +
+

{{ entity.name }}

+

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

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

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

diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 0ea7e745e..31bc38841 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9521,7 +9521,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g label_scanner.target_found - Item Found + Item found in database From b1b66e8b4f4bea8775760c6da719d48ddbf6d84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 19:30:28 +0100 Subject: [PATCH 36/40] Fixed phpstan issues --- src/Controller/ScanController.php | 2 -- src/Services/InfoProviderSystem/PartInfoRetriever.php | 8 ++++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 6acdc16c4..65eccf279 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -75,8 +75,6 @@ class ScanController extends AbstractController public function __construct( protected BarcodeScanResultHandler $resultHandler, protected BarcodeScanHelper $barcodeNormalizer, - private readonly ProviderRegistry $providerRegistry, - private readonly PartInfoRetriever $infoRetriever, ) {} #[Route(path: '', name: 'scan_dialog')] diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 27474b926..5cc23f051 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -25,10 +25,14 @@ use App\Entity\Parts\Part; use App\Exceptions\InfoProviderNotActiveException; +use App\Exceptions\OAuthReconnectRequiredException; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use Psr\Http\Client\ClientExceptionInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -51,6 +55,10 @@ public function __construct(private readonly ProviderRegistry $provider_registry * @param string $keyword The keyword to search for * @return SearchResultDTO[] The search results * @throws InfoProviderNotActiveException if any of the given providers is not active + * @throws ClientException if any of the providers throws an exception during the search + * @throws \InvalidArgumentException if any of the given providers is not a valid provider key or instance + * @throws TransportException if any of the providers throws an exception during the search + * @throws OAuthReconnectRequiredException if any of the providers throws an exception during the search that indicates that the OAuth token needs to be refreshed */ public function searchByKeyword(string $keyword, array $providers): array { From 68bcc391e37e15223e4502488b92e90abf77f26e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 19:35:59 +0100 Subject: [PATCH 37/40] Fixed tests --- tests/Controller/ScanControllerTest.php | 55 ------------------- .../BarcodeScanResultHandlerTest.php | 33 +++-------- 2 files changed, 7 insertions(+), 81 deletions(-) diff --git a/tests/Controller/ScanControllerTest.php b/tests/Controller/ScanControllerTest.php index 64065878e..b504cd292 100644 --- a/tests/Controller/ScanControllerTest.php +++ b/tests/Controller/ScanControllerTest.php @@ -51,59 +51,4 @@ public function testScanQRCode(): void $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/BarcodeScanResultHandlerTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php index 9bcc40917..e92ebd56d 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php @@ -80,12 +80,13 @@ public function testGetRedirectURL(LocalBarcodeScanResult $scanResult, string $u $this->assertSame($url, $this->service->getInfoURL($scanResult)); } - public function testGetRedirectEntityNotFount(): void + public function testGetRedirectEntityNotFound(): void { - $this->expectException(EntityNotFoundException::class); - //If we encounter an invalid lot, we must throw an exception - $this->service->getInfoURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, + //If we encounter an invalid lot, we must get an null result + $url = $this->service->getInfoURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 12_345_678, BarcodeSourceType::INTERNAL)); + + $this->assertNull($url); } public function testGetRedirectURLThrowsOnUnknownScanType(): void @@ -101,19 +102,12 @@ public function getDecodedForInfoMode(): array $this->service->getInfoURL($unknown); } - public function testEIGPBarcodeWithoutSupplierPartNumberThrowsEntityNotFound(): void - { - $scan = new EIGP114BarcodeScanResult([]); - - $this->expectException(EntityNotFoundException::class); - $this->service->getInfoURL($scan); - } - public function testEIGPBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void { $scan = new EIGP114BarcodeScanResult([]); $this->assertNull($this->service->resolvePart($scan)); + $this->assertNull($this->service->getInfoURL($scan)); } public function testLCSCBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void @@ -124,19 +118,6 @@ public function testLCSCBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void ); $this->assertNull($this->service->resolvePart($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() - rawInput: '{pc:C0000000,pm:}' - ); - - $this->expectException(EntityNotFoundException::class); - $this->service->getInfoURL($scan); + $this->assertNull($this->service->getInfoURL($scan)); } } From 38431e88a441953ba057c4cc1b539510634f6147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 19:48:57 +0100 Subject: [PATCH 38/40] Fixed part image for mobile view --- assets/css/app/images.css | 6 ++++++ templates/label_system/scanner/_info_mode.html.twig | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/assets/css/app/images.css b/assets/css/app/images.css index 0212a85b7..7fa23a9ec 100644 --- a/assets/css/app/images.css +++ b/assets/css/app/images.css @@ -58,6 +58,12 @@ object-fit: contain; } +@media (max-width: 768px) { + .part-info-image { + max-height: 100px; + } +} + .object-fit-cover { object-fit: cover; } diff --git a/templates/label_system/scanner/_info_mode.html.twig b/templates/label_system/scanner/_info_mode.html.twig index 92da61c2f..23deb6d37 100644 --- a/templates/label_system/scanner/_info_mode.html.twig +++ b/templates/label_system/scanner/_info_mode.html.twig @@ -18,13 +18,13 @@
-
+
-
+

{{ part.name }}

{{ part.description | format_markdown(true) }}
@@ -103,13 +103,13 @@
-
+
-
+

{{ entity.name }}

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

From 62a44a4fcb5040905218ae8026e92900189ad8f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 21:22:18 +0100 Subject: [PATCH 39/40] Added more tests for BarcodeScanResultHandler service --- .../BarcodeScanResultHandlerTest.php | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php index e92ebd56d..94c3c2879 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php @@ -41,6 +41,9 @@ namespace App\Tests\Services\LabelSystem\BarcodeScanner; +use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; +use App\Entity\Parts\StorageLocation; use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -120,4 +123,61 @@ public function testLCSCBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void $this->assertNull($this->service->resolvePart($scan)); $this->assertNull($this->service->getInfoURL($scan)); } + + public function testResolveEntityThrowsOnUnknownScanType(): void + { + $unknown = new class implements BarcodeScanResultInterface { + public function getDecodedForInfoMode(): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->service->resolvePart($unknown); + } + + public function testResolveEntity(): void + { + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolveEntity($scan); + + $this->assertSame(1, $part->getId()); + $this->assertInstanceOf(Part::class, $part); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL); + $entity = $this->service->resolveEntity($scan); + $this->assertSame(1, $entity->getId()); + $this->assertInstanceOf(PartLot::class, $entity); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL); + $entity = $this->service->resolveEntity($scan); + $this->assertSame(1, $entity->getId()); + $this->assertInstanceOf(StorageLocation::class, $entity->getId()); + } + + public function testResolvePart(): void + { + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolvePart($scan); + + $this->assertSame(1, $part->getId()); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolvePart($scan); + $this->assertSame(3, $part->getId()); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolvePart($scan); + $this->assertNull($part); //Store location does not resolve to a part + } + + public function testGetCreateInfos(): void + { + $lcscScan = LCSCBarcodeScanResult::parse('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'); + $infos = $this->service->getCreateInfos($lcscScan); + + $this->assertSame('lcsc', $infos['providerKey']); + $this->assertSame('C138033', $infos['providerId']); + } } From fc1367b26abf31e62a5983c29b013c6e693ab265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 21:26:09 +0100 Subject: [PATCH 40/40] Fixed tests --- .../LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php index 94c3c2879..840e84c06 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php @@ -153,7 +153,7 @@ public function testResolveEntity(): void $scan = new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL); $entity = $this->service->resolveEntity($scan); $this->assertSame(1, $entity->getId()); - $this->assertInstanceOf(StorageLocation::class, $entity->getId()); + $this->assertInstanceOf(StorageLocation::class, $entity); } public function testResolvePart(): void