Merge branch 'master' into add-edit-kicad-suggestion-list-editor

This commit is contained in:
Jan Böhmer 2026-04-14 23:56:04 +02:00 committed by GitHub
commit 5f66ec5ee6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 4324 additions and 4025 deletions

4
.env
View file

@ -121,6 +121,10 @@ SAML_SP_PRIVATE_KEY="MIIE..."
# In demo mode things it is not possible for a user to change his password and his settings.
DEMO_MODE=0
# When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file.
# This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network.
ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK=0
# Change this to true, if no url rewriting (like mod_rewrite for Apache) is available
# In that case all URL contains the index.php front controller in URL
NO_URL_REWRITE_AVAILABLE=0

View file

@ -67,7 +67,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
- name: Install yarn dependencies
run: yarn install

View file

@ -106,7 +106,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
- name: Install yarn dependencies
run: yarn install
@ -129,7 +129,7 @@ jobs:
run: ./bin/phpunit --coverage-clover=coverage.xml
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
env_vars: PHP_VERSION,DB_TYPE
token: ${{ secrets.CODECOV_TOKEN }}

4
.gitignore vendored
View file

@ -25,6 +25,10 @@
uploads/*
!uploads/.keep
# Some people use Certbot or similar tools to make SSL certificates.
# Also see https://www.rfc-editor.org/rfc/rfc5785
public/.well-known/
# Do not keep cache files
.php_cs.cache
.phpcs-cache

View file

@ -62,7 +62,7 @@ RUN yarn build
RUN yarn cache clean && rm -rf node_modules/
# FrankenPHP base stage
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
FROM dunglas/frankenphp:1-php8.4-bookworm AS frankenphp_upstream
ARG TARGETARCH
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \

View file

@ -74,11 +74,11 @@ Part-DB is also used by small companies and universities for managing their inve
## Requirements
* A **web server** (like Apache2 or nginx) that is capable of
running [Symfony 6](https://symfony.com/doc/current/reference/requirements.html),
running [Symfony 7](https://symfony.com/doc/current/reference/requirements.html),
this includes a minimum PHP version of **PHP 8.2**
* A **MySQL** (at least 5.7) /**MariaDB** (at least 10.4) database server, or **PostgreSQL** 10+ if you do not want to use SQLite.
* Shell access to your server is highly recommended!
* For building the client-side assets **yarn** and **nodejs** (>= 20.0) is needed.
* For building the client-side assets **yarn** and **nodejs** (>= 22.0) is needed.
## Installation

1456
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -105,6 +105,8 @@ parameters:
env(DATABASE_EMULATE_NATURAL_SORT): 0
env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0
######################################################################################################################
# Bulk Info Provider Import Configuration
######################################################################################################################

View file

@ -1550,7 +1550,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* template_parameters?: array{ // Default parameters to be passed to the template
* className?: scalar|Param|null, // Default class attribute to apply to the root table elements // Default: "table table-bordered"
* columnFilter?: "thead"|"tfoot"|"both"|Param|null, // If and where to enable the DataTables Filter module // Default: null
* ...<mixed>
* ...<string, mixed>
* },
* translation_domain?: scalar|Param|null, // Default translation domain to be used // Default: "messages"
* }
@ -1705,14 +1705,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* use_underscore?: bool|Param, // Default: true
* unordered_list_markers?: list<scalar|Param|null>,
* },
* ...<mixed>
* ...<string, mixed>
* },
* }
* @psalm-type GregwarCaptchaConfig = array{
* length?: scalar|Param|null, // Default: 5
* width?: scalar|Param|null, // Default: 130
* height?: scalar|Param|null, // Default: 50
* font?: scalar|Param|null, // Default: "C:\\Users\\mail\\Documents\\PHP\\Part-DB-server\\vendor\\gregwar\\captcha-bundle\\DependencyInjection/../Generator/Font/captcha.ttf"
* font?: scalar|Param|null, // Default: "/home/jan/php/Part-DB-server/vendor/gregwar/captcha-bundle/DependencyInjection/../Generator/Font/captcha.ttf"
* keep_value?: scalar|Param|null, // Default: false
* charset?: scalar|Param|null, // Default: "abcdefhjkmnprstuvwxyz23456789"
* as_file?: scalar|Param|null, // Default: false
@ -2493,7 +2493,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* max_header_length?: int|Param, // Max header length supported by the cache server. // Default: 7500
* request_options?: mixed, // To pass options to the client charged with the request. // Default: []
* purger?: scalar|Param|null, // Specify a purger to use (available values: "api_platform.http_cache.purger.varnish.ban", "api_platform.http_cache.purger.varnish.xkey", "api_platform.http_cache.purger.souin"). // Default: "api_platform.http_cache.purger.varnish"
* xkey?: array{ // Deprecated: The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate paramters.
* xkey?: array{ // Deprecated: The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate parameters.
* glue?: scalar|Param|null, // xkey glue between keys // Default: " "
* },
* },
@ -2649,7 +2649,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* cast_fn?: mixed,
* default?: mixed,
* filter_class?: mixed,
* ...<mixed>
* ...<string, mixed>
* }>,
* strict_query_parameter_validation?: mixed,
* hide_hydra_operation?: mixed,
@ -2669,7 +2669,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* name?: mixed,
* allow_create?: mixed,
* item_uri_template?: mixed,
* ...<mixed>
* ...<string, mixed>
* },
* }
* @psalm-type ConfigType = array{

View file

@ -86,6 +86,7 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `ATTACHMENT_DOWNLOAD_BY_DEFAULT`: When this is set to 1, the "download external file" checkbox is checked by default
when adding a new attachment. Otherwise, it is unchecked by default. Use this if you wanna download all attachments
locally by default. Attachment download is only possible, when `ALLOW_ATTACHMENT_DOWNLOADS` is set to 1.
* `ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK` (default `0`): When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file. This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network.
* `ATTACHMENT_SHOW_HTML_FILES`: When enabled, user uploaded HTML attachments can be viewed directly in the browser.
Many potential malicious functions are restricted, still this is a potential security risk and should only be enabled,
if you trust the users who can upload files. When set to 0, HTML files are rendered as plain text.

View file

@ -95,6 +95,11 @@ services:
docker-compose up -d
```
{: .warning }
> If you run a root console inside the docker container, and wanna execute commands on the webserver behalf, be sure to use `sudo -E` command (with the `-E` flag) to preserve env variables from the current shell.
> Otherwise Part-DB console might use the wrong configuration to execute commands.
6. Create the initial database with
```bash

View file

@ -18,7 +18,7 @@ fulfilled by the official Part-DB docker image.*
Part-DB 2.0 requires at least PHP 8.2 (newer versions are recommended). So if your existing Part-DB installation is still
running PHP 8.1, you will have to upgrade your PHP version first.
The minimum required version of node.js is now 20.0 or newer, so if you are using 18.0, you will have to upgrade it too.
The minimum required version of node.js is now 22.0 or newer, so if you are using 18.0, you will have to upgrade it too.
Most distributions should have the possibility to get backports for PHP 8.4 and modern nodejs, so you should be able to
easily upgrade your system to the new requirements. Otherwise, you can use the official Part-DB docker image, which
@ -60,6 +60,8 @@ The `php bin/console partdb:backup` command can help you with this.
If you want to change them, you must migrate them to the settings interface as described below.
### Docker installation
**When running the console commands from inside a docker container's shell as root, be sure to use `sudo -E` to preserve the environment variables, so that they are correctly passed to the command.**
1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and the file where you configure the docker environment variables.
2. Stop the existing Part-DB container with `docker compose down`
3. Ensure that your docker compose file uses the new latest images (either `latest` or `2` tag).

View file

@ -9,16 +9,16 @@
"@symfony/stimulus-bridge": "^4.0.0",
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
"@symfony/webpack-encore": "^5.1.0",
"@symfony/webpack-encore": "^6.0.0",
"bootstrap": "^5.1.3",
"core-js": "^3.38.0",
"intl-messageformat": "^10.2.5",
"intl-messageformat": "^10.5.11",
"jquery": "^3.5.1",
"popper.js": "^1.14.7",
"regenerator-runtime": "^0.13.9",
"regenerator-runtime": "^0.14.1",
"webpack": "^5.74.0",
"webpack-bundle-analyzer": "^5.1.1",
"webpack-cli": "^5.1.0",
"webpack-cli": "^6.0.0",
"webpack-notifier": "^1.15.0"
},
"license": "AGPL-3.0-or-later",
@ -30,14 +30,14 @@
"build": "encore production --progress"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
},
"dependencies": {
"@algolia/autocomplete-js": "^1.17.0",
"@algolia/autocomplete-plugin-recent-searches": "^1.17.0",
"@algolia/autocomplete-theme-classic": "^1.17.0",
"@ckeditor/ckeditor5-dev-translations": "^43.0.1",
"@ckeditor/ckeditor5-dev-utils": "^43.0.1",
"@ckeditor/ckeditor5-dev-translations": "^53",
"@ckeditor/ckeditor5-dev-utils": "^53",
"@jbtronics/bs-treeview": "^1.0.1",
"@part-db/html5-qrcode": "^4.0.0",
"@zxcvbn-ts/core": "^3.0.2",
@ -69,11 +69,11 @@
"marked": "^17.0.1",
"marked-gfm-heading-id": "^4.1.1",
"marked-mangle": "^1.0.1",
"pdfmake": "^0.2.2",
"pdfmake": "^0.3.7",
"stimulus-use": "^0.52.0",
"tom-select": "^2.1.0",
"ts-loader": "^9.2.6",
"typescript": "^5.7.2"
"typescript": "^6.0.2"
},
"resolutions": {
"jquery": "^3.5.1"

View file

@ -56,13 +56,16 @@ class LoadFixturesCommand extends Command
}
$factory = new ResetAutoIncrementPurgerFactory();
$purger = $factory->createForEntityManager(null, $this->entityManager);
//Use truncate purging to fix compatibility with postgresql
$purger = $factory->createForEntityManager(null, $this->entityManager, purgeWithTruncate: true);
$purger->purge();
//Afterwards run the load fixtures command as normal, but with the --append option
$new_input = new ArrayInput([
'command' => 'doctrine:fixtures:load',
'--purge-with-truncate' => true,
'--append' => true,
]);
@ -70,4 +73,4 @@ class LoadFixturesCommand extends Command
return $returnCode ?? Command::FAILURE;
}
}
}

View file

@ -1,8 +1,5 @@
<?php
declare(strict_types=1);
/*
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
@ -20,23 +17,28 @@ declare(strict_types=1);
* 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\DataTables;
use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\Attachments\Attachment;
use App\Doctrine\Helpers\FieldHelper;
use App\Entity\Parts\Part;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
@ -44,9 +46,12 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class ProjectBomEntriesDataTable implements DataTableTypeInterface
{
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper,
protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
{
public function __construct(
protected EntityURLGenerator $entityURLGenerator,
protected TranslatorInterface $translator,
protected AmountFormatter $amountFormatter,
protected PartDataTableHelper $partDataTableHelper
) {
}
@ -62,7 +67,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
return '';
}
return $this->partDataTableHelper->renderPicture($context->getPart());
},
}
])
->add('id', TextColumn::class, [
@ -133,23 +138,24 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->add('category', EntityColumn::class, [
'label' => $this->translator->trans('part.table.category'),
'property' => 'part.category',
'orderField' => 'NATSORT(category.name)',
'orderField' => 'NATSORT(category.name)'
])
->add('footprint', EntityColumn::class, [
'property' => 'part.footprint',
'label' => $this->translator->trans('part.table.footprint'),
'orderField' => 'NATSORT(footprint.name)',
'orderField' => 'NATSORT(footprint.name)'
])
->add('manufacturer', EntityColumn::class, [
'property' => 'part.manufacturer',
'label' => $this->translator->trans('part.table.manufacturer'),
'orderField' => 'NATSORT(manufacturer.name)',
'orderField' => 'NATSORT(manufacturer.name)'
])
->add('manufacturing_status', EnumColumn::class, [
'label' => $this->translator->trans('part.table.manufacturingStatus'),
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
'orderField' => 'part.manufacturing_status',
'class' => ManufacturingStatus::class,
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
if ($status === null) {
@ -183,8 +189,10 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
return '';
}
])
->add('storageLocations', TextColumn::class, [
'label' => 'part.table.storeLocations',
->add('storelocation', TextColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'),
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
'orderField' => 'NATSORT(MIN(_storelocations.name))',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
if ($context->getPart() !== null) {
@ -207,11 +215,13 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
$dataTable->createAdapter(ORMAdapter::class, [
'entity' => Attachment::class,
'query' => function (QueryBuilder $builder) use ($options): void {
$this->getQuery($builder, $options);
$dataTable->createAdapter(TwoStepORMAdapter::class, [
'entity' => ProjectBOMEntry::class,
'hydrate' => AbstractQuery::HYDRATE_OBJECT,
'filter_query' => function (QueryBuilder $builder) use ($options): void {
$this->getFilterQuery($builder, $options);
},
'detail_query' => $this->getDetailQuery(...),
'criteria' => [
function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options);
@ -221,20 +231,71 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
]);
}
private function getQuery(QueryBuilder $builder, array $options): void
private function getFilterQuery(QueryBuilder $builder, array $options): void
{
$builder->select('bom_entry')
->addSelect('part')
$builder
->select('bom_entry.id')
->from(ProjectBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.partLots', '_partLots')
->leftJoin('_partLots.storage_location', '_storelocations')
->leftJoin('part.footprint', 'footprint')
->leftJoin('part.manufacturer', 'manufacturer')
->leftJoin('part.partCustomState', 'partCustomState')
->where('bom_entry.project = :project')
->setParameter('project', $options['project'])
->addGroupBy('bom_entry')
->addGroupBy('part')
->addGroupBy('category')
->addGroupBy('footprint')
->addGroupBy('manufacturer')
->addGroupBy('partCustomState')
;
}
private function getDetailQuery(QueryBuilder $builder, array $filter_results): void
{
$ids = array_map(static fn (array $row) => $row['id'], $filter_results);
if ($ids === []) {
$ids = [-1];
}
$builder
->select('bom_entry')
->addSelect('part')
->addSelect('category')
->addSelect('partLots')
->addSelect('storelocations')
->addSelect('footprint')
->addSelect('manufacturer')
->addSelect('partCustomState')
->from(ProjectBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.partLots', 'partLots')
->leftJoin('partLots.storage_location', 'storelocations')
->leftJoin('part.footprint', 'footprint')
->leftJoin('part.manufacturer', 'manufacturer')
->leftJoin('part.partCustomState', 'partCustomState')
->where('bom_entry.id IN (:ids)')
->setParameter('ids', $ids)
->addGroupBy('bom_entry')
->addGroupBy('part')
->addGroupBy('partLots')
->addGroupBy('category')
->addGroupBy('storelocations')
->addGroupBy('footprint')
->addGroupBy('manufacturer')
->addGroupBy('partCustomState')
->setHint(Query::HINT_READ_ONLY, true)
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
;
FieldHelper::addOrderByFieldParam($builder, 'bom_entry.id', 'ids');
}
private function buildCriteria(QueryBuilder $builder, array $options): void
{

View file

@ -139,7 +139,7 @@ class TypeSynonymRowType extends AbstractType
*/
private function getPreferredLocales(): array
{
$fromSettings = $this->localizationSettings->languageMenuEntries ?? [];
$fromSettings = $this->localizationSettings->languageMenuEntries;
return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam);
}

