mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-01-20 09:09:33 +00:00
Merge 7336bc8114 into 766ba07105
This commit is contained in:
commit
240096f69f
13 changed files with 953 additions and 38 deletions
|
|
@ -21,17 +21,30 @@ import {Controller} from "@hotwired/stimulus";
|
||||||
//import * as ZXing from "@zxing/library";
|
//import * as ZXing from "@zxing/library";
|
||||||
|
|
||||||
import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
|
import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
|
||||||
|
import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller";
|
||||||
|
|
||||||
/* stimulusFetch: 'lazy' */
|
/* stimulusFetch: 'lazy' */
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
|
|
||||||
//codeReader = null;
|
|
||||||
|
|
||||||
_scanner = null;
|
_scanner = null;
|
||||||
|
_submitting = false;
|
||||||
|
_lastDecodedText = "";
|
||||||
|
_onInfoChange = null;
|
||||||
|
|
||||||
connect() {
|
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
|
//This function ensures, that the qrbox is 70% of the total viewport
|
||||||
let qrboxFunction = function(viewfinderWidth, viewfinderHeight) {
|
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
|
//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) => {
|
Html5Qrcode.getCameras().catch(() => {
|
||||||
document.getElementById('scanner-warning').classList.remove('d-none');
|
document.getElementById("scanner-warning")?.classList.remove("d-none");
|
||||||
});
|
});
|
||||||
|
|
||||||
this._scanner = new Html5QrcodeScanner(this.element.id, {
|
this._scanner = new Html5QrcodeScanner(this.element.id, {
|
||||||
fps: 10,
|
fps: 10,
|
||||||
qrbox: qrboxFunction,
|
qrbox: qrboxFunction,
|
||||||
|
// Key change: shrink preview height on mobile
|
||||||
|
...(isMobile ? { aspectRatio: 1.0 } : {}),
|
||||||
experimentalFeatures: {
|
experimentalFeatures: {
|
||||||
//This option improves reading quality on android chrome
|
//This option improves reading quality on android chrome
|
||||||
useBarCodeDetectorIfSupported: true
|
useBarCodeDetectorIfSupported: true,
|
||||||
}
|
},
|
||||||
}, false);
|
}, false);
|
||||||
|
|
||||||
this._scanner.render(this.onScanSuccess.bind(this));
|
this._scanner.render(this.onScanSuccess.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this._scanner.pause();
|
// If we already stopped/cleared before submit, nothing to do.
|
||||||
this._scanner.clear();
|
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
|
//Put our decoded Text into the input box
|
||||||
document.getElementById('scan_dialog_input').value = decodedText;
|
const input = document.getElementById("scan_dialog_input");
|
||||||
//Submit form
|
if (input) input.value = decodedText;
|
||||||
document.getElementById('scan_dialog_form').requestSubmit();
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector;
|
||||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper;
|
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper;
|
||||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
|
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
|
||||||
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
||||||
|
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
|
||||||
|
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
|
||||||
use Doctrine\ORM\EntityNotFoundException;
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
|
@ -53,6 +55,12 @@ use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
|
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
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
|
* @see \App\Tests\Controller\ScanControllerTest
|
||||||
|
|
@ -60,9 +68,12 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||||
#[Route(path: '/scan')]
|
#[Route(path: '/scan')]
|
||||||
class ScanController extends AbstractController
|
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')]
|
#[Route(path: '', name: 'scan_dialog')]
|
||||||
public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response
|
public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response
|
||||||
|
|
@ -72,29 +83,33 @@ class ScanController extends AbstractController
|
||||||
$form = $this->createForm(ScanDialogType::class);
|
$form = $this->createForm(ScanDialogType::class);
|
||||||
$form->handleRequest($request);
|
$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()) {
|
if ($input === null && $form->isSubmitted() && $form->isValid()) {
|
||||||
$input = $form['input']->getData();
|
$input = $form['input']->getData();
|
||||||
$mode = $form['mode']->getData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$infoModeData = null;
|
$infoModeData = null;
|
||||||
|
|
||||||
if ($input !== null) {
|
if ($input !== null && $input !== '') {
|
||||||
try {
|
$mode = $form->isSubmitted() ? $form['mode']->getData() : null;
|
||||||
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
|
$infoMode = $form->isSubmitted() ? (bool) $form['info_mode']->getData() : false;
|
||||||
//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();
|
|
||||||
|
|
||||||
|
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');
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,8 @@ class ScanDialogType extends AbstractType
|
||||||
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
|
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
|
||||||
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
|
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
|
||||||
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
|
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',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ use App\Entity\LabelSystem\LabelSupportedElement;
|
||||||
use App\Entity\Parts\Manufacturer;
|
use App\Entity\Parts\Manufacturer;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\Parts\PartLot;
|
use App\Entity\Parts\PartLot;
|
||||||
|
use App\Repository\Parts\PartRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\EntityNotFoundException;
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
@ -77,6 +78,10 @@ final class BarcodeRedirector
|
||||||
return $this->getURLVendorBarcode($barcodeScan);
|
return $this->getURLVendorBarcode($barcodeScan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($barcodeScan instanceof LCSCBarcodeScanResult) {
|
||||||
|
return $this->getURLLCSCBarcode($barcodeScan);
|
||||||
|
}
|
||||||
|
|
||||||
throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($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
|
* Gets the URL to a part from a scan of a Vendor Barcode
|
||||||
*/
|
*/
|
||||||
|
|
@ -163,4 +216,46 @@ final class BarcodeRedirector
|
||||||
}
|
}
|
||||||
throw new EntityNotFoundException();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,9 @@ final class BarcodeScanHelper
|
||||||
if ($type === BarcodeSourceType::EIGP114) {
|
if ($type === BarcodeSourceType::EIGP114) {
|
||||||
return $this->parseEIGP114Barcode($input);
|
return $this->parseEIGP114Barcode($input);
|
||||||
}
|
}
|
||||||
|
if ($type === BarcodeSourceType::LCSC) {
|
||||||
|
return $this->parseLCSCBarcode($input);
|
||||||
|
}
|
||||||
|
|
||||||
//Null means auto and we try the different formats
|
//Null means auto and we try the different formats
|
||||||
$result = $this->parseInternalBarcode($input);
|
$result = $this->parseInternalBarcode($input);
|
||||||
|
|
@ -117,6 +120,11 @@ final class BarcodeScanHelper
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try LCSC barcode
|
||||||
|
if (LCSCBarcodeScanResult::looksLike($input)) {
|
||||||
|
return $this->parseLCSCBarcode($input);
|
||||||
|
}
|
||||||
|
|
||||||
throw new InvalidArgumentException('Unknown barcode');
|
throw new InvalidArgumentException('Unknown barcode');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,6 +133,11 @@ final class BarcodeScanHelper
|
||||||
return EIGP114BarcodeScanResult::parseFormat06Code($input);
|
return EIGP114BarcodeScanResult::parseFormat06Code($input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parseLCSCBarcode(string $input): LCSCBarcodeScanResult
|
||||||
|
{
|
||||||
|
return LCSCBarcodeScanResult::parse($input);
|
||||||
|
}
|
||||||
|
|
||||||
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
|
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
|
||||||
{
|
{
|
||||||
$lot_repo = $this->entityManager->getRepository(PartLot::class);
|
$lot_repo = $this->entityManager->getRepository(PartLot::class);
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,7 @@ enum BarcodeSourceType
|
||||||
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
|
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
|
||||||
*/
|
*/
|
||||||
case EIGP114;
|
case EIGP114;
|
||||||
}
|
|
||||||
|
/** For LCSC.com formatted QR codes */
|
||||||
|
case LCSC;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
templates/label_system/scanner/augmented_result.html.twig
Normal file
97
templates/label_system/scanner/augmented_result.html.twig
Normal 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 %}
|
||||||
|
|
@ -12,13 +12,16 @@
|
||||||
<div class="offset-sm-3 col-sm-9">
|
<div class="offset-sm-3 col-sm-9">
|
||||||
|
|
||||||
<div class="img-thumbnail" style="max-width: 600px;">
|
<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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="scan-augmented-result" class="mt-3"></div>
|
||||||
|
|
||||||
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
|
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
|
||||||
|
|
||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
|
|
@ -26,7 +29,16 @@
|
||||||
|
|
||||||
{% if infoModeData %}
|
{% if infoModeData %}
|
||||||
<hr>
|
<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">
|
<table class="table table-striped table-hover table-bordered table-sm">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,59 @@ class ScanControllerTest extends WebTestCase
|
||||||
$this->client->request('GET', '/scan/part/1');
|
$this->client->request('GET', '/scan/part/1');
|
||||||
$this->assertResponseRedirects('/en/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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,11 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
|
||||||
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
||||||
use Doctrine\ORM\EntityNotFoundException;
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
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
|
final class BarcodeRedirectorTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -82,4 +87,74 @@ final class BarcodeRedirectorTest extends KernelTestCase
|
||||||
$this->service->getRedirectURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT,
|
$this->service->getRedirectURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT,
|
||||||
12_345_678, BarcodeSourceType::INTERNAL));
|
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)']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
|
||||||
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
|
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
|
||||||
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
|
||||||
|
|
||||||
class BarcodeScanHelperTest extends WebTestCase
|
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"];
|
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
|
public static function invalidDataProvider(): \Iterator
|
||||||
|
|
@ -153,4 +162,33 @@ class BarcodeScanHelperTest extends WebTestCase
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
$this->service->scanBarcodeContent($input);
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<target>EIGP 114 barcode (e.g. the datamatrix codes on digikey and mouser orders)</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="QSMS_Bd" name="scan_dialog.info_mode">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>scan_dialog.info_mode</source>
|
<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>
|
<target>Decoded information</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="nmXQWcS" name="label_generator.edit_profiles">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>label_generator.edit_profiles</source>
|
<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">
|
<unit id="BlR_EQc" name="settings.ips.buerklin.help">
|
||||||
<segment>
|
<segment>
|
||||||
<source>settings.ips.buerklin.help</source>
|
<source>settings.ips.buerklin.help</source>
|
||||||
<target>Buerklin-API access limits:
|
<target>Buerklin-API access limits:
|
||||||
100 requests/minute per IP address
|
100 requests/minute per IP address
|
||||||
Buerklin-API Authentication server:
|
Buerklin-API Authentication server:
|
||||||
10 requests/minute per IP address</target>
|
10 requests/minute per IP address</target>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue