This commit is contained in:
Alex Barclay 2025-12-02 16:08:22 +01:00 committed by GitHub
commit 30c439cc17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 861 additions and 0 deletions

View file

@ -0,0 +1,135 @@
import {Controller} from "@hotwired/stimulus";
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2024 Alex Barclay (https://github.com/barclaac)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
_scanner = null;
constructor() {
super()
_scanner = new ScannerSerial()
}
connect(event) {
console.log('Controller connected')
if (_scanner.isConnected()) {
console.log('already connected')
document.getElementById('handheld_scanner_dialog_connect').style.display='none'
document.getElementById('handheld_scanner_dialog_disconnect').style.display=''
}
}
async onConnectScanner(event) {
console.log('Connect to barcode reader')
if (!_scanner.isConnected()) {
await _scanner.connect(async (reader) => {
decoder = new TextDecoder();
var barcodeBuffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
console.log('releasing reader')
reader.releaseLock();
reader = null;
break;
}
console.log(value);
console.log(decoder.decode(value));
partial = decoder.decode(value);
barcodeBuffer += partial
end = false
endidx = partial.indexOf('\x1e\x04');
if (endidx != -1) {
end = true;
} else {
endidx = partial.indexOf('\r');
if (endidx != -1) {
end = true;
}
}
if (end) {
// Decode the barcode
console.log(barcodeBuffer)
start = barcodeBuffer.indexOf('[)>')
if (start == -1) {
console.log('badly formed barcode')
} else {
// Post this back to the server
document.getElementById('handheld_scanner_dialog_barcode').value = barcodeBuffer;
form = document.getElementById('handheld_dialog_form');
form.requestSubmit();
}
barcodeBuffer = '';
}
}
})
document.getElementById('handheld_scanner_dialog_connect').style.display='none'
document.getElementById('handheld_scanner_dialog_disconnect').style.display=''
}
}
async onDisconnectScanner(event) {
console.log('Disconnect called')
if (_scanner.isConnected) {
_scanner.disconnect()
document.getElementById('handheld_scanner_dialog_connect').style.display=''
document.getElementById('handheld_scanner_dialog_disconnect').style.display='none'
}
}
}
class ScannerSerial {
device = null
reader = null
constructor() {
if (ScannerSerial.instance) {
return ScannerSerial.instance;
}
ScannerSerial.instance = this;
}
async connect(readHandler) {
this.device = await navigator.serial.requestPort({ filters: [{ usbVendorId: 0x03f0, usbDeviceId: 0x0339 }] })
await this.device.open({baudRate: 9600})
console.log(this.device)
this.reader = this.device.readable.getReader()
readHandler(this.reader)
}
async disconnect() {
await this.reader.cancel()
this.reader = null
this.device.close()
}
isConnected() {
if (this.reader == null) {
return false;
}
return true
}
}

View file

@ -0,0 +1,28 @@
# EIGP 114 Barcode Operations #
## Concept of Operation ##
Module is intended to be as quick and easy as possible once you receive your bag of goodies from
Digikey or Mouser. Steps would be:
* Grab a bag from the shipment
* Identify the component type and scan your intended storage box
* Scan the storage container
* Scan the bag and put in container
* Grab the next bag (same component type)
* Scan the bag and put in container
* Grab the next bag (different component type)
* Scan new container
* Scan the bag and put in container
### Alternatives ###
* Grab a bag from the shipment
* Scan the bag
* Put the bag in the single storage location indicated
* Scan next bag from shipment...
## Scanner Types ##
Currently only supports an HP 4430 scanner. Code uses WebSerial to communicate with the scanner. For
this to me more widespread would need to add a database of scanner USB device IDs and some data
indicating how each is different

View file

@ -0,0 +1,437 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2024 Alex Barclay (https://github.com/barclaac)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Base\AbstractDBElement;
use App\Entity\LabelSystem\LabelOptions;
use App\Entity\LabelSystem\LabelProcessMode;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\Entity\Parts\Category;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation;
use App\Exceptions\TwigModeException;
use App\Form\LabelSystem\HandheldScannerDialogType;
use App\Helpers\EIGP114;
use App\Repository\DBElementRepository;
use App\Services\ElementTypeNameGenerator;
use App\Services\LabelSystem\LabelGenerator;
use App\Services\Misc\RangeParser;
use App\Services\Parts\PartLotWithdrawAddHelper;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
class BarcodeScanType
{
protected ?string $barcode;
protected ?string $manufacturerPN;
protected ?int $quantity;
protected ?string $location;
// Show last item and quantity added to help check if scanning is working well
protected ?string $lastManufacturerPN;
protected ?int $lastQuantity;
public function __construct() {
$this->barcode = "";
$this->manufacturerPN = "";
$this->quantity = 0;
$this->location = "";
$this->lastManufacturerPN = "";
$this->lastQuantity = 0;
}
public function getBarcode(): ?string {
return $this->barcode;
}
public function setBarcode(?string $barcode): self {
$this->barcode = $barcode;
return $this;
}
public function getManufacturerPN(): ?string {
return $this->manufacturerPN;
}
public function setManufacturerPN(?string $manufacturerPN): self {
$this->manufacturerPN = $manufacturerPN;
return $this;
}
public function getLastManufacturerPN(): ?string {
return $this->lastManufacturerPN;
}
public function setLastManufacturerPN(?string $lastManufacturerPN): self {
$this->lastManufacturerPN = $lastManufacturerPN;
return $this;
}
public function getQuantity(): ?int {
return $this->quantity;
}
public function setQuantity(?int $quantity): self {
$this->quantity = $quantity;
return $this;
}
public function getLastQuantity(): ?int {
return $this->lastQuantity;
}
public function setLastQuantity(?int $lastQuantity): self {
$this->lastQuantity = $lastQuantity;
return $this;
}
public function getLocation(): ?string {
return $this->location;
}
public function setLocation(?string $location): self {
$this->location = $location;
return $this;
}
public function cycleLastAdded() : void {
$this->lastManufacturerPN = $this->manufacturerPN;
$this->manufacturerPN = null;
$this->lastQuantity = $this->quantity;
$this->quantity = 0;
}
}
class HandheldScannerController extends AbstractController
{
public function __construct(protected EntityManagerInterface $em, protected LoggerInterface $logger, protected TranslatorInterface $translator)
{
}
#[Route(path: '/handheldscanner',name: 'handheld_scanner_dialog')]
public function generator(Request $request,
PartLotWithdrawAddHelper $withdrawAddHelper): Response
{
$this->logger->info('*** rendering form ***');
$this->logger->info(var_export($request->getPayload()->all(), true));
$barcode = new BarcodeScanType();
$form = $this->buildForm($barcode);
$form->handleRequest($request);
if ($form->get('autocommit')->getData() == true || ($form->isSubmitted() && $form->isValid())) {
if ($this->processSubmit($form, $withdrawAddHelper, $barcode)) {
// Need a new form to render because we can't change submitted form
$this->logger->info('replacing form with fresh');
$barcode->cycleLastAdded(); // Shuffle into last added slot
$newForm = $this->buildForm($barcode);
$newForm->get('missingloc')->setData($form->get('missingloc')->getData());
$newForm->get('locfrompart')->setData($form->get('locfrompart')->getData());
$newForm->get('foundloc')->setData($form->get('foundloc')->getData());
$newForm->get('missingpart')->setData($form->get('missingpart')->getData());
$newForm->get('manufacturer_pn')->setData('');
$newForm->get('quantity')->setData(0);
$form = $newForm;
}
}
return $this->render('label_system/handheld_scanner/handheld_scanner.html.twig', [
'form' => $form,
]);
}
protected function buildForm(BarcodeScanType $barcode) : FormInterface
{
$builder = $this->container->get('form.factory')
->createBuilder(HandheldScannerDialogType::class, $barcode);
$this->addPreSubmitEventHandler($builder);
$form = $builder->getForm();
return $form;
}
protected function addPreSubmitEventHandler(FormBuilderInterface $builder)
{
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$form = $event->getForm();
$data = $event->getData();
if (!isset($data['barcode'])) {
return;
}
$r = EIGP114::decode($data['barcode']);
// Remove barcode so that if user has edited fields that the barcode won't
// override any of the user's values
unset($data['barcode']);
if (array_key_exists('location', $r)) {
$data['location'] = $r['location'];
$data['last_scan'] = 'location';
$data['manufacturer_pn'] = '';
$data['locfrompart'] = false;
$data['foundpart'] = false;
$data['quantity'] = '';
} else if (array_key_exists('supplier_pn', $r)) {
$data['manufacturer_pn'] = $r['supplier_pn'];
$data['last_scan'] = 'part';
if (array_key_exists('quantity', $r)) {
$data['quantity'] = $r['quantity'];
}
}
// Look up the location in the database to see if one needs to be created
if ($data['location'] != "") {
$storageRepository = $this->em->getRepository(StorageLocation::class);
$storage = $this->getStorageLocation($data['location']);
$data['foundloc'] = ($storage != null);
}
// Look up the part in the database to see if one needs to be created
$part = null;
if ($data['manufacturer_pn'] != "") {
$partRepository = $this->em->getRepository(Part::class);
$part = $partRepository->findOneBy(['manufacturer_product_number' => $data['manufacturer_pn']]);
$data['foundpart'] = ($part != null);
if ($data['foundpart'] == false &&
(array_key_exists('missingpart', $data) && $data['missingpart'] == false) &&
$data['autocommit'] == true) {
$this->addFlash('error', 'Cannot autocommit part - part not in database');
}
}
// Did we want to use the storage location for this part
// Will require all part-lots to be in the same location
if (array_key_exists('locfrompart', $data) && $part) {
$this->logger->info('take loc from part');
$locs=[];
foreach ($part->getPartLots() as &$pl) {
$locs[$pl->getStorageLocation()->getId()] = $pl->getStorageLocation();
}
if (count($locs) == 1) {
// Got exactly 1 location - can set this as the default
$storageLoc = array_pop($locs);
$data['location'] = $storageLoc->getName();
}
}
$event->setData($data);
});
}
protected function processSubmit(FormInterface $form, PartLotWithdrawAddHelper $withdrawAddHelper,
BarcodeScanType $barcode) : bool {
$this->logger->info("form submitted");
// We could be here through an autosubmit or because the actual button was pressed
// To proceed we need a storage location, manufacturer part number and a quantity
$this->logger->info('processSubmit');
if ($form instanceof Form && $form->getClickedButton() != null || $form->get('autocommit')->getData() == true) {
$fail = false;
if ($barcode->getLocation() != '' && $barcode->getManufacturerPN() != '' &&
$barcode->getQuantity() != 0) {
// Got all the data that we need - now work through the items to see if we
// can submit the data
$storageLocation = $this->getStorageLocation($barcode->getLocation());
if (!$storageLocation) {
if ($form->get('missingloc')->getData() == true) {
$storageLocation = $this->createStorageLocation($barcode->getLocation());
} else {
$fail = true;
$this->addFlash('error', 'storage doesn\'t exist');
}
}
$part = $this->getPart($barcode->getManufacturerPN());
if (!$part) {
$this->logger->debug('part not found');
if ($form->get('missingpart')->getData() == true) {
$this->logger->debug('create part');
$part = $this->createMissingPart($barcode->getManufacturerPN());
$this->logger->debug('part created');
$this->em->flush();
} else {
$fail = true;
$this->addFlash('error', 'part doesn\'t exist');
}
}
if (!$fail) {
// Have a part and storage location so attempt to add stock for this combination
$found=false;
$partLots = $part->getPartLots();
if ($partLots != null) {
foreach ($part->getPartLots() as &$pl) {
if ($pl->getStorageLocation()->getId() == $storageLocation->getId()) {
$this->logger->info('Found existing storage location, adding stock');
if ($withdrawAddHelper->canAdd($pl)) {
$withdrawAddHelper->add($pl, $barcode->getQuantity(), "Barcode scan add");
$found = true;
}
break;
}
$this->logger->info('Part lot {fullPath}', ['fullPath' => $pl->getStorageLocation()->getFullPath()]);
}
}
if (!$found) {
// No part lot for this storage location - add one
$partLot = new PartLot();
$partLot->setStorageLocation($storageLocation);
$partLot->setInstockUnknown(false);
$partLot->setAmount(0.0);
$part->addPartLot($partLot);
$this->em->flush(); // Must have an ID for target
if ($withdrawAddHelper->canAdd($partLot)) {
$withdrawAddHelper->add($partLot, $barcode->getQuantity(), "Barcode scan add");
}
}
}
if (!$fail) {
$this->em->flush();
}
return true;
}
}
return false;
}
protected function getStorageLocation(string $name) : ?StorageLocation
{
$repository = $this->em->getRepository(StorageLocation::class);
$storage = $repository->findOneBy(['name' => $name]);
if ($storage) {
$this->logger->info($storage->getFullPath());
} else {
$this->logger->info('Storage not found in database');
}
if ($storage instanceof StorageLocation) {
return $storage;
}
return null;
}
protected function createStorageLocation(string $location): StorageLocation
{
$repository = $this->em->getRepository(StorageLocation::class);
$storage = new StorageLocation();
$storage->setName($location);
$this->em->persist($storage);
return $storage;
}
protected function getPart(string $partNumber) : ?Part
{
$repository = $this->em->getRepository(Part::class);
$part = $repository->findOneBy(['manufacturer_product_number' => $partNumber]);
if ($part) {
$this->logger->info($part->getName());
}
return $part;
}
protected function createMissingPart(string $partNumber) : ?Part
{
$repository = $this->em->getRepository(Category::class);
$category = $repository->findOneBy(['name' => 'Unclassified']);
$part = new Part();
if ($category instanceof Category) {
$part->setCategory($category);
}
$part->setName($partNumber);
$part->setManufacturerProductNumber($partNumber);
$this->em->persist($part);
return $part;
}
protected function addStock(Form $form, PartLotWithdrawAddHelper $withdrawAddHelper,
BarcodeScanType $barcode)
{
$storage = null;
$part = null;
// See if the storage location exists was in barcode
if ($barcode->getLocation() != "") {
$storage = $this->getStorageLocation($barcode->getLocation());
}
if ($barcode->getManufacturerPN() != "") {
// Got a part instead
$repository = $this->em->getRepository(Part::class);
$part = $repository->findOneBy(['manufacturer_product_number' => $barcode->getManufacturerPN()]);
if ($part) {
$this->logger->info($part->getName());
}
}
// Does a part lot exist for this combination?
if ($storage != null && $part != null) {
$found=false;
foreach ($part->getPartLots() as &$pl) {
if ($pl->getStorageLocation()->getId() == $storage->getId()) {
$this->logger->info('Found existing storage location, adding stock');
if ($withdrawAddHelper->canAdd($pl)) {
$withdrawAddHelper->add($pl, $barcode->getQuantity(), "Test add");
$found = true;
}
$this->em->flush();
$this->addFlash('success', 'stock added');
break;
}
$this->logger->info('Part lot {fullPath}', ['fullPath' => $pl->getStorageLocation()->getFullPath()]);
}
if (!$found) {
// No part lot for this storage location - add one
$partLot = new PartLot();
$partLot->setStorageLocation($storage);
$partLot->setInstockUnknown(false);
$partLot->setAmount(0.0);
$part->addPartLot($partLot);
$this->em->flush();
if ($withdrawAddHelper->canAdd($partLot)) {
$withdrawAddHelper->add($partLot, $barcode->getQuantity(), "Creational add");
}
$this->em->flush();
$this->addFlash('success', 'partlot added');
}
}
}
}