View file

@ -44,6 +44,8 @@ use App\Exceptions\AttachmentDownloadException;
use App\Settings\SystemSettings\AttachmentsSettings;
use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use const DIRECTORY_SEPARATOR;
use InvalidArgumentException;
use RuntimeException;
@ -76,6 +78,8 @@ class AttachmentSubmitHandler
protected FileTypeFilterTools $filterTools,
protected AttachmentsSettings $settings,
protected readonly SVGSanitizer $SVGSanitizer,
#[Autowire(env: "bool:ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK")]
private readonly bool $allow_local_network_downloads = false,
)
{
//The mapping used to determine which folder will be used for an attachment type
@ -95,6 +99,10 @@ class AttachmentSubmitHandler
UserAttachment::class => 'user',
LabelAttachment::class => 'label_profile',
];
if (!$this->allow_local_network_downloads) {
$this->httpClient = new NoPrivateNetworkHttpClient($this->httpClient);
}
}
/**
@ -373,6 +381,7 @@ class AttachmentSubmitHandler
],
];
$response = $this->httpClient->request('GET', $url, $opts);
//Digikey wants TLSv1.3, so try again with that if we get a 403
if ($response->getStatusCode() === 403) {
@ -434,8 +443,8 @@ class AttachmentSubmitHandler
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
//Save the path to the attachment
$attachment->setInternalPath($new_path);
} catch (TransportExceptionInterface) {
throw new AttachmentDownloadException('Transport error!');
} catch (TransportExceptionInterface $exception) {
throw new AttachmentDownloadException('Transport error: '.$exception->getMessage());
}
return $attachment;

View file

@ -42,6 +42,7 @@ use Brick\Schema\Interfaces\Thing;
use Brick\Schema\SchemaReader;
use Brick\Schema\SchemaTypeList;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class GenericWebProvider implements InfoProviderInterface
@ -55,7 +56,8 @@ class GenericWebProvider implements InfoProviderInterface
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever,
)
{
$this->httpClient = (new RandomizeUseragentHttpClient($httpClient))->withOptions(
//Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us
$this->httpClient = (new RandomizeUseragentHttpClient(new NoPrivateNetworkHttpClient($httpClient)))->withOptions(
[
'timeout' => 15,
]

View file

@ -280,9 +280,13 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
{
//If a URL starts with // we assume that it is a relative URL and we add the protocol
if (str_starts_with($url, '//')) {
return 'https:' . $url;
$url = 'https:' . $url;
}
//Encode bare % signs that are not already part of a valid percent-encoded sequence
//Fixes part numbers with % in them e.g. SMD0603-5K1-1%
$url = preg_replace('/%(?![0-9A-Fa-f]{2})/', '%25', $url);
return $url;
}

View file

@ -105,6 +105,10 @@ final class BarcodeScanHelper
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 @@ final class BarcodeScanHelper
return new AmazonBarcodeScanResult($input);
}
// Try TME barcode
if (TMEBarcodeScanResult::isTMEBarcode($input)) {
return TMEBarcodeScanResult::parse($input);
}
throw new InvalidArgumentException('Unknown barcode');
}
@ -162,6 +171,7 @@ final class BarcodeScanHelper
return LCSCBarcodeScanResult::parse($input);
}
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
{
$lot_repo = $this->entityManager->getRepository(PartLot::class);

View file

@ -150,6 +150,10 @@ final readonly class BarcodeScanResultHandler
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
}
if ($barcodeScan instanceof TMEBarcodeScanResult) {
return $this->resolvePartFromTME($barcodeScan);
}
return null;
}
@ -236,6 +240,26 @@ final readonly class BarcodeScanResultHandler
}
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 @@ final readonly class BarcodeScanResultHandler
*/
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 [

View file

@ -52,4 +52,7 @@ enum BarcodeSourceType: string
case LCSC = 'lcsc';
case AMAZON = 'amazon';
/** For TME (tme.eu) formatted QR codes */
case TME = 'tme';
}

View file

@ -254,12 +254,16 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
*/
public static function isFormat06Code(string $input): bool
{
//Code must begin with [)><RS>06<GS>
if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")){
return false;
//Code should begin with [)><RS>06<GS> as per the standard
if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")
// some codes don't contain record separators
&& !str_starts_with($input, "[)>06\u{1D}")
// This is found on old Mouser parts
&& !str_starts_with($input, ">[)>06\u{1D}"))
{
return false;
}
//Digikey does not put a trailer onto the barcode, so we just check for the header
//Digikey and Mouser don't put a trailer onto the barcode, so we just check for the header
return true;
}

View file

@ -0,0 +1,143 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* 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\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<string, string> $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);
}
}

View file

@ -0,0 +1,68 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* 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\Tests\Doctrine\Functions;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\SqlWalker;
use PHPUnit\Framework\TestCase;
abstract class AbstractDoctrineFunctionTestCase extends TestCase
{
protected function createSqlWalker(AbstractPlatform $platform, string $serverVersion = '11.0.0-MariaDB'): SqlWalker
{
$connection = $this->createMock(Connection::class);
$connection->method('getDatabasePlatform')->willReturn($platform);
$connection->method('getServerVersion')->willReturn($serverVersion);
$sqlWalker = $this->getMockBuilder(SqlWalker::class)
->disableOriginalConstructor()
->onlyMethods(['getConnection'])
->getMock();
$sqlWalker->method('getConnection')->willReturn($connection);
return $sqlWalker;
}
protected function createNode(string $sql): Node
{
$node = $this->createMock(Node::class);
$node->method('dispatch')->willReturn($sql);
return $node;
}
protected function setObjectProperty(object $object, string $property, mixed $value): void
{
$reflection = new \ReflectionProperty($object, $property);
$reflection->setValue($object, $value);
}
protected function setStaticProperty(string $class, string $property, mixed $value): void
{
$reflection = new \ReflectionProperty($class, $property);
$reflection->setValue(null, $value);
}
}

View file

@ -0,0 +1,42 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* 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\Tests\Doctrine\Functions;
use App\Doctrine\Functions\ArrayPosition;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
final class ArrayPositionTest extends AbstractDoctrineFunctionTestCase
{
public function testArrayPositionBuildsSql(): void
{
$function = new ArrayPosition('ARRAY_POSITION');
$this->setObjectProperty($function, 'array', $this->createNode(':ids'));
$this->setObjectProperty($function, 'field', $this->createNode('p.id'));
$sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform()));
$this->assertSame('ARRAY_POSITION(:ids, p.id)', $sql);
}
}

View file

@ -0,0 +1,45 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* 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\Tests\Doctrine\Functions;
use App\Doctrine\Functions\Field2;
use Doctrine\DBAL\Platforms\MySQLPlatform;
final class Field2Test extends AbstractDoctrineFunctionTestCase
{
public function testField2BuildsSql(): void
{
$function = new Field2('FIELD2');
$this->setObjectProperty($function, 'field', $this->createNode('p.id'));
$this->setObjectProperty($function, 'values', [
$this->createNode('1'),
$this->createNode('2'),
$this->createNode('3'),
]);
$sql = $function->getSql($this->createSqlWalker(new MySQLPlatform()));
$this->assertSame('FIELD2(p.id, 1, 2, 3)', $sql);
}
}

