diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index 200dd2a7..b5a96834 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -21,17 +21,30 @@ import {Controller} from "@hotwired/stimulus"; //import * as ZXing from "@zxing/library"; import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode"; +import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller"; /* stimulusFetch: 'lazy' */ + export default class extends Controller { - - //codeReader = null; - _scanner = null; - + _submitting = false; + _lastDecodedText = ""; + _onInfoChange = null; connect() { - console.log('Init Scanner'); + // Prevent double init if connect fires twice + if (this._scanner) return; + + // clear last decoded barcode when state changes on info box + const info = document.getElementById("scan_dialog_info_mode"); + if (info) { + this._onInfoChange = () => { + this._lastDecodedText = ""; + }; + info.addEventListener("change", this._onInfoChange); + } + + const isMobile = window.matchMedia("(max-width: 768px)").matches; //This function ensures, that the qrbox is 70% of the total viewport let qrboxFunction = function(viewfinderWidth, viewfinderHeight) { @@ -45,31 +58,135 @@ export default class extends Controller { } //Try to get the number of cameras. If the number is 0, then the promise will fail, and we show the warning dialog - Html5Qrcode.getCameras().catch((devices) => { - document.getElementById('scanner-warning').classList.remove('d-none'); + Html5Qrcode.getCameras().catch(() => { + document.getElementById("scanner-warning")?.classList.remove("d-none"); }); this._scanner = new Html5QrcodeScanner(this.element.id, { fps: 10, qrbox: qrboxFunction, + // Key change: shrink preview height on mobile + ...(isMobile ? { aspectRatio: 1.0 } : {}), experimentalFeatures: { //This option improves reading quality on android chrome - useBarCodeDetectorIfSupported: true - } + useBarCodeDetectorIfSupported: true, + }, }, false); this._scanner.render(this.onScanSuccess.bind(this)); } disconnect() { - this._scanner.pause(); - this._scanner.clear(); + // If we already stopped/cleared before submit, nothing to do. + const scanner = this._scanner; + this._scanner = null; + this._submitting = false; + this._lastDecodedText = ""; + + // Unbind info-mode change handler (always do this, even if scanner is null) + const info = document.getElementById("scan_dialog_info_mode"); + if (info && this._onInfoChange) { + info.removeEventListener("change", this._onInfoChange); + } + this._onInfoChange = null; + + if (!scanner) return; + + try { + const p = scanner.clear?.(); + if (p && typeof p.then === "function") p.catch(() => {}); + } catch (_) { + // ignore + } } - onScanSuccess(decodedText, decodedResult) { + + async onScanSuccess(decodedText) { + if (!decodedText) return; + + const normalized = String(decodedText).trim(); + if (!normalized) return; + + // scan once per barcode + if (normalized === this._lastDecodedText) return; + + // If a request/submit is in-flight, ignore scans. + if (this._submitting) return; + + // Mark as handled immediately (prevents spam even if callback fires repeatedly) + this._lastDecodedText = normalized; + this._submitting = true; + //Put our decoded Text into the input box - document.getElementById('scan_dialog_input').value = decodedText; - //Submit form - document.getElementById('scan_dialog_form').requestSubmit(); + const input = document.getElementById("scan_dialog_input"); + if (input) input.value = decodedText; + + const infoMode = !!document.getElementById("scan_dialog_info_mode")?.checked; + + try { + const data = await this.lookup(normalized, infoMode); + + // ok:false = transient junk decode; ignore without wiping UI + if (!data || data.ok !== true) { + this._lastDecodedText = ""; // allow retry + return; + } + + // If info mode is OFF and part was found -> redirect + if (!infoMode && data.found && data.redirectUrl) { + window.location.assign(data.redirectUrl); + return; + } + + // Otherwise render returned fragment HTML + if (typeof data.html === "string" && data.html !== "") { + const el = document.getElementById("scan-augmented-result"); + if (el) el.innerHTML = data.html; + } + } catch (e) { + console.warn("[barcode_scan] lookup failed", e); + // allow retry on failure + this._lastDecodedText = ""; + } finally { + this._submitting = false; + } + } + + + async lookup(decodedText, infoMode) { + const form = document.getElementById("scan_dialog_form"); + if (!form) return { ok: false }; + + generateCsrfToken(form); + + const mode = + document.querySelector('input[name="scan_dialog[mode]"]:checked')?.value ?? ""; + + const body = new URLSearchParams(); + body.set("input", decodedText); + if (mode !== "") body.set("mode", mode); + body.set("info_mode", infoMode ? "1" : "0"); + + const headers = { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + ...generateCsrfHeaders(form), + }; + + const url = this.element.dataset.lookupUrl; + if (!url) throw new Error("Missing data-lookup-url on #reader-box"); + + const resp = await fetch(url, { + method: "POST", + headers, + body: body.toString(), + credentials: "same-origin", + }); + + if (!resp.ok) { + throw new Error(`lookup failed: HTTP ${resp.status}`); + } + + return await resp.json(); } } diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index aebadd89..537be473 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -46,6 +46,8 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector; use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper; use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; use Doctrine\ORM\EntityNotFoundException; use InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -53,6 +55,12 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\Routing\Attribute\Route; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\ProviderRegistry; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use App\Entity\Parts\Part; +use \App\Entity\Parts\StorageLocation; /** * @see \App\Tests\Controller\ScanControllerTest @@ -60,9 +68,12 @@ use Symfony\Component\Routing\Attribute\Route; #[Route(path: '/scan')] class ScanController extends AbstractController { - public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeScanHelper $barcodeNormalizer) - { - } + public function __construct( + protected BarcodeRedirector $barcodeParser, + protected BarcodeScanHelper $barcodeNormalizer, + private readonly ProviderRegistry $providerRegistry, + private readonly PartInfoRetriever $infoRetriever, + ) {} #[Route(path: '', name: 'scan_dialog')] public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response @@ -72,29 +83,33 @@ class ScanController extends AbstractController $form = $this->createForm(ScanDialogType::class); $form->handleRequest($request); + // If JS is working, scanning uses /scan/lookup and this action just renders the page. + // This fallback only runs if user submits the form manually or uses ?input=... if ($input === null && $form->isSubmitted() && $form->isValid()) { $input = $form['input']->getData(); - $mode = $form['mode']->getData(); } $infoModeData = null; - if ($input !== null) { - try { - $scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null); - //Perform a redirect if the info mode is not enabled - if (!$form['info_mode']->getData()) { - try { - return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); - } catch (EntityNotFoundException) { - $this->addFlash('success', 'scan.qr_not_found'); - } - } else { //Otherwise retrieve infoModeData - $infoModeData = $scan_result->getDecodedForInfoMode(); + if ($input !== null && $input !== '') { + $mode = $form->isSubmitted() ? $form['mode']->getData() : null; + $infoMode = $form->isSubmitted() ? (bool) $form['info_mode']->getData() : false; + try { + $scan = $this->barcodeNormalizer->scanBarcodeContent((string) $input, $mode ?? null); + + // If not in info mode, mimic “normal scan” behavior: redirect if possible. + if (!$infoMode) { + $url = $this->barcodeParser->getRedirectURL($scan); + return $this->redirect($url); } - } catch (InvalidArgumentException) { - $this->addFlash('error', 'scan.format_unknown'); + + // Info mode fallback: render page with prefilled result + $infoModeData = $scan->getDecodedForInfoMode(); + + } catch (\Throwable $e) { + // Keep fallback user-friendly; avoid 500 + $this->addFlash('warning', 'scan.format_unknown'); } } @@ -132,4 +147,232 @@ class ScanController extends AbstractController return $this->redirectToRoute('homepage'); } } + + /** + * Builds a URL for creating a new part based on the barcode data + * @param object $scanResult + * @param string $locale + * @return string|null + */ + private function buildCreateUrlForScanResult(object $scanResult, string $locale): ?string + { + // LCSC + if ($scanResult instanceof LCSCBarcodeScanResult) { + $lcscCode = $scanResult->getPC(); + if (is_string($lcscCode) && $lcscCode !== '') { + return '/' + . rawurlencode($locale) + . '/part/from_info_provider/lcsc/' + . rawurlencode($lcscCode) + . '/create'; + } + } + + // Mouser / Digi-Key (EIGP114) + if ($scanResult instanceof EIGP114BarcodeScanResult) { + $vendor = $scanResult->guessBarcodeVendor(); + + // Mouser: use supplierPartNumber -> search provider -> provider_id + if ($vendor === 'mouser' + && is_string($scanResult->supplierPartNumber) + && $scanResult->supplierPartNumber !== '' + ) { + try { + $mouserProvider = $this->providerRegistry->getProviderByKey('mouser'); + + if (!$mouserProvider->isActive()) { + $this->addFlash('warning', 'Mouser provider is disabled / not configured.'); + return null; + } + // Search Mouser using the MPN + $dtos = $this->infoRetriever->searchByKeyword( + keyword: $scanResult->supplierPartNumber, + providers: [$mouserProvider] + ); + + // If there are results, provider_id is MouserPartNumber (per MouserProvider.php) + $best = $dtos[0] ?? null; + + if ($best !== null && is_string($best->provider_id) && $best->provider_id !== '') { + return '/' + . rawurlencode($locale) + . '/part/from_info_provider/mouser/' + . rawurlencode($best->provider_id) + . '/create'; + } + + $this->addFlash('warning', 'No Mouser match found for this MPN.'); + return null; + } catch (\InvalidArgumentException) { + // provider key not found in registry + $this->addFlash('warning', 'Mouser provider is not installed/enabled.'); + return null; + } catch (\Throwable $e) { + // Don’t break scanning UX if provider lookup fails + $this->addFlash('warning', 'Mouser lookup failed: ' . $e->getMessage()); + return null; + } + } + + // Digi-Key: can use customerPartNumber or supplierPartNumber directly + if ($vendor === 'digikey') { + try { + $provider = $this->providerRegistry->getProviderByKey('digikey'); + + if (!$provider->isActive()) { + $this->addFlash('warning', 'Digi-Key provider is disabled / not configured (API key missing).'); + return null; + } + + $id = $scanResult->customerPartNumber ?: $scanResult->supplierPartNumber; + + if (is_string($id) && $id !== '') { + return '/' + . rawurlencode($locale) + . '/part/from_info_provider/digikey/' + . rawurlencode($id) + . '/create'; + } + } catch (\InvalidArgumentException) { + $this->addFlash('warning', 'Digi-Key provider is not installed/enabled'); + return null; + } + } + } + + return null; + } + + private function buildLocationsForPart(Part $part): array + { + $byLocationId = []; + + foreach ($part->getPartLots() as $lot) { + $loc = $lot->getStorageLocation(); + if ($loc === null) { + continue; + } + + $locId = $loc->getID(); + $qty = $lot->getAmount(); + + if (!isset($byLocationId[$locId])) { + $byLocationId[$locId] = [ + 'breadcrumb' => $this->buildStorageBreadcrumb($loc), + 'qty' => $qty, + ]; + } else { + $byLocationId[$locId]['qty'] += $qty; + } + } + + return array_values($byLocationId); + } + + private function buildStorageBreadcrumb(StorageLocation $loc): array + { + $items = []; + $cur = $loc; + + // 20 is the overflow limit in src/Entity/Base/AbstractStructuralDBElement.php line ~273 + for ($i = 0; $i < 20 && $cur !== null; $i++) { + $items[] = [ + 'name' => $cur->getName(), + 'url' => $this->generateUrl('part_list_store_location', ['id' => $cur->getID()]), + ]; + + $parent = $cur->getParent(); // inherited from AbstractStructuralDBElement + $cur = ($parent instanceof StorageLocation) ? $parent : null; + } + + return array_reverse($items); + } + + /** + * Provides XHR endpoint for looking up barcode information and return JSON response + * @param Request $request + * @return JsonResponse + */ + #[Route(path: '/lookup', name: 'scan_lookup', methods: ['POST'])] + public function lookup(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('@tools.label_scanner'); + + $input = trim((string) $request->request->get('input', '')); + $mode = (string) ($request->request->get('mode') ?? ''); + $infoMode = (bool) filter_var($request->request->get('info_mode', false), FILTER_VALIDATE_BOOL); + $locale = $request->getLocale(); + + if ($input === '') { + return new JsonResponse(['ok' => false], 200); + } + + $modeEnum = null; + if ($mode !== '') { + $i = (int) $mode; + $cases = BarcodeSourceType::cases(); + $modeEnum = $cases[$i] ?? null; // null if out of range + } + + try { + $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $modeEnum); + } catch (InvalidArgumentException) { + // Camera sometimes produces garbage decodes for a frame; ignore those. + return new JsonResponse(['ok' => false], 200); + } + + $decoded = $scan->getDecodedForInfoMode(); + + // Determine if this barcode resolves to *anything* (part, lot->part, storelocation) + $redirectUrl = null; + $targetFound = false; + + try { + $redirectUrl = $this->barcodeParser->getRedirectURL($scan); + $targetFound = true; + } catch (EntityNotFoundException) { + $targetFound = false; + } + + // Only resolve Part for part-like targets. Storelocation scans should remain null here. + $part = null; + $partName = null; + $partUrl = null; + $locations = []; + + if ($targetFound) { + $part = $this->barcodeParser->resolvePartOrNull($scan); + + if ($part instanceof Part) { + $partName = $part->getName(); + $partUrl = $this->generateUrl('app_part_show', ['id' => $part->getID()]); + $locations = $this->buildLocationsForPart($part); + } + } + + // Create link only when NOT found (vendor codes) + $createUrl = null; + if (!$targetFound) { + $createUrl = $this->buildCreateUrlForScanResult($scan, $locale); + } + + // Render fragment (use openUrl for universal "Open" link) + $html = $this->renderView('label_system/scanner/augmented_result.html.twig', [ + 'decoded' => $decoded, + 'found' => $targetFound, + 'openUrl' => $redirectUrl, + 'partName' => $partName, + 'partUrl' => $partUrl, + 'locations' => $locations, + 'createUrl' => $createUrl, + ]); + + return new JsonResponse([ + 'ok' => true, + 'found' => $targetFound, + 'redirectUrl' => $redirectUrl, // client redirects only when infoMode=false + 'html' => $html, + 'infoMode' => $infoMode, + ], 200); + } } diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php index 13ff8e6f..0a67467f 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -75,7 +75,8 @@ class ScanDialogType extends AbstractType BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal', BarcodeSourceType::IPN => 'scan_dialog.mode.ipn', BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user', - BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp' + BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp', + BarcodeSourceType::LCSC => 'scan_dialog.mode.lcsc', }, ]); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php index 2de7c035..3ce25071 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php @@ -45,6 +45,7 @@ use App\Entity\LabelSystem\LabelSupportedElement; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; +use App\Repository\Parts\PartRepository; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityNotFoundException; use InvalidArgumentException; @@ -77,6 +78,10 @@ final class BarcodeRedirector return $this->getURLVendorBarcode($barcodeScan); } + if ($barcodeScan instanceof LCSCBarcodeScanResult) { + return $this->getURLLCSCBarcode($barcodeScan); + } + throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan)); } @@ -102,6 +107,54 @@ final class BarcodeRedirector } } + /** + * Gets the URL to a part from a scan of the LCSC Barcode + */ + private function getURLLCSCBarcode(LCSCBarcodeScanResult $barcodeScan): string + { + $part = $this->getPartFromLCSC($barcodeScan); + return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]); + } + + /** + * Resolve LCSC barcode -> Part. + * Strategy: + * 1) Try providerReference.provider_id == pc (LCSC "Cxxxxxx") if you store it there + * 2) Fallback to manufacturer_product_number == pm (MPN) + * Returns first match (consistent with EIGP114 logic) + */ + private function getPartFromLCSC(LCSCBarcodeScanResult $barcodeScan): Part + { + // Try LCSC code (pc) as provider id if available + $pc = $barcodeScan->getPC(); // e.g. C138033 + if ($pc) { + $qb = $this->em->getRepository(Part::class)->createQueryBuilder('part'); + $qb->where($qb->expr()->like('LOWER(part.providerReference.provider_id)', 'LOWER(:vendor_id)')); + $qb->setParameter('vendor_id', $pc); + $results = $qb->getQuery()->getResult(); + if ($results) { + return $results[0]; + } + } + + // Fallback to MPN (pm) + $pm = $barcodeScan->getPM(); // e.g. RC0402FR-071ML + if (!$pm) { + throw new EntityNotFoundException(); + } + + $mpnQb = $this->em->getRepository(Part::class)->createQueryBuilder('part'); + $mpnQb->where($mpnQb->expr()->like('LOWER(part.manufacturer_product_number)', 'LOWER(:mpn)')); + $mpnQb->setParameter('mpn', $pm); + + $results = $mpnQb->getQuery()->getResult(); + if ($results) { + return $results[0]; + } + + throw new EntityNotFoundException(); + } + /** * Gets the URL to a part from a scan of a Vendor Barcode */ @@ -163,4 +216,46 @@ final class BarcodeRedirector } throw new EntityNotFoundException(); } + + public function resolvePartOrNull(BarcodeScanResultInterface $barcodeScan): ?Part + { + try { + if ($barcodeScan instanceof LocalBarcodeScanResult) { + return $this->resolvePartFromLocal($barcodeScan); + } + + if ($barcodeScan instanceof EIGP114BarcodeScanResult) { + return $this->getPartFromVendor($barcodeScan); + } + + if ($barcodeScan instanceof LCSCBarcodeScanResult) { + return $this->getPartFromLCSC($barcodeScan); + } + + return null; + } catch (EntityNotFoundException) { + return null; + } + } + + private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): ?Part + { + switch ($barcodeScan->target_type) { + case LabelSupportedElement::PART: + $part = $this->em->find(Part::class, $barcodeScan->target_id); + return $part instanceof Part ? $part : null; + + case LabelSupportedElement::PART_LOT: + $lot = $this->em->find(PartLot::class, $barcodeScan->target_id); + if (!$lot instanceof PartLot) { + return null; + } + return $lot->getPart(); + + default: + // STORELOCATION etc. doesn't map to a Part + return null; + } + } + } diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php index e5930b36..c9cce95a 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php @@ -92,6 +92,9 @@ final class BarcodeScanHelper if ($type === BarcodeSourceType::EIGP114) { return $this->parseEIGP114Barcode($input); } + if ($type === BarcodeSourceType::LCSC) { + return $this->parseLCSCBarcode($input); + } //Null means auto and we try the different formats $result = $this->parseInternalBarcode($input); @@ -117,6 +120,11 @@ final class BarcodeScanHelper return $result; } + // Try LCSC barcode + if (LCSCBarcodeScanResult::looksLike($input)) { + return $this->parseLCSCBarcode($input); + } + throw new InvalidArgumentException('Unknown barcode'); } @@ -125,6 +133,11 @@ final class BarcodeScanHelper return EIGP114BarcodeScanResult::parseFormat06Code($input); } + private function parseLCSCBarcode(string $input): LCSCBarcodeScanResult + { + return LCSCBarcodeScanResult::parse($input); + } + private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult { $lot_repo = $this->entityManager->getRepository(PartLot::class); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php index 40f707de..8f1dc72e 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php @@ -42,4 +42,7 @@ enum BarcodeSourceType * EIGP114 formatted barcodes like used by digikey, mouser, etc. */ case EIGP114; -} \ No newline at end of file + + /** For LCSC.com formatted QR codes */ + case LCSC; +} diff --git a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php new file mode 100644 index 00000000..236bad48 --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php @@ -0,0 +1,130 @@ + $fields + */ + public function __construct( + public readonly array $fields, + public readonly string $raw_input, + ) {} + + public function getSourceType(): BarcodeSourceType + { + return BarcodeSourceType::LCSC; + } + + /** + * @return string|null The manufactures part number + */ + public function getPM(): ?string + { + $v = $this->fields['pm'] ?? null; + $v = $v !== null ? trim($v) : null; + return ($v === '') ? null : $v; + } + + /** + * @return string|null The lcsc.com part number + */ + public function getPC(): ?string + { + $v = $this->fields['pc'] ?? null; + $v = $v !== null ? trim($v) : null; + return ($v === '') ? null : $v; + } + + /** + * @return array|float[]|int[]|null[]|string[] An array of fields decoded from the barcode + */ + public function getDecodedForInfoMode(): array + { + // Keep it human-friendly + return [ + 'Barcode type' => 'LCSC', + 'MPN (pm)' => $this->getPM() ?? '', + 'LCSC code (pc)' => $this->getPC() ?? '', + 'Qty' => $this->fields['qty'] ?? '', + 'Order No (on)' => $this->fields['on'] ?? '', + 'Pick Batch (pbn)' => $this->fields['pbn'] ?? '', + 'Warehouse (wc)' => $this->fields['wc'] ?? '', + 'Country/Channel (cc)' => $this->fields['cc'] ?? '', + ]; + } + + /** + * Parses the barcode data to see if the input matches the expected format used by lcsc.com + * @param string $input + * @return bool + */ + public static function looksLike(string $input): bool + { + $s = trim($input); + + // Your example: {pbn:...,on:...,pc:...,pm:...,qty:...} + if (!str_starts_with($s, '{') || !str_ends_with($s, '}')) { + return false; + } + + // Must contain at least pm: and pc: (common for LCSC labels) + return (stripos($s, 'pm:') !== false) && (stripos($s, 'pc:') !== false); + } + + /** + * Parse the barcode input string into the fields used by lcsc.com + * @param string $input + * @return self + */ + public static function parse(string $input): self + { + $raw = trim($input); + + if (!self::looksLike($raw)) { + throw new InvalidArgumentException('Not an LCSC barcode'); + } + + $inner = trim($raw); + $inner = substr($inner, 1, -1); // remove { } + + $fields = []; + + // This format is comma-separated pairs, values do not contain commas in your sample. + $pairs = array_filter( + array_map('trim', explode(',', $inner)), + static fn(string $s): bool => $s !== '' + ); + + foreach ($pairs as $pair) { + $pos = strpos($pair, ':'); + if ($pos === false) { + continue; + } + + $k = trim(substr($pair, 0, $pos)); + $v = trim(substr($pair, $pos + 1)); + + if ($k === '') { + continue; + } + + $fields[$k] = $v; + } + + if (!isset($fields['pm']) || trim($fields['pm']) === '') { + throw new InvalidArgumentException('LCSC barcode missing pm field'); + } + + return new self($fields, $raw); + } +} diff --git a/templates/label_system/scanner/augmented_result.html.twig b/templates/label_system/scanner/augmented_result.html.twig new file mode 100644 index 00000000..c31e336a --- /dev/null +++ b/templates/label_system/scanner/augmented_result.html.twig @@ -0,0 +1,97 @@ +{% if decoded is not empty %} +
+ +
+

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