View file

@ -0,0 +1,139 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2024 Alex Barclay (https://github.com/barclaac)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\LabelSystem;
use Symfony\Bundle\SecurityBundle\Security;
use App\Form\LabelOptionsType;
use App\Helpers\EIGP114;
use App\Validator\Constraints\Misc\ValidRange;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ButtonType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class HandheldScannerDialogType extends AbstractType
{
public function __construct(protected Security $security)
{
}
public function buildForm(FormBuilderInterface $builder, array $options = []): void
{
$builder->add('barcode', HiddenType::Class, [
'required' => true,
'action' => '',
]);
$builder->add('location', TextType::Class, [
'required' => false,
'label' => 'Storage Location',
'help' => 'Scan this first, will erase part fields',
]);
$builder->add('manufacturer_pn', TextType::Class, [
'required' => false,
'label' => 'Manufacturer Part',
]);
$builder->add('last_manufacturer_pn', TextType::Class, [
'required' => false,
'label' => 'Last Added Manufacturer Part',
]);
$builder->add('quantity', IntegerType::Class, [
'required' => false,
'label' => 'Quantity',
]);
$builder->add('last_quantity', IntegerType::Class, [
'required' => false,
'label' => 'Last Added Quantity',
]);
$builder->add('missingloc', CheckboxType::Class, [
'label' => 'Create missing Storage Location',
'mapped' => false,
'required' => false,
]);
$builder->add('locfrompart', CheckboxType::Class, [
'label' => 'Take storage location from part',
'mapped' => false,
'required' => false,
]);
$builder->add('foundloc', CheckboxType::Class, [
'label' => 'Storage Location in database',
'mapped' => false,
'required' => false,
]);
$builder->add('missingpart', CheckboxType::Class, [
'label' => 'Create missing Part',
'mapped' => false,
'required' => false,
]);
$builder->add('foundpart', CheckboxType::Class, [
'label' => 'Part in database',
'mapped' => false,
'required' => false,
]);
$builder->add('autocommit', CheckboxType::Class, [
'label' => 'Autocommit on Scan',
'mapped' => false,
'required' => false,
]);
$builder->add('connect', ButtonType::Class, [
'label' => 'Connect',
'attr' => ['data-action' => 'pages--handheld-scan#onConnectScanner',
'class' => 'btn btn-primary' ],
]);
$builder->add('disconnect', ButtonType::Class, [
'label' => 'Disconnect',
'attr' => ['data-action' => 'pages--handheld-scan#onDisconnectScanner',
'class' => 'btn btn-primary',
'style' => 'display: none'],
]);
$builder->add('submit', SubmitType::Class, [
'label' => 'Submit',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('mapped', false);
$resolver->setDefault('allow_extra_fields', true);
}
}

85
src/Helpers/EIGP114.php Normal file
View file

@ -0,0 +1,85 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2024 Alex Barclay (https://github.com/barclaac)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Helpers;
use Psr\Log\LoggerInterface;
/*
EIGP 114
ISO/IEC 15418
ANS MH10.8.2-2016
Based on the ISO standard but it seems that both Mouser and Digikey aren't quite
following the standard.
Message is defined as (very loose RE format): ({:RS:} = 0x1e, {:GS:} = 0x1d, {:EOT:} = 0x04)
Header: [)>{:RS:}06{:GS:}
Field data identifier: [0-9]*[A-Z]
Field data: .*
Field Separator: {:GS:} - may or may not be present after last field
Trailer: {:RS:}{:EOT:}
Mouser & Digikey differences:
Header: [)>06{:GS:} - note the missing {:RS:}
Field: last field missing trailer {:RS:}{:EOT:} so use {:CR:} inserted by scanner itself
*/
class EIGP114
{
public static function decode($barcode): array {
$rslt = [];
// Find the start sequence for a type 6 ISO/IEC 15434 message
$hdrPattern = "/(\[\)>\u{001e}*06\u{001d})([[:print:]\u{001d}]*)(\u{001e}\u{0004})*/";
$matches = [];
$loc = preg_match_all($hdrPattern, $barcode, $matches);
$fields = [];
if ($loc) {
$remain = $matches[2][0];
$fields = preg_split("/\u{001d}/", $remain);
foreach ($fields as &$v) {
EIGP114::processField($v, $rslt);
}
}
return $rslt;
}
private static function processField($field, &$result) : void {
$pattern = "/([0-9]{0,2}[A-Z])(.+)/";
$matches = [];
if (preg_match_all($pattern, $field, $matches)) {
switch ($matches[1][0]) {
case 'L':
$result['location'] = $matches[2][0];
break;
case '1P':
$result['supplier_pn'] = $matches[2][0];
break;
case 'Q':
$result['quantity'] = $matches[2][0];
break;
default:
//array_push($result, $matches);
}
}
}
}

View file

@ -153,6 +153,13 @@ class ToolsTreeBuilder
))->setIcon('fa-treeview fa-fw fa-solid fa-tasks');
}
if ($this->security->isGranted('@info_providers.create_parts')) {
$nodes[] = (new TreeViewNode(
"Handheld Scanner",
$this->urlGenerator->generate('handheld_scanner_dialog')
))->setIcon('fa-treeview fa-fw fa-solid fa-camera-retro');
}
return $nodes;
}

View file

@ -0,0 +1,30 @@
{% extends 'main_card.html.twig' %}
{% block card_title %}<i class="fas fa-camera-retro fa-fw"></i>Handheld Scanner Operations{% endblock %}
{% block card_content %}
{{ form_start(form, {'attr': {'id': 'handheld_dialog_form'}}) }}
{{ form_row(form.location) }}
{{ form_row(form.missingloc) }}
{{ form_row(form.locfrompart) }}
{{ form_row(form.foundloc) }}
{{ form_row(form.manufacturer_pn) }}
{{ form_row(form.missingpart) }}
{{ form_row(form.foundpart) }}
{{ form_row(form.quantity) }}
{{ form_row(form.autocommit) }}
<div>
{{ form_row(form.last_manufacturer_pn) }}
{{ form_row(form.last_quantity) }}
</div>
<div {{ stimulus_controller('pages/handheld_scan')}}>
{{ form_widget(form.connect) }}
{{ form_widget(form.disconnect) }}
{{ form_widget(form.submit) }}
</div>
{{ form_end(form) }}
{% endblock %}