This commit is contained in:
swdee 2026-01-19 05:49:32 +00:00 committed by GitHub
commit 240096f69f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 953 additions and 38 deletions

View file

@ -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();
}
}

View file

@ -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) {
// Dont 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);
}
}

View file

@ -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',
},
]);

View file

@ -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;
}
}
}

View file

@ -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);

View file

@ -42,4 +42,7 @@ enum BarcodeSourceType
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
*/
case EIGP114;
}
/** For LCSC.com formatted QR codes */
case LCSC;
}

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Services\LabelSystem\BarcodeScanner;
use InvalidArgumentException;
/**
* 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
{
/**
* @param array<string, string> $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);
}
}

View file

@ -0,0 +1,97 @@
{% if decoded is not empty %}
<hr>
<div class="d-flex align-items-center mb-2">
<h4 class="mb-0 me-2">
{% if found and partName %}
{% trans %}label_scanner.part_info.title{% endtrans %}
{% else %}
{% trans %}label_scanner.scan_result.title{% endtrans %}
{% endif %}
</h4>
{% if createUrl %}
<a class="btn btn-primary ms-2 mb-2"
href="{{ createUrl }}"
target="_blank"
title="{% trans %}part.create.btn{% endtrans %}">
<i class="fa-solid fa-plus-square"></i>
</a>
{% endif %}
</div>
{% if found %}
<div class="alert alert-success mb-2 d-flex align-items-center justify-content-between">
<div>
{% if partName %}
{{ partName }}
{% else %}
{% trans %}label_scanner.target_found{% endtrans %}
{% endif %}
</div>
{% if openUrl %}
<a href="{{ openUrl }}" target="_blank" class="btn btn-sm btn-outline-success">
{% trans %}open{% endtrans %}
</a>
{% endif %}
</div>
{% if partName %}
{% if locations is not empty %}
<table class="table table-sm mb-2 w-auto">
<thead>
<tr>
<th scope="col">{% trans %}part_lots.storage_location{% endtrans %}</th>
<th scope="col" class="text-end" style="width: 6rem;">
{% trans %}part_lots.amount{% endtrans %}
</th>
</tr>
</thead>
<tbody>
{% for loc in locations %}
<tr>
<td>
<ul class="structural_link d-inline">
{% for crumb in loc.breadcrumb %}
<li><a href="{{ crumb.url }}" target="_blank">{{ crumb.name }}</a></li>
{% endfor %}
</ul>
</td>
<td class="text-end" style="width: 6rem;">
{% if loc.qty is not null %}<strong>{{ loc.qty }}</strong>{% else %}<span class="text-muted">—</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-muted mb-2">{% trans %}label_scanner.no_locations{% endtrans %}</div>
{% endif %}
{% endif %}
{% else %}
<div class="alert alert-warning mb-2">
{% trans %}label_scanner.qr_part_no_found{% endtrans %}
</div>
{% endif %}
{# Decoded barcode fields #}
<table class="table table-striped table-hover table-bordered table-sm mt-4 mb-0 w-auto">
<tbody>
{% for key, value in decoded %}
<tr>
<th class="text-nowrap">{{ key }}</th>
<td><code>{{ value }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{# Whitespace under table and Input form fields #}
<div class="pt-3">
<hr class="my-0">
</div>
<div class="mb-4"></div>
{% endif %}

View file

@ -12,13 +12,16 @@
<div class="offset-sm-3 col-sm-9">
<div class="img-thumbnail" style="max-width: 600px;">
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}></div>
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}
data-lookup-url="{{ path('scan_lookup') }}"></div>
</div>
</div>
</div>
</div>
<div id="scan-augmented-result" class="mt-3"></div>
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
{{ form_end(form) }}
@ -26,7 +29,16 @@
{% if infoModeData %}
<hr>
<h4>{% trans %}label_scanner.decoded_info.title{% endtrans %}</h4>
<div class="d-flex align-items-center mb-2">
<h4 class="mb-0">{% trans %}label_scanner.decoded_info.title{% endtrans %}</h4>
{% if createUrl %}
<a class="btn btn-primary ms-2" href="{{ createUrl }}"
target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
<i class="fa-solid fa-plus-square"></i>
</a>
{% endif %}
</div>
<table class="table table-striped table-hover table-bordered table-sm">
<tbody>

View file

@ -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']);
}
}

View file

@ -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)']);
}
}

View file

@ -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);
}
}

View file

@ -12027,6 +12027,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>EIGP 114 barcode (e.g. the datamatrix codes on digikey and mouser orders)</target>
</segment>
</unit>
<unit id="lAzDdr" name="scan_dialog.mode.lcsc">
<segment state="translated">
<source>scan_dialog.mode.lcsc</source>
<target>LCSC.com barcode</target>
</segment>
</unit>
<unit id="QSMS_Bd" name="scan_dialog.info_mode">
<segment state="translated">
<source>scan_dialog.info_mode</source>
@ -12039,6 +12045,36 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>Decoded information</target>
</segment>
</unit>
<unit id="k6Gv1gf" name="label_scanner.part_info.title">
<segment state="translated">
<source>label_scanner.part_info.title</source>
<target>Part information</target>
</segment>
</unit>
<unit id="k9lvxgf" name="label_scanner.target_found">
<segment state="translated">
<source>label_scanner.target_found</source>
<target>Item Found</target>
</segment>
</unit>
<unit id="kd6G2gf" name="label_scanner.scan_result.title">
<segment state="translated">
<source>label_scanner.scan_result.title</source>
<target>Scan result</target>
</segment>
</unit>
<unit id="kG2vk5p" name="label_scanner.no_locations">
<segment state="translated">
<source>label_scanner.no_locations</source>
<target>Part is not stored at any locations</target>
</segment>
</unit>
<unit id="fGrv35p" name="label_scanner.qr_part_no_found">
<segment state="translated">
<source>label_scanner.qr_part_no_found</source>
<target>No part found for scanned barcode, click button above to Create Part</target>
</segment>
</unit>
<unit id="nmXQWcS" name="label_generator.edit_profiles">
<segment state="translated">
<source>label_generator.edit_profiles</source>
@ -14262,7 +14298,7 @@ Please note that this system is currently experimental, and the synonyms defined
<unit id="BlR_EQc" name="settings.ips.buerklin.help">
<segment>
<source>settings.ips.buerklin.help</source>
<target>Buerklin-API access limits:
<target>Buerklin-API access limits:
100 requests/minute per IP address
Buerklin-API Authentication server:
10 requests/minute per IP address</target>