+ + + {% if createUrl %} + + + + {% endif %} +
+ + {% if found %} +
+
+ {% if partName %} + {{ partName }} + {% else %} + {% trans %}label_scanner.target_found{% endtrans %} + {% endif %} +
+ + {% if openUrl %} + + {% trans %}open{% endtrans %} + + {% endif %} +
+ + {% if partName %} + {% if locations is not empty %} + + + + + + + + + {% 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 1f978a9b..ed657839 100644 --- a/templates/label_system/scanner/scanner.html.twig +++ b/templates/label_system/scanner/scanner.html.twig @@ -12,13 +12,16 @@
-
+
+
+ {{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }} {{ form_end(form) }} @@ -26,7 +29,16 @@ {% if infoModeData %}
-

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

+
+

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

+ + {% if createUrl %} + + + + {% endif %} +
diff --git a/tests/Controller/ScanControllerTest.php b/tests/Controller/ScanControllerTest.php index 98992e09..e907e739 100644 --- a/tests/Controller/ScanControllerTest.php +++ b/tests/Controller/ScanControllerTest.php @@ -51,4 +51,59 @@ class ScanControllerTest extends WebTestCase $this->client->request('GET', '/scan/part/1'); $this->assertResponseRedirects('/en/part/1'); } + + public function testLookupReturnsFoundOnKnownPart(): void + { + $this->client->request('POST', '/en/scan/lookup', [ + 'input' => '0000001', + 'mode' => '', + 'info_mode' => 'true', + ]); + + $this->assertResponseIsSuccessful(); + + $data = json_decode((string) $this->client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertTrue($data['ok']); + $this->assertTrue($data['found']); + $this->assertSame('/en/part/1', $data['redirectUrl']); + $this->assertTrue($data['infoMode']); + $this->assertIsString($data['html']); + $this->assertNotSame('', trim($data['html'])); + } + + public function testLookupReturnsNotFoundOnUnknownPart(): void + { + $this->client->request('POST', '/en/scan/lookup', [ + // Use a valid LCSC barcode + 'input' => '{pbn:PICK2407080035,on:WM2407080118,pc:C365735,pm:ES8316,qty:12,mc:,cc:1,pdi:120044290,hp:null,wc:ZH}', + 'mode' => '', + 'info_mode' => 'true', + ]); + + $this->assertResponseIsSuccessful(); + + $data = json_decode((string)$this->client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertTrue($data['ok']); + $this->assertFalse($data['found']); + $this->assertSame(null, $data['redirectUrl']); + $this->assertTrue($data['infoMode']); + $this->assertIsString($data['html']); + $this->assertNotSame('', trim($data['html'])); + } + + public function testLookupReturnsFalseOnGarbageInput(): void + { + $this->client->request('POST', '/en/scan/lookup', [ + 'input' => 'not-a-real-barcode', + 'mode' => '', + 'info_mode' => 'false', + ]); + + $this->assertResponseIsSuccessful(); + + $data = json_decode((string) $this->client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + $this->assertFalse($data['ok']); + } } diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php index c40e141d..563f23cf 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php @@ -49,6 +49,11 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; use Doctrine\ORM\EntityNotFoundException; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface; +use InvalidArgumentException; + final class BarcodeRedirectorTest extends KernelTestCase { @@ -82,4 +87,74 @@ final class BarcodeRedirectorTest extends KernelTestCase $this->service->getRedirectURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 12_345_678, BarcodeSourceType::INTERNAL)); } + + public function testGetRedirectURLThrowsOnUnknownScanType(): void + { + $unknown = new class implements BarcodeScanResultInterface { + public function getDecodedForInfoMode(): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->service->getRedirectURL($unknown); + } + + public function testEIGPBarcodeWithoutSupplierPartNumberThrowsEntityNotFound(): void + { + $scan = new EIGP114BarcodeScanResult([]); + + $this->expectException(EntityNotFoundException::class); + $this->service->getRedirectURL($scan); + } + + public function testEIGPBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void + { + $scan = new EIGP114BarcodeScanResult([]); + + $this->assertNull($this->service->resolvePartOrNull($scan)); + } + + public function testLCSCBarcodeMissingPmThrowsEntityNotFound(): void + { + // pc present but no pm => getPartFromLCSC() will throw EntityNotFoundException + // because it falls back to PM when PC doesn't match anything. + $scan = new LCSCBarcodeScanResult( + fields: ['pc' => 'C0000000', 'pm' => ''], // pm becomes null via getPM() + raw_input: '{pc:C0000000,pm:}' + ); + + $this->expectException(EntityNotFoundException::class); + $this->service->getRedirectURL($scan); + } + + public function testLCSCBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void + { + $scan = new LCSCBarcodeScanResult( + fields: ['pc' => 'C0000000', 'pm' => ''], + raw_input: '{pc:C0000000,pm:}' + ); + + $this->assertNull($this->service->resolvePartOrNull($scan)); + } + + public function testLCSCParseRejectsNonLCSCFormat(): void + { + $this->expectException(InvalidArgumentException::class); + LCSCBarcodeScanResult::parse('not-an-lcsc-barcode'); + } + + public function testLCSCParseExtractsFields(): void + { + $scan = LCSCBarcodeScanResult::parse('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'); + + $this->assertSame('RC0402FR-071ML', $scan->getPM()); + $this->assertSame('C138033', $scan->getPC()); + + $decoded = $scan->getDecodedForInfoMode(); + $this->assertSame('LCSC', $decoded['Barcode type']); + $this->assertSame('RC0402FR-071ML', $decoded['MPN (pm)']); + $this->assertSame('C138033', $decoded['LCSC code (pc)']); + } } diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php index fcea7730..6655f510 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php @@ -49,6 +49,7 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; class BarcodeScanHelperTest extends WebTestCase { @@ -124,6 +125,14 @@ class BarcodeScanHelperTest extends WebTestCase ]); yield [$eigp114Result, "[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04"]; + + $lcscInput = '{pc:C138033,pm:RC0402FR-071ML,qty:10}'; + $lcscResult = new LCSCBarcodeScanResult( + ['pc' => 'C138033', 'pm' => 'RC0402FR-071ML', 'qty' => '10'], + $lcscInput + ); + + yield [$lcscResult, $lcscInput]; } public static function invalidDataProvider(): \Iterator @@ -153,4 +162,33 @@ class BarcodeScanHelperTest extends WebTestCase $this->expectException(\InvalidArgumentException::class); $this->service->scanBarcodeContent($input); } + + public function testAutoDetectLcscBarcode(): void + { + $input = '{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'; + + $result = $this->service->scanBarcodeContent($input); + + $this->assertInstanceOf(LCSCBarcodeScanResult::class, $result); + $this->assertSame('C138033', $result->getPC()); + $this->assertSame('RC0402FR-071ML', $result->getPM()); + } + + public function testLcscExplicitTypeParses(): void + { + $input = '{pc:C138033,pm:RC0402FR-071ML,qty:10}'; + + $result = $this->service->scanBarcodeContent($input, BarcodeSourceType::LCSC); + + $this->assertInstanceOf(LCSCBarcodeScanResult::class, $result); + $this->assertSame('C138033', $result->getPC()); + $this->assertSame('RC0402FR-071ML', $result->getPM()); + } + + public function testLcscExplicitTypeRejectsNonLcsc(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->service->scanBarcodeContent('not-an-lcsc', BarcodeSourceType::LCSC); + } } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index f7f10146..8ae6b777 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12027,6 +12027,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g EIGP 114 barcode (e.g. the datamatrix codes on digikey and mouser orders) + + + scan_dialog.mode.lcsc + LCSC.com barcode + + scan_dialog.info_mode @@ -12039,6 +12045,36 @@ Please note, that you can not impersonate a disabled user. If you try you will g Decoded information + + + label_scanner.part_info.title + Part information + + + + + label_scanner.target_found + Item Found + + + + + label_scanner.scan_result.title + Scan result + + + + + label_scanner.no_locations + Part is not stored at any locations + + + + + label_scanner.qr_part_no_found + No part found for scanned barcode, click button above to Create Part + + label_generator.edit_profiles @@ -14262,7 +14298,7 @@ Please note that this system is currently experimental, and the synonyms defined settings.ips.buerklin.help - Buerklin-API access limits: + Buerklin-API access limits: 100 requests/minute per IP address Buerklin-API Authentication server: 10 requests/minute per IP address