diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php index 0bee33a10..7f65262ab 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php @@ -105,6 +105,10 @@ public function scanBarcodeContent(string $input, ?BarcodeSourceType $type = nul return new AmazonBarcodeScanResult($input); } + if ($type === BarcodeSourceType::TME) { + return TMEBarcodeScanResult::parse($input); + } + //Null means auto and we try the different formats $result = $this->parseInternalBarcode($input); @@ -144,6 +148,11 @@ public function scanBarcodeContent(string $input, ?BarcodeSourceType $type = nul return new AmazonBarcodeScanResult($input); } + // Try TME barcode + if (TMEBarcodeScanResult::isTMEBarcode($input)) { + return TMEBarcodeScanResult::parse($input); + } + throw new InvalidArgumentException('Unknown barcode'); } @@ -162,6 +171,7 @@ private function parseLCSCBarcode(string $input): LCSCBarcodeScanResult return LCSCBarcodeScanResult::parse($input); } + private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult { $lot_repo = $this->entityManager->getRepository(PartLot::class); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php index 1927edb90..60a1136fe 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -150,6 +150,10 @@ public function resolveEntity(BarcodeScanResultInterface $barcodeScan): Part|Par ?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin); } + if ($barcodeScan instanceof TMEBarcodeScanResult) { + return $this->resolvePartFromTME($barcodeScan); + } + return null; } @@ -236,6 +240,26 @@ private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part } + private function resolvePartFromTME(TMEBarcodeScanResult $barcodeScan): ?Part + { + $pn = $barcodeScan->tmePartNumber; + if ($pn) { + $part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pn); + if ($part !== null) { + return $part; + } + + //Try to find the part by SPN/SKU + $part = $this->em->getRepository(Part::class)->getPartBySPN($pn); + if ($part !== null) { + return $part; + } + } + + // Fallback: search by MPN + return $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->mpn, $barcodeScan->manufacturer); + } + /** * Tries to extract creation information for a part from the given barcode scan result. This can be used to * automatically fill in the info provider reference of a part, when creating a new part based on the scan result. @@ -247,6 +271,20 @@ private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part */ public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array { + // TME + if ($scanResult instanceof TMEBarcodeScanResult) { + if ($scanResult->tmePartNumber === null) { + return null; + } + return [ + 'providerKey' => 'tme', + 'providerId' => $scanResult->tmePartNumber, + 'lotAmount' => $scanResult->quantity, + 'lotName' => $scanResult->purchaseOrder, + 'lotUserBarcode' => $scanResult->rawInput, + ]; + } + // LCSC if ($scanResult instanceof LCSCBarcodeScanResult) { return [ diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php index fb6eaa774..df991a8c8 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php @@ -52,4 +52,7 @@ enum BarcodeSourceType: string case LCSC = 'lcsc'; case AMAZON = 'amazon'; + + /** For TME (tme.eu) formatted QR codes */ + case TME = 'tme'; } diff --git a/src/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResult.php new file mode 100644 index 000000000..5feb67c1a --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResult.php @@ -0,0 +1,143 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\LabelSystem\BarcodeScanner; + +use InvalidArgumentException; + +/** + * This class represents the content of a tme.eu barcode label. + * The format is space-separated KEY:VALUE tokens, e.g.: + * QTY:1000 PN:SMD0603-5K1-1% PO:32723349/7 MFR:ROYALOHM MPN:0603SAF5101T5E CoO:TH RoHS https://www.tme.eu/details/... + */ +readonly class TMEBarcodeScanResult implements BarcodeScanResultInterface +{ + /** @var int|null Quantity (QTY) */ + public ?int $quantity; + + /** @var string|null TME part number (PN) */ + public ?string $tmePartNumber; + + /** @var string|null Purchase order number (PO) */ + public ?string $purchaseOrder; + + /** @var string|null Manufacturer name (MFR) */ + public ?string $manufacturer; + + /** @var string|null Manufacturer part number (MPN) */ + public ?string $mpn; + + /** @var string|null Country of origin (CoO) */ + public ?string $countryOfOrigin; + + /** @var bool Whether the part is RoHS compliant */ + public bool $rohs; + + /** @var string|null The product URL */ + public ?string $productUrl; + + /** + * @param array $fields Parsed key-value fields (keys uppercased) + * @param string $rawInput Original barcode string + */ + public function __construct( + public array $fields, + public string $rawInput, + ) { + $this->quantity = isset($this->fields['QTY']) ? (int) $this->fields['QTY'] : null; + $this->tmePartNumber = $this->fields['PN'] ?? null; + $this->purchaseOrder = $this->fields['PO'] ?? null; + $this->manufacturer = $this->fields['MFR'] ?? null; + $this->mpn = $this->fields['MPN'] ?? null; + $this->countryOfOrigin = $this->fields['COO'] ?? null; + $this->rohs = isset($this->fields['ROHS']); + $this->productUrl = $this->fields['URL'] ?? null; + } + + public function getSourceType(): BarcodeSourceType + { + return BarcodeSourceType::TME; + } + + public function getDecodedForInfoMode(): array + { + return [ + 'Barcode type' => 'TME', + 'TME Part No. (PN)' => $this->tmePartNumber ?? '', + 'MPN' => $this->mpn ?? '', + 'Manufacturer (MFR)' => $this->manufacturer ?? '', + 'Qty' => $this->quantity !== null ? (string) $this->quantity : '', + 'Purchase Order (PO)' => $this->purchaseOrder ?? '', + 'Country of Origin (CoO)' => $this->countryOfOrigin ?? '', + 'RoHS' => $this->rohs ? 'Yes' : 'No', + 'URL' => $this->productUrl ?? '', + ]; + } + + /** + * Returns true if the input looks like a TME barcode label (contains tme.eu URL). + */ + public static function isTMEBarcode(string $input): bool + { + return str_contains(strtolower($input), 'tme.eu'); + } + + /** + * Parse the TME barcode string into a TMEBarcodeScanResult. + */ + public static function parse(string $input): self + { + $raw = trim($input); + + if (!self::isTMEBarcode($raw)) { + throw new InvalidArgumentException('Not a TME barcode'); + } + + $fields = []; + + // Split on whitespace; each token is either KEY:VALUE, a bare keyword, or the URL + $tokens = preg_split('/\s+/', $raw); + foreach ($tokens as $token) { + if ($token === '') { + continue; + } + + // The TME URL + if (str_starts_with(strtolower($token), 'http')) { + $fields['URL'] = $token; + continue; + } + + $colonPos = strpos($token, ':'); + if ($colonPos !== false) { + $key = strtoupper(substr($token, 0, $colonPos)); + $value = substr($token, $colonPos + 1); + $fields[$key] = $value; + } else { + // Bare keyword like "RoHS" + $fields[strtoupper($token)] = ''; + } + } + + return new self($fields, $raw); + } +} diff --git a/tests/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResultTest.php b/tests/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResultTest.php new file mode 100644 index 000000000..838174b85 --- /dev/null +++ b/tests/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResultTest.php @@ -0,0 +1,110 @@ +. + */ + +namespace App\Tests\Services\LabelSystem\BarcodeScanner; + +use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; +use App\Services\LabelSystem\BarcodeScanner\TMEBarcodeScanResult; +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; + +class TMEBarcodeScanResultTest extends TestCase +{ + private const EXAMPLE1 = 'QTY:1000 PN:SMD0603-5K1-1% PO:32723349/7 MFR:ROYALOHM MPN:0603SAF5101T5E CoO:TH RoHS https://www.tme.eu/details/SMD0603-5K1-1%25'; + private const EXAMPLE2 = 'QTY:5 PN:ETQP3M6R8KVP PO:31199729/3 MFR:PANASONIC MPN:ETQP3M6R8KVP RoHS https://www.tme.eu/details/ETQP3M6R8KVP'; + + public function testIsTMEBarcode(): void + { + $this->assertFalse(TMEBarcodeScanResult::isTMEBarcode('invalid')); + $this->assertFalse(TMEBarcodeScanResult::isTMEBarcode('QTY:5 PN:ABC MPN:XYZ')); + $this->assertFalse(TMEBarcodeScanResult::isTMEBarcode('')); + + $this->assertTrue(TMEBarcodeScanResult::isTMEBarcode(self::EXAMPLE1)); + $this->assertTrue(TMEBarcodeScanResult::isTMEBarcode(self::EXAMPLE2)); + } + + public function testParseInvalidThrows(): void + { + $this->expectException(InvalidArgumentException::class); + TMEBarcodeScanResult::parse('not-a-tme-barcode'); + } + + public function testParseExample1(): void + { + $scan = TMEBarcodeScanResult::parse(self::EXAMPLE1); + + $this->assertSame(1000, $scan->quantity); + $this->assertSame('SMD0603-5K1-1%', $scan->tmePartNumber); + $this->assertSame('32723349/7', $scan->purchaseOrder); + $this->assertSame('ROYALOHM', $scan->manufacturer); + $this->assertSame('0603SAF5101T5E', $scan->mpn); + $this->assertSame('TH', $scan->countryOfOrigin); + $this->assertTrue($scan->rohs); + $this->assertSame('https://www.tme.eu/details/SMD0603-5K1-1%25', $scan->productUrl); + $this->assertSame(self::EXAMPLE1, $scan->rawInput); + } + + public function testParseExample2(): void + { + $scan = TMEBarcodeScanResult::parse(self::EXAMPLE2); + + $this->assertSame(5, $scan->quantity); + $this->assertSame('ETQP3M6R8KVP', $scan->tmePartNumber); + $this->assertSame('31199729/3', $scan->purchaseOrder); + $this->assertSame('PANASONIC', $scan->manufacturer); + $this->assertSame('ETQP3M6R8KVP', $scan->mpn); + $this->assertNull($scan->countryOfOrigin); + $this->assertTrue($scan->rohs); + $this->assertSame('https://www.tme.eu/details/ETQP3M6R8KVP', $scan->productUrl); + } + + public function testGetSourceType(): void + { + $scan = TMEBarcodeScanResult::parse(self::EXAMPLE2); + $this->assertSame(BarcodeSourceType::TME, $scan->getSourceType()); + } + + public function testParseUppercaseUrl(): void + { + $input = 'QTY:500 PN:M0.6W-10K MFR:ROYAL.OHM MPN:MF006FF1002A50 PO:7792659/8 HTTPS://WWW.TME.EU/DETAILS/M0.6W-10K'; + $this->assertTrue(TMEBarcodeScanResult::isTMEBarcode($input)); + + $scan = TMEBarcodeScanResult::parse($input); + $this->assertSame(500, $scan->quantity); + $this->assertSame('M0.6W-10K', $scan->tmePartNumber); + $this->assertSame('ROYAL.OHM', $scan->manufacturer); + $this->assertSame('MF006FF1002A50', $scan->mpn); + $this->assertSame('7792659/8', $scan->purchaseOrder); + $this->assertSame('HTTPS://WWW.TME.EU/DETAILS/M0.6W-10K', $scan->productUrl); + } + + public function testGetDecodedForInfoMode(): void + { + $scan = TMEBarcodeScanResult::parse(self::EXAMPLE1); + $decoded = $scan->getDecodedForInfoMode(); + + $this->assertSame('TME', $decoded['Barcode type']); + $this->assertSame('SMD0603-5K1-1%', $decoded['TME Part No. (PN)']); + $this->assertSame('0603SAF5101T5E', $decoded['MPN']); + $this->assertSame('ROYALOHM', $decoded['Manufacturer (MFR)']); + $this->assertSame('1000', $decoded['Qty']); + $this->assertSame('Yes', $decoded['RoHS']); + } +} diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 79789e217..db5951368 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -1,6 +1,6 @@ - + attachment_type.caption @@ -12861,6 +12861,12 @@ Buerklin-API-Authentication-Server: Amazon Barcode + + + scan_dialog.mode.tme + TME Barcode + + settings.ips.canopy diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index ce92bda69..a8db61ac4 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12863,6 +12863,12 @@ Buerklin-API Authentication server: Amazon barcode + + + scan_dialog.mode.tme + TME barcode + + settings.ips.canopy