diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index 29db5de5..e12abce8 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -21,17 +21,21 @@ import {Controller} from "@hotwired/stimulus"; //import * as ZXing from "@zxing/library"; import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode"; +import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller"; /* stimulusFetch: 'lazy' */ export default class extends Controller { _scanner = null; _submitting = false; + _lastDecodedText = ""; connect() { // Prevent double init if connect fires twice if (this._scanner) return; + this.bindModeToggles(); + //This function ensures, that the qrbox is 70% of the total viewport let qrboxFunction = function(viewfinderWidth, viewfinderHeight) { let minEdgePercentage = 0.7; // 70% @@ -65,6 +69,8 @@ export default class extends Controller { const scanner = this._scanner; this._scanner = null; this._submitting = false; + this._lastDecodedText = ""; + this.unbindModeToggles(); if (!scanner) return; @@ -76,15 +82,80 @@ export default class extends Controller { } } + /** + * Add events to Mode checkboxes so they both can't be selected at the same time + */ + bindModeToggles() { + const info = document.getElementById("scan_dialog_info_mode"); + const aug = document.getElementById("scan_dialog_augmented_mode"); + if (!info || !aug) return; + + const onInfoChange = () => { + if (info.checked) aug.checked = false; + }; + const onAugChange = () => { + if (aug.checked) info.checked = false; + }; + + info.addEventListener("change", onInfoChange); + aug.addEventListener("change", onAugChange); + + // Save references so we can remove listeners on disconnect + this._onInfoChange = onInfoChange; + this._onAugChange = onAugChange; + } + + unbindModeToggles() { + const info = document.getElementById("scan_dialog_info_mode"); + const aug = document.getElementById("scan_dialog_augmented_mode"); + if (!info || !aug) return; + + if (this._onInfoChange) info.removeEventListener("change", this._onInfoChange); + if (this._onAugChange) aug.removeEventListener("change", this._onAugChange); + + this._onInfoChange = null; + this._onAugChange = null; + } + + + async onScanSuccess(decodedText) { + if (!decodedText) return; + + const normalized = String(decodedText).trim(); + + // If we already handled this exact barcode and it's still showing, ignore. + if (normalized === this._lastDecodedText) return; + + // If a request/submit is in-flight, ignore scans. if (this._submitting) return; + + // Mark as handled immediately (prevents spam even if callback fires repeatedly) + this._lastDecodedText = normalized; this._submitting = true; //Put our decoded Text into the input box const input = document.getElementById("scan_dialog_input"); if (input) input.value = decodedText; - // Stop scanner BEFORE submitting to avoid camera transition races + const augmented = !!document.getElementById("scan_dialog_augmented_mode")?.checked; + + // If augmented mode: do NOT submit the form. + if (augmented) { + try { + await this.lookupAndRender(decodedText); + } catch (e) { + console.warn("[barcode_scan] augmented lookup failed", e); + // Allow retry on failure by clearing last decoded text + this._lastDecodedText = ""; + } finally { + // allow scanning again + this._submitting = false; + } + return; + } + + // Non-augmented: Stop scanner BEFORE submitting to avoid camera transition races try { if (this._scanner?.clear) { await this._scanner.clear(); @@ -98,4 +169,37 @@ export default class extends Controller { //Submit form document.getElementById("scan_dialog_form")?.requestSubmit(); } + + async lookupAndRender(decodedText) { + const form = document.getElementById("scan_dialog_form"); + if (!form) return; + + // Ensure the hidden csrf field has been converted from placeholder -> real token + cookie set + generateCsrfToken(form); + + const mode = + document.querySelector('input[name="scan_dialog[mode]"]:checked')?.value ?? ""; + + const body = new URLSearchParams(); + body.set("input", decodedText); + if (mode !== "") body.set("mode", mode); + + const headers = { + "Accept": "text/html", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + ...generateCsrfHeaders(form), // adds the special CSRF header Symfony expects (if enabled) + }; + + const resp = await fetch(this.element.dataset.augmentedUrl, { + method: "POST", + headers, + body: body.toString(), + credentials: "same-origin", + }); + + const html = await resp.text(); + + const el = document.getElementById("scan-augmented-result"); + if (el) el.innerHTML = html; + } } diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index 63787767..e9f6bafa 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -57,6 +57,10 @@ use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\Routing\Attribute\Route; use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\ProviderRegistry; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use App\Entity\Parts\Part; +use \App\Entity\Parts\StorageLocation; /** * @see \App\Tests\Controller\ScanControllerTest @@ -98,87 +102,10 @@ class ScanController extends AbstractController // redirect user to part page return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); } catch (EntityNotFoundException) { - // Fallback: show decoded info like info-mode as part does not exist + // Part not found -> show decoded info + optional "create part" link $infoModeData = $scan_result->getDecodedForInfoMode(); - $locale = $request->getLocale(); - - // If it's an LCSC scan, offer "create part" link - if ($scan_result instanceof LCSCBarcodeScanResult) { - $lcscCode = $scan_result->getPC(); - - if (is_string($lcscCode) && $lcscCode !== '') { - // Prefer generating a relative URL; browser will use current host - $createUrl = "/{$locale}/part/from_info_provider/lcsc/{$lcscCode}/create"; - } - } - - // If EIGP114 (Mouser / Digi-Key), offer "create part" link - if ($scan_result instanceof EIGP114BarcodeScanResult) { - // Use guessed vendor and supplierPartNumber. - $vendor = $scan_result->guessBarcodeVendor(); - - if ($vendor === 'mouser' && is_string($scan_result->supplierPartNumber) - && $scan_result->supplierPartNumber !== '') { - - try { - $mouserProvider = $this->providerRegistry->getProviderByKey('mouser'); - - if (!$mouserProvider->isActive()) { - $this->addFlash('warning', 'Mouser provider is disabled / not configured.'); - } else { - // Search Mouser using the MPN - $dtos = $this->infoRetriever->searchByKeyword( - keyword: $scan_result->supplierPartNumber, - providers: [$mouserProvider] - ); - - // If there are results, provider_id is MouserPartNumber (per MouserProvider.php) - $best = $dtos[0] ?? null; - - if ($best !== null && is_string($best->provider_id) && $best->provider_id !== '') { - $createUrl = '/' - . rawurlencode($locale) - . '/part/from_info_provider/mouser/' - . rawurlencode($best->provider_id) - . '/create'; - } else { - $this->addFlash('warning', 'No Mouser match found for this MPN.'); - } - } - } catch (\InvalidArgumentException $e) { - // provider key not found in registry - $this->addFlash('warning', 'Mouser provider is not installed/enabled.'); - } catch (\Throwable $e) { - // Don’t break scanning UX if provider lookup fails - $this->addFlash('warning', 'Mouser lookup failed: ' . $e->getMessage()); - } - } - - // Digikey can keep using customerPartNumber if present (it is in their barcode) - if ($vendor === 'digikey') { - - try { - $provider = $this->providerRegistry->getProviderByKey('digikey'); - - if (!$provider->isActive()) { - $this->addFlash('warning', 'Digi-Key provider is disabled / not configured (API key missing).'); - } else { - $id = $scan_result->customerPartNumber ?: $scan_result->supplierPartNumber; - - if (is_string($id) && $id !== '') { - $createUrl = '/' - . rawurlencode($locale) - . '/part/from_info_provider/digikey/' - . rawurlencode($id) - . '/create'; - } - } - } catch (\InvalidArgumentException $e) { - $this->addFlash('warning', 'Digi-Key provider is not installed/enabled'); - } - } - } + $createUrl = $this->buildCreateUrlForScanResult($scan_result, $request->getLocale()); if ($createUrl === null) { $this->addFlash('warning', 'scan.qr_not_found'); @@ -227,4 +154,202 @@ class ScanController extends AbstractController return $this->redirectToRoute('homepage'); } } + + /** + * Builds a URL for creating a new part based on the barcode data + * @param object $scanResult + * @param string $locale + * @return string|null + */ + private function buildCreateUrlForScanResult(object $scanResult, string $locale): ?string + { + // LCSC + if ($scanResult instanceof LCSCBarcodeScanResult) { + $lcscCode = $scanResult->getPC(); + if (is_string($lcscCode) && $lcscCode !== '') { + return '/' + . rawurlencode($locale) + . '/part/from_info_provider/lcsc/' + . rawurlencode($lcscCode) + . '/create'; + } + } + + // Mouser / Digi-Key (EIGP114) + if ($scanResult instanceof EIGP114BarcodeScanResult) { + $vendor = $scanResult->guessBarcodeVendor(); + + // Mouser: use supplierPartNumber -> search provider -> provider_id + if ($vendor === 'mouser' + && is_string($scanResult->supplierPartNumber) + && $scanResult->supplierPartNumber !== '' + ) { + try { + $mouserProvider = $this->providerRegistry->getProviderByKey('mouser'); + + if (!$mouserProvider->isActive()) { + $this->addFlash('warning', 'Mouser provider is disabled / not configured.'); + return null; + } + // Search Mouser using the MPN + $dtos = $this->infoRetriever->searchByKeyword( + keyword: $scanResult->supplierPartNumber, + providers: [$mouserProvider] + ); + + // If there are results, provider_id is MouserPartNumber (per MouserProvider.php) + $best = $dtos[0] ?? null; + + if ($best !== null && is_string($best->provider_id) && $best->provider_id !== '') { + return '/' + . rawurlencode($locale) + . '/part/from_info_provider/mouser/' + . rawurlencode($best->provider_id) + . '/create'; + } + + $this->addFlash('warning', 'No Mouser match found for this MPN.'); + return null; + } catch (\InvalidArgumentException) { + // provider key not found in registry + $this->addFlash('warning', 'Mouser provider is not installed/enabled.'); + return null; + } catch (\Throwable $e) { + // Don’t break scanning UX if provider lookup fails + $this->addFlash('warning', 'Mouser lookup failed: ' . $e->getMessage()); + return null; + } + } + + // Digi-Key: can use customerPartNumber or supplierPartNumber directly + if ($vendor === 'digikey') { + try { + $provider = $this->providerRegistry->getProviderByKey('digikey'); + + if (!$provider->isActive()) { + $this->addFlash('warning', 'Digi-Key provider is disabled / not configured (API key missing).'); + return null; + } + + $id = $scanResult->customerPartNumber ?: $scanResult->supplierPartNumber; + + if (is_string($id) && $id !== '') { + return '/' + . rawurlencode($locale) + . '/part/from_info_provider/digikey/' + . rawurlencode($id) + . '/create'; + } + } catch (\InvalidArgumentException) { + $this->addFlash('warning', 'Digi-Key provider is not installed/enabled'); + return null; + } + } + } + + return null; + } + + private function buildLocationsForPart(Part $part): array + { + $byLocationId = []; + + foreach ($part->getPartLots() as $lot) { + $loc = $lot->getStorageLocation(); + if ($loc === null) { + continue; + } + + $locId = $loc->getID(); + $qty = $lot->getAmount(); + + if (!isset($byLocationId[$locId])) { + $byLocationId[$locId] = [ + 'breadcrumb' => $this->buildStorageBreadcrumb($loc), + 'qty' => $qty, + ]; + } else { + $byLocationId[$locId]['qty'] += $qty; + } + } + + return array_values($byLocationId); + } + + private function buildStorageBreadcrumb(StorageLocation $loc): array + { + $items = []; + $cur = $loc; + + // 20 is the overflow limit in src/Entity/Base/AbstractStructuralDBElement.php line ~273 + for ($i = 0; $i < 20 && $cur !== null; $i++) { + $items[] = [ + 'name' => $cur->getName(), + 'url' => $this->generateUrl('part_list_store_location', ['id' => $cur->getID()]), + ]; + + $parent = $cur->getParent(); // inherited from AbstractStructuralDBElement + $cur = ($parent instanceof StorageLocation) ? $parent : null; + } + + return array_reverse($items); + } + + + + + #[Route(path: '/augmented', name: 'scan_augmented', methods: ['POST'])] + public function augmented(Request $request): Response + { + $this->denyAccessUnlessGranted('@tools.label_scanner'); + + $input = (string) $request->request->get('input', ''); + $mode = $request->request->get('mode'); // string|null + + if ($input === '') { + // Return empty fragment or an error fragment; your choice + return new Response('', 200); + } + + $modeEnum = null; + if ($mode !== null && $mode !== '') { + // Radio values are enum integers in your form + $modeEnum = BarcodeSourceType::from((int) $mode); + } + + $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $modeEnum); + $decoded = $scan->getDecodedForInfoMode(); + + $locale = $request->getLocale(); + $part = $this->barcodeParser->resolvePartOrNull($scan); + + $found = $part !== null; + $partName = null; + $partUrl = null; + $locations = []; + $createUrl = null; + + if ($found) { + $partName = $part->getName(); + + // This is the same route BarcodeRedirector uses + $partUrl = $this->generateUrl('app_part_show', ['id' => $part->getID()]); + + // Build locations (see below) + $locations = $this->buildLocationsForPart($part); + + } else { + // Reuse your centralized create-url logic (the helper you already extracted) + $createUrl = $this->buildCreateUrlForScanResult($scan, $locale); + } + + return $this->render('label_system/scanner/augmented_result.html.twig', [ + 'decoded' => $decoded, + 'found' => $found, + 'partName' => $partName, + 'partUrl' => $partUrl, + 'locations' => $locations, + 'createUrl' => $createUrl, + ]); + } } diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php index 0a67467f..d0d609d3 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -85,6 +85,11 @@ class ScanDialogType extends AbstractType 'required' => false, ]); + $builder->add('augmented_mode', CheckboxType::class, [ + 'label' => 'scan_dialog.augmented_mode', + 'required' => false, + ]); + $builder->add('submit', SubmitType::class, [ 'label' => 'scan_dialog.submit', ]); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php index 1364e6c1..3ce25071 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php @@ -216,4 +216,46 @@ final class BarcodeRedirector } throw new EntityNotFoundException(); } + + public function resolvePartOrNull(BarcodeScanResultInterface $barcodeScan): ?Part + { + try { + if ($barcodeScan instanceof LocalBarcodeScanResult) { + return $this->resolvePartFromLocal($barcodeScan); + } + + if ($barcodeScan instanceof EIGP114BarcodeScanResult) { + return $this->getPartFromVendor($barcodeScan); + } + + if ($barcodeScan instanceof LCSCBarcodeScanResult) { + return $this->getPartFromLCSC($barcodeScan); + } + + return null; + } catch (EntityNotFoundException) { + return null; + } + } + + private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): ?Part + { + switch ($barcodeScan->target_type) { + case LabelSupportedElement::PART: + $part = $this->em->find(Part::class, $barcodeScan->target_id); + return $part instanceof Part ? $part : null; + + case LabelSupportedElement::PART_LOT: + $lot = $this->em->find(PartLot::class, $barcodeScan->target_id); + if (!$lot instanceof PartLot) { + return null; + } + return $lot->getPart(); + + default: + // STORELOCATION etc. doesn't map to a Part + return null; + } + } + } diff --git a/templates/label_system/scanner/augmented_result.html.twig b/templates/label_system/scanner/augmented_result.html.twig new file mode 100644 index 00000000..20eec82b --- /dev/null +++ b/templates/label_system/scanner/augmented_result.html.twig @@ -0,0 +1,66 @@ +{% if decoded is not empty %} +
| {% trans %}part_lots.storage_location{% endtrans %} | ++ {% trans %}part_lots.amount{% endtrans %} + | +
|---|---|
+
|
+ + {% if loc.qty is not null %}{{ loc.qty }}{% else %}—{% endif %} + | +