View file

@ -0,0 +1,66 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* 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\Tests\Doctrine\Functions;
use App\Doctrine\Functions\ILike;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use PHPUnit\Framework\Attributes\DataProvider;
final class ILikeTest extends AbstractDoctrineFunctionTestCase
{
public static function iLikePlatformProvider(): \Generator
{
yield 'mysql' => [new MySQLPlatform(), '(part_name LIKE :pattern)'];
yield 'postgres' => [new PostgreSQLPlatform(), '(part_name ILIKE :pattern)'];
yield 'sqlite' => [new SQLitePlatform(), "(part_name LIKE :pattern ESCAPE '\\')"];
}
#[DataProvider('iLikePlatformProvider')]
public function testILikeUsesExpectedOperator(AbstractPlatform $platform, string $expectedSql): void
{
$function = new ILike('ILIKE');
$function->value = $this->createNode('part_name');
$function->expr = $this->createNode(':pattern');
$sql = $function->getSql($this->createSqlWalker($platform));
$this->assertSame($expectedSql, $sql);
}
public function testILikeThrowsOnUnsupportedPlatform(): void
{
$function = new ILike('ILIKE');
$function->value = $this->createNode('part_name');
$function->expr = $this->createNode(':pattern');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('does not support case insensitive like expressions');
$function->getSql($this->createSqlWalker(new SQLServerPlatform()));
}
}

View file

@ -0,0 +1,95 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* 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\Tests\Doctrine\Functions;
use App\Doctrine\Functions\Natsort;
use Doctrine\DBAL\Platforms\MariaDBPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
final class NatsortTest extends AbstractDoctrineFunctionTestCase
{
protected function setUp(): void
{
parent::setUp();
Natsort::allowSlowNaturalSort(false);
$this->setStaticProperty(Natsort::class, 'supportsNaturalSort', null);
}
public function testNatsortUsesPostgresCollation(): void
{
$function = new Natsort('NATSORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform()));
$this->assertSame('part_name COLLATE numeric', $sql);
}
public function testNatsortUsesMariaDbNativeFunctionOnSupportedVersion(): void
{
$function = new Natsort('NATSORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new MariaDBPlatform(), '10.11.2-MariaDB'));
$this->assertSame('NATURAL_SORT_KEY(part_name)', $sql);
}
public function testNatsortFallsBackWithoutSlowSort(): void
{
$function = new Natsort('NATSORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new MariaDBPlatform(), '10.6.10-MariaDB'));
$this->assertSame('part_name', $sql);
}
public function testNatsortUsesSlowSortFunctionOnMySqlWhenEnabled(): void
{
Natsort::allowSlowNaturalSort();
$function = new Natsort('NATSORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new MySQLPlatform()));
$this->assertSame('NatSortKey(part_name, 0)', $sql);
}
public function testNatsortUsesSlowSortCollationOnSqliteWhenEnabled(): void
{
Natsort::allowSlowNaturalSort();
$function = new Natsort('NATSORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new SQLitePlatform()));
$this->assertSame('part_name COLLATE NATURAL_CMP', $sql);
}
}

View file

@ -0,0 +1,66 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* 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\Tests\Doctrine\Functions;
use App\Doctrine\Functions\Regexp;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use PHPUnit\Framework\Attributes\DataProvider;
final class RegexpTest extends AbstractDoctrineFunctionTestCase
{
public static function regexpPlatformProvider(): \Generator
{
yield 'mysql' => [new MySQLPlatform(), '(part_name REGEXP :regex)'];
yield 'sqlite' => [new SQLitePlatform(), '(part_name REGEXP :regex)'];
yield 'postgres' => [new PostgreSQLPlatform(), '(part_name ~* :regex)'];
}
#[DataProvider('regexpPlatformProvider')]
public function testRegexpUsesExpectedOperator(AbstractPlatform $platform, string $expectedSql): void
{
$function = new Regexp('REGEXP');
$this->setObjectProperty($function, 'value', $this->createNode('part_name'));
$this->setObjectProperty($function, 'regexp', $this->createNode(':regex'));
$sql = $function->getSql($this->createSqlWalker($platform));
$this->assertSame($expectedSql, $sql);
}
public function testRegexpThrowsOnUnsupportedPlatform(): void
{
$function = new Regexp('REGEXP');
$this->setObjectProperty($function, 'value', $this->createNode('part_name'));
$this->setObjectProperty($function, 'regexp', $this->createNode(':regex'));
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('does not support regular expressions');
$function->getSql($this->createSqlWalker(new SQLServerPlatform()));
}
}

View file

@ -0,0 +1,391 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* 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\Tests\Services\InfoProviderSystem\Providers;
use App\Entity\Parts\ManufacturingStatus;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
use App\Services\InfoProviderSystem\Providers\TMEClient;
use App\Services\InfoProviderSystem\Providers\TMEProvider;
use App\Settings\InfoProviderSystem\TMESettings;
use App\Tests\SettingsTestHelper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class TMEProviderTest extends TestCase
{
private TMESettings $settings;
private TMEProvider $provider;
private MockHttpClient $httpClient;
protected function setUp(): void
{
$this->httpClient = new MockHttpClient();
$this->settings = SettingsTestHelper::createSettingsDummy(TMESettings::class);
// Use a short (anonymous-style) token so grossPrices is read from settings
$this->settings->apiToken = 'test_token_000000000000000000000000000000000000000';
$this->settings->apiSecret = 'test_secret';
$this->settings->currency = 'EUR';
$this->settings->language = 'en';
$this->settings->country = 'DE';
$this->settings->grossPrices = false;
$this->provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings);
}
// --- Mock response helpers ---
// Only fields actually read by TMEProvider are included.
private function mockProductList(array $products): MockResponse
{
return new MockResponse(json_encode([
'Status' => 'OK',
'Data' => ['ProductList' => $products],
]));
}
private function mockFilesList(array $products): MockResponse
{
return new MockResponse(json_encode([
'Status' => 'OK',
'Data' => ['ProductList' => $products],
]));
}
private function mockParametersList(array $products): MockResponse
{
return new MockResponse(json_encode([
'Status' => 'OK',
'Data' => ['ProductList' => $products],
]));
}
private function mockPrices(string $currency, string $priceType, array $products): MockResponse
{
return new MockResponse(json_encode([
'Status' => 'OK',
'Data' => [
'Currency' => $currency,
'PriceType' => $priceType,
'ProductList' => $products,
],
]));
}
// --- Mock data ---
private function smd0603Products(): MockResponse
{
return $this->mockProductList([[
'Symbol' => 'SMD0603-5K1-1%',
'OriginalSymbol' => '0603SAF5101T5E',
'Producer' => 'ROYALOHM',
'Description' => 'Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C',
'Category' => 'SMD resistors',
'Photo' => '//ce8dc832c.cloudimg.io/v7/_cdn_/E9/C2/B0/00/0/732318_1.jpg',
'ProductStatusList' => [],
'ProductInformationPage' => '//www.tme.eu/en/details/smd0603-5k1-1%/smd-resistors/royalohm/0603saf5101t5e/',
'Weight' => 0.021,
'WeightUnit' => 'g',
]]);
}
private function smd0603Files(): MockResponse
{
return $this->mockFilesList([[
'Symbol' => 'SMD0603-5K1-1%',
'Files' => [
'AdditionalPhotoList' => [],
'DocumentList' => [
['DocumentUrl' => '//www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf'],
['DocumentUrl' => '//www.tme.eu/Document/c283990e907c122bb808207d1578ac7f/POWER_RATING-DTE.pdf'],
],
],
]]);
}
private function smd0603Parameters(): MockResponse
{
return $this->mockParametersList([[
'Symbol' => 'SMD0603-5K1-1%',
'ParameterList' => [
['ParameterId' => 34, 'ParameterName' => 'Type of resistor', 'ParameterValue' => 'thick film'],
['ParameterId' => 35, 'ParameterName' => 'Case - mm', 'ParameterValue' => '1608'],
['ParameterId' => 38, 'ParameterName' => 'Resistance', 'ParameterValue' => '5.1kΩ'],
['ParameterId' => 39, 'ParameterName' => 'Tolerance', 'ParameterValue' => '±1%'],
['ParameterId' => 120, 'ParameterName' => 'Operating voltage', 'ParameterValue' => '50V'],
],
]]);
}
private function smd0603Prices(): MockResponse
{
return $this->mockPrices('EUR', 'NET', [[
'Symbol' => 'SMD0603-5K1-1%',
'PriceList' => [
['Amount' => 100, 'PriceValue' => 0.01077],
['Amount' => 1000, 'PriceValue' => 0.00291],
['Amount' => 5000, 'PriceValue' => 0.00150],
],
]]);
}
private function etqp3mProducts(): MockResponse
{
return $this->mockProductList([[
'Symbol' => 'ETQP3M6R8KVP',
'OriginalSymbol' => 'ETQP3M6R8KVP',
'Producer' => 'PANASONIC',
'Description' => 'Inductor: wire; SMD; 6.8uH; 2.9A; R: 65.7mΩ; ±20%; ETQP3M; 5.5x5x3mm',
'Category' => 'Inductors',
'Photo' => '//ce8dc832c.cloudimg.io/v7/_cdn_/9E/27/A0/00/0/684777_1.jpg',
'ProductStatusList' => [],
'ProductInformationPage' => '//www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/',
'Weight' => 0.44,
'WeightUnit' => 'g',
]]);
}
private function etqp3mFiles(): MockResponse
{
return $this->mockFilesList([[
'Symbol' => 'ETQP3M6R8KVP',
'Files' => [
'AdditionalPhotoList' => [],
'DocumentList' => [
['DocumentUrl' => '//www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf'],
['DocumentUrl' => '//www.tme.eu/Document/8480690a42fa577214e35e33d3fc8d77/ETQP3M100KVN-LNK.txt'],
],
],
]]);
}
private function etqp3mParameters(): MockResponse
{
return $this->mockParametersList([[
'Symbol' => 'ETQP3M6R8KVP',
'ParameterList' => [
['ParameterId' => 566, 'ParameterName' => 'Inductance', 'ParameterValue' => '6.8µH'],
['ParameterId' => 370, 'ParameterName' => 'Operating current', 'ParameterValue' => '2.9A'],
['ParameterId' => 39, 'ParameterName' => 'Tolerance', 'ParameterValue' => '±20%'],
],
]]);
}
private function etqp3mPrices(): MockResponse
{
return $this->mockPrices('EUR', 'NET', [[
'Symbol' => 'ETQP3M6R8KVP',
'PriceList' => [
['Amount' => 1, 'PriceValue' => 0.589],
['Amount' => 5, 'PriceValue' => 0.429],
['Amount' => 10, 'PriceValue' => 0.399],
],
]]);
}
// --- Tests ---
public function testGetProviderInfo(): void
{
$info = $this->provider->getProviderInfo();
$this->assertIsArray($info);
$this->assertArrayHasKey('name', $info);
$this->assertArrayHasKey('description', $info);
$this->assertArrayHasKey('url', $info);
$this->assertEquals('TME', $info['name']);
$this->assertEquals('https://tme.eu/', $info['url']);
}
public function testGetProviderKey(): void
{
$this->assertSame('tme', $this->provider->getProviderKey());
}
public function testIsActiveWithCredentials(): void
{
$this->assertTrue($this->provider->isActive());
}
public function testIsActiveWithoutCredentials(): void
{
$this->settings->apiToken = null;
$provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings);
$this->assertFalse($provider->isActive());
}
public function testGetCapabilities(): void
{
$capabilities = $this->provider->getCapabilities();
$this->assertIsArray($capabilities);
$this->assertContains(ProviderCapabilities::BASIC, $capabilities);
$this->assertContains(ProviderCapabilities::PICTURE, $capabilities);
$this->assertContains(ProviderCapabilities::DATASHEET, $capabilities);
$this->assertContains(ProviderCapabilities::PRICE, $capabilities);
$this->assertContains(ProviderCapabilities::FOOTPRINT, $capabilities);
}
public function testGetHandledDomains(): void
{
$this->assertContains('tme.eu', $this->provider->getHandledDomains());
}
public function testGetIDFromURL(): void
{
$this->assertSame('fi321_se', $this->provider->getIDFromURL('https://www.tme.eu/de/details/fi321_se/kuhler/alutronic/'));
$this->assertSame('smd0603-5k1-1%25', $this->provider->getIDFromURL('https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/'));
$this->assertNull($this->provider->getIDFromURL('https://www.tme.eu/en/'));
}
public function testSearchByKeyword(): void
{
$this->httpClient->setResponseFactory([$this->smd0603Products()]);
$results = $this->provider->searchByKeyword('SMD0603-5K1-1%');
$this->assertIsArray($results);
$this->assertCount(1, $results);
$this->assertInstanceOf(SearchResultDTO::class, $results[0]);
$this->assertSame('SMD0603-5K1-1%', $results[0]->provider_id);
$this->assertSame('0603SAF5101T5E', $results[0]->name);
$this->assertSame('ROYALOHM', $results[0]->manufacturer);
$this->assertSame('SMD resistors', $results[0]->category);
$this->assertSame(ManufacturingStatus::ACTIVE, $results[0]->manufacturing_status);
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$results[0]->provider_url
);
}
public function testGetDetailsWithPercentInPartNumber(): void
{
$this->httpClient->setResponseFactory([
$this->smd0603Products(),
$this->smd0603Files(),
$this->smd0603Parameters(),
$this->smd0603Prices(),
]);
$result = $this->provider->getDetails('SMD0603-5K1-1%');
$this->assertInstanceOf(PartDetailDTO::class, $result);
$this->assertSame('SMD0603-5K1-1%', $result->provider_id);
$this->assertSame('0603SAF5101T5E', $result->name);
$this->assertSame('Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C', $result->description);
$this->assertSame('ROYALOHM', $result->manufacturer);
$this->assertSame('0603SAF5101T5E', $result->mpn);
$this->assertSame('SMD resistors', $result->category);
$this->assertSame(ManufacturingStatus::ACTIVE, $result->manufacturing_status);
$this->assertSame(0.021, $result->mass);
$this->assertSame('1608', $result->footprint);
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$result->provider_url
);
$this->assertCount(2, $result->datasheets);
$this->assertSame('https://www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf', $result->datasheets[0]->url);
$this->assertCount(0, $result->images);
$this->assertCount(1, $result->vendor_infos);
$vendorInfo = $result->vendor_infos[0];
$this->assertInstanceOf(PurchaseInfoDTO::class, $vendorInfo);
$this->assertSame('TME', $vendorInfo->distributor_name);
$this->assertSame('SMD0603-5K1-1%', $vendorInfo->order_number);
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$vendorInfo->product_url
);
$this->assertCount(3, $vendorInfo->prices);
$this->assertSame(100.0, $vendorInfo->prices[0]->minimum_discount_amount);
$this->assertSame('0.01077', $vendorInfo->prices[0]->price);
$this->assertSame('EUR', $vendorInfo->prices[0]->currency_iso_code);
$this->assertFalse($vendorInfo->prices[0]->includes_tax);
$this->assertCount(5, $result->parameters);
}
public function testGetDetailsForEtqp3m6r8kvp(): void
{
$this->httpClient->setResponseFactory([
$this->etqp3mProducts(),
$this->etqp3mFiles(),
$this->etqp3mParameters(),
$this->etqp3mPrices(),
]);
$result = $this->provider->getDetails('ETQP3M6R8KVP');
$this->assertInstanceOf(PartDetailDTO::class, $result);
$this->assertSame('ETQP3M6R8KVP', $result->provider_id);
$this->assertSame('ETQP3M6R8KVP', $result->name);
$this->assertSame('Inductor: wire; SMD; 6.8uH; 2.9A; R: 65.7mΩ; ±20%; ETQP3M; 5.5x5x3mm', $result->description);
$this->assertSame('PANASONIC', $result->manufacturer);
$this->assertSame('ETQP3M6R8KVP', $result->mpn);
$this->assertSame('Inductors', $result->category);
$this->assertSame(ManufacturingStatus::ACTIVE, $result->manufacturing_status);
$this->assertSame(0.44, $result->mass);
$this->assertNull($result->footprint);
$this->assertSame('https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/', $result->provider_url);
$this->assertCount(2, $result->datasheets);
$this->assertSame('https://www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf', $result->datasheets[0]->url);
$this->assertCount(0, $result->images);
$this->assertCount(1, $result->vendor_infos);
$vendorInfo = $result->vendor_infos[0];
$this->assertSame('TME', $vendorInfo->distributor_name);
$this->assertSame('ETQP3M6R8KVP', $vendorInfo->order_number);
$this->assertSame('https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/', $vendorInfo->product_url);
$this->assertCount(3, $vendorInfo->prices);
$this->assertSame(1.0, $vendorInfo->prices[0]->minimum_discount_amount);
$this->assertSame('0.589', $vendorInfo->prices[0]->price);
$this->assertSame('EUR', $vendorInfo->prices[0]->currency_iso_code);
$this->assertFalse($vendorInfo->prices[0]->includes_tax);
$this->assertCount(3, $result->parameters);
}
public function testNormalizeURLEncodesBarePctSign(): void
{
$method = (new \ReflectionClass($this->provider))->getMethod('normalizeURL');
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$method->invoke($this->provider, '//www.tme.eu/en/details/smd0603-5k1-1%/smd-resistors/royalohm/0603saf5101t5e/')
);
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$method->invoke($this->provider, '//www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/')
);
$this->assertSame(
'https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/',
$method->invoke($this->provider, '//www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/')
);
$this->assertSame('https://example.com/path', $method->invoke($this->provider, 'https://example.com/path'));
}
}

View file

@ -93,6 +93,13 @@ final class EIGP114BarcodeScanResultTest extends TestCase
//Valid code (digikey, without trailer)
$this->assertTrue(EIGP114BarcodeScanResult::isFormat06Code("[)>\x1e06\x1dPQ1045-ND\x1d1P364019-01\x1d30PQ1045-ND\x1dK12432 TRAVIS FOSS P\x1d1K85732873\x1d10K103332956\x1d9D231013\x1d1TQJ13P\x1d11K1\x1d4LTW\x1dQ3\x1d11ZPICK\x1d12Z7360988\x1d13Z999999\x1d20Z0000000000000000000000000000000000000000000000000000000000000000000000000000000000000"));
//Valid code (without record separator)
$this->assertTrue(EIGP114BarcodeScanResult::isFormat06Code("[)>06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04"));
//Old mouser format
$this->assertTrue(EIGP114BarcodeScanResult::isFormat06Code(">[)>06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04"));
}
public function testParseFormat06CodeInvalid(): void
@ -101,6 +108,32 @@ final class EIGP114BarcodeScanResultTest extends TestCase
EIGP114BarcodeScanResult::parseFormat06Code('');
}
public function testParseWithoutRecordSeparator(): void
{
$barcode = EIGP114BarcodeScanResult::parseFormat06Code("[)>06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04");
$this->assertSame([
'P' => '596-777A1-ND',
'1P' => 'XAF4444',
'Q' => '3',
'10D' => '1452',
'1T' => 'BF1103',
'4L' => 'US',
], $barcode->data);
}
public function testParseOldMouserFormat(): void
{
$barcode = EIGP114BarcodeScanResult::parseFormat06Code(">[)>06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04");
$this->assertSame([
'P' => '596-777A1-ND',
'1P' => 'XAF4444',
'Q' => '3',
'10D' => '1452',
'1T' => 'BF1103',
'4L' => 'US',
], $barcode->data);
}
public function testParseFormat06Code(): void
{
$barcode = EIGP114BarcodeScanResult::parseFormat06Code("[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04");

View file

@ -0,0 +1,110 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* 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/>.
*/
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']);
}
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="de">
<file id="messages.en">
<file id="messages.de">
<unit id="x_wTSQS" name="attachment_type.caption">
<segment state="translated">
<source>attachment_type.caption</source>
@ -12861,6 +12861,12 @@ Buerklin-API-Authentication-Server:
<target>Amazon Barcode</target>
</segment>
</unit>
<unit id="d.V2Pid" name="scan_dialog.mode.tme">
<segment state="translated">
<source>scan_dialog.mode.tme</source>
<target>TME Barcode</target>
</segment>
</unit>
<unit id="BQWuR_G" name="settings.ips.canopy">
<segment state="translated">
<source>settings.ips.canopy</source>

View file

@ -12947,6 +12947,12 @@ Buerklin-API Authentication server:
<target>Amazon barcode</target>
</segment>
</unit>
<unit id="d.V2Pid" name="scan_dialog.mode.tme">
<segment state="translated">
<source>scan_dialog.mode.tme</source>
<target>TME barcode</target>
</segment>
</unit>
<unit id="BQWuR_G" name="settings.ips.canopy">
<segment state="translated">
<source>settings.ips.canopy</source>

5554
yarn.lock

File diff suppressed because it is too large Load diff