diff --git a/docs/configuration.md b/docs/configuration.md
index 3f832958..7103f0ef 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -116,6 +116,8 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
value should be handled as confidential data and not shared publicly.
* `SHOW_PART_IMAGE_OVERLAY`: Set to 0 to disable the part image overlay, which appears if you hover over an image in the
part image gallery
+* `IPN_SUGGEST_REGEX`: A global regular expression, that part IPNs have to fullfill. Enforce your own format for your users.
+* `IPN_SUGGEST_REGEX_HELP`: Define your own user help text for the Regex format specification.
* `IPN_AUTO_APPEND_SUFFIX`: When enabled, an incremental suffix will be added to the user input when entering an existing
* IPN again upon saving.
* `IPN_SUGGEST_PART_DIGITS`: Defines the fixed number of digits used as the increment at the end of an IPN (Internal Part Number).
diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php
index 9cab2f9a..7d0937ab 100644
--- a/src/Form/Part/PartBaseType.php
+++ b/src/Form/Part/PartBaseType.php
@@ -84,6 +84,28 @@ class PartBaseType extends AbstractType
$descriptionAttr['data-ipn-suggestion'] = 'descriptionField';
}
+ $ipnAttr = [
+ 'class' => 'ipn-suggestion-field',
+ 'data-elements--ipn-suggestion-target' => 'input',
+ 'autocomplete' => 'off',
+ ];
+
+ if ($this->ipnSuggestSettings->regex !== null && $this->ipnSuggestSettings->regex !== '') {
+ $ipnAttr['pattern'] = $this->ipnSuggestSettings->regex;
+ $ipnAttr['placeholder'] = $this->ipnSuggestSettings->regex;
+ }
+
+ $ipnOptions = [
+ 'required' => false,
+ 'empty_data' => null,
+ 'label' => 'part.edit.ipn',
+ 'attr' => $ipnAttr,
+ ];
+
+ if (isset($ipnAttr['pattern']) && $this->ipnSuggestSettings->regexHelp !== null && $this->ipnSuggestSettings->regexHelp !== '') {
+ $ipnOptions['help'] = $this->ipnSuggestSettings->regexHelp;
+ }
+
//Common section
$builder
->add('name', TextType::class, [
@@ -186,16 +208,7 @@ class PartBaseType extends AbstractType
'disable_not_selectable' => true,
'label' => 'part.edit.partUnit',
])
- ->add('ipn', TextType::class, [
- 'required' => false,
- 'empty_data' => null,
- 'label' => 'part.edit.ipn',
- 'attr' => [
- 'class' => 'ipn-suggestion-field',
- 'data-elements--ipn-suggestion-target' => 'input',
- 'autocomplete' => 'off',
- ]
- ]);
+ ->add('ipn', TextType::class, $ipnOptions);
//Comment section
$builder->add('comment', RichTextEditorType::class, [
diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php
index 6974d254..3c83001a 100644
--- a/src/Repository/PartRepository.php
+++ b/src/Repository/PartRepository.php
@@ -285,7 +285,7 @@ class PartRepository extends NamedDBElementRepository
continue;
}
- if ($part->getId() === $currentPart->getId()) {
+ if ($part->getId() === $currentPart->getId() && $currentPart->getID() !== null) {
// Extract and return the current part's increment directly
$incrementPart = substr($part->getIpn(), -$suggestPartDigits);
if (is_numeric($incrementPart)) {
diff --git a/src/Settings/MiscSettings/IpnSuggestSettings.php b/src/Settings/MiscSettings/IpnSuggestSettings.php
index 96efcc33..5092dfaf 100644
--- a/src/Settings/MiscSettings/IpnSuggestSettings.php
+++ b/src/Settings/MiscSettings/IpnSuggestSettings.php
@@ -26,6 +26,7 @@ namespace App\Settings\MiscSettings;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
+use Jbtronics\SettingsBundle\ParameterTypes\StringType;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
@@ -38,6 +39,23 @@ class IpnSuggestSettings
{
use SettingsTrait;
+ #[SettingsParameter(
+ label: new TM("settings.misc.ipn_suggest.regex"),
+ options: ['type' => StringType::class],
+ formOptions: ['attr' => ['placeholder' => '^[A-Za-z0-9]{3,4}(?:-[A-Za-z0-9]{3,4})*-\d{4}$']],
+ envVar: "IPN_SUGGEST_REGEX", envVarMode: EnvVarMode::OVERWRITE,
+ )]
+ public ?string $regex = null;
+
+ #[SettingsParameter(
+ label: new TM("settings.misc.ipn_suggest.regex_help"),
+ description: new TM("settings.misc.ipn_suggest.regex_help_description"),
+ options: ['type' => StringType::class],
+ formOptions: ['attr' => ['placeholder' => 'Format: 3–4 alphanumeric segments (any number) separated by "-", followed by "-" and 4 digits, e.g., PCOM-RES-0001']],
+ envVar: "IPN_SUGGEST_REGEX_HELP", envVarMode: EnvVarMode::OVERWRITE,
+ )]
+ public ?string $regexHelp = null;
+
#[SettingsParameter(
label: new TM("settings.misc.ipn_suggest.autoAppendSuffix"),
envVar: "bool:IPN_AUTO_APPEND_SUFFIX", envVarMode: EnvVarMode::OVERWRITE,
diff --git a/tests/Repository/PartRepositoryTest.php b/tests/Repository/PartRepositoryTest.php
new file mode 100644
index 00000000..68b75abb
--- /dev/null
+++ b/tests/Repository/PartRepositoryTest.php
@@ -0,0 +1,297 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Repository;
+
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Part;
+use App\Settings\MiscSettings\IpnSuggestSettings;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Mapping\ClassMetadata;
+use PHPUnit\Framework\TestCase;
+use Doctrine\ORM\Query;
+use Doctrine\ORM\QueryBuilder;
+use Symfony\Contracts\Translation\TranslatorInterface;
+use App\Repository\PartRepository;
+
+final class PartRepositoryTest extends TestCase
+{
+ public function test_autocompleteSearch_builds_expected_query_without_db(): void
+ {
+ $qb = $this->getMockBuilder(QueryBuilder::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods([
+ 'select', 'leftJoin', 'where', 'orWhere',
+ 'setParameter', 'setMaxResults', 'orderBy', 'getQuery'
+ ])->getMock();
+
+ $qb->expects(self::once())->method('select')->with('part')->willReturnSelf();
+
+ $qb->expects(self::exactly(2))->method('leftJoin')->with($this->anything(), $this->anything())->willReturnSelf();
+
+ $qb->expects(self::atLeastOnce())->method('where')->with($this->anything())->willReturnSelf();
+ $qb->method('orWhere')->with($this->anything())->willReturnSelf();
+
+ $searchQuery = 'res';
+ $qb->expects(self::once())->method('setParameter')->with('query', '%'.$searchQuery.'%')->willReturnSelf();
+ $qb->expects(self::once())->method('setMaxResults')->with(10)->willReturnSelf();
+ $qb->expects(self::once())->method('orderBy')->with('NATSORT(part.name)', 'ASC')->willReturnSelf();
+
+ $emMock = $this->createMock(EntityManagerInterface::class);
+ $classMetadata = new ClassMetadata(Part::class);
+ $emMock->method('getClassMetadata')->with(Part::class)->willReturn($classMetadata);
+
+ $translatorMock = $this->createMock(TranslatorInterface::class);
+ $ipnSuggestSettings = $this->createMock(IpnSuggestSettings::class);
+
+ $repo = $this->getMockBuilder(PartRepository::class)
+ ->setConstructorArgs([$emMock, $translatorMock, $ipnSuggestSettings])
+ ->onlyMethods(['createQueryBuilder'])
+ ->getMock();
+
+ $repo->expects(self::once())
+ ->method('createQueryBuilder')
+ ->with('part')
+ ->willReturn($qb);
+
+ $part = new Part(); // create found part, because it is not saved in DB
+ $part->setName('Resistor');
+
+ $queryMock = $this->getMockBuilder(Query::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getResult'])
+ ->getMock();
+ $queryMock->expects(self::once())->method('getResult')->willReturn([$part]);
+
+ $qb->method('getQuery')->willReturn($queryMock);
+
+ $result = $repo->autocompleteSearch($searchQuery, 10);
+
+ // Check one part found and returned
+ self::assertIsArray($result);
+ self::assertCount(1, $result);
+ self::assertSame($part, $result[0]);
+ }
+
+ public function test_autoCompleteIpn_with_unsaved_part_and_category_without_part_description(): void
+ {
+ $qb = $this->getMockBuilder(QueryBuilder::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods([
+ 'select', 'leftJoin', 'where', 'andWhere', 'orWhere',
+ 'setParameter', 'setMaxResults', 'orderBy', 'getQuery'
+ ])->getMock();
+
+ $qb->method('select')->willReturnSelf();
+ $qb->method('leftJoin')->willReturnSelf();
+ $qb->method('where')->willReturnSelf();
+ $qb->method('andWhere')->willReturnSelf();
+ $qb->method('orWhere')->willReturnSelf();
+ $qb->method('setParameter')->willReturnSelf();
+ $qb->method('setMaxResults')->willReturnSelf();
+ $qb->method('orderBy')->willReturnSelf();
+
+ $emMock = $this->createMock(EntityManagerInterface::class);
+ $classMetadata = new ClassMetadata(Part::class);
+ $emMock->method('getClassMetadata')->with(Part::class)->willReturn($classMetadata);
+
+ $translatorMock = $this->createMock(TranslatorInterface::class);
+ $translatorMock->method('trans')
+ ->willReturnCallback(static function (string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string {
+ return $id;
+ });
+
+ $ipnSuggestSettings = $this->createMock(IpnSuggestSettings::class);
+
+ $ipnSuggestSettings->suggestPartDigits = 4;
+ $ipnSuggestSettings->useDuplicateDescription = false;
+
+ $repo = $this->getMockBuilder(PartRepository::class)
+ ->setConstructorArgs([$emMock, $translatorMock, $ipnSuggestSettings])
+ ->onlyMethods(['createQueryBuilder'])
+ ->getMock();
+
+ $repo->expects(self::atLeastOnce())
+ ->method('createQueryBuilder')
+ ->with('part')
+ ->willReturn($qb);
+
+ $queryMock = $this->getMockBuilder(Query::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getResult'])
+ ->getMock();
+
+ $categoryParent = new Category();
+ $categoryParent->setName('Passive components');
+ $categoryParent->setPartIpnPrefix('PCOM');
+
+ $categoryChild = new Category();
+ $categoryChild->setName('Resistors');
+ $categoryChild->setPartIpnPrefix('RES');
+ $categoryChild->setParent($categoryParent);
+
+ $partForSuggestGeneration = new Part(); // create found part, because it is not saved in DB
+ $partForSuggestGeneration->setIpn('RES-0001');
+ $partForSuggestGeneration->setCategory($categoryChild);
+
+ $queryMock->method('getResult')->willReturn([$partForSuggestGeneration]);
+ $qb->method('getQuery')->willReturn($queryMock);
+ $suggestions = $repo->autoCompleteIpn($partForSuggestGeneration, '', 4);
+
+ // Check structure available
+ self::assertIsArray($suggestions);
+ self::assertArrayHasKey('commonPrefixes', $suggestions);
+ self::assertArrayHasKey('prefixesPartIncrement', $suggestions);
+ self::assertNotEmpty($suggestions['commonPrefixes']);
+ self::assertNotEmpty($suggestions['prefixesPartIncrement']);
+
+ // Check expected values
+ self::assertSame('RES-', $suggestions['commonPrefixes'][0]['title']);
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category', $suggestions['commonPrefixes'][0]['description']);
+ self::assertSame('PCOM-RES-', $suggestions['commonPrefixes'][1]['title']);
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment', $suggestions['commonPrefixes'][1]['description']);
+
+ self::assertSame('RES-0002', $suggestions['prefixesPartIncrement'][0]['title']); // next possible free increment for given part category
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category.increment', $suggestions['prefixesPartIncrement'][0]['description']);
+ self::assertSame('PCOM-RES-0002', $suggestions['prefixesPartIncrement'][1]['title']); // next possible free increment for given part category
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.increment', $suggestions['prefixesPartIncrement'][1]['description']);
+ }
+
+ public function test_autoCompleteIpn_with_unsaved_part_and_category_with_part_description(): void
+ {
+ $qb = $this->getMockBuilder(QueryBuilder::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods([
+ 'select', 'leftJoin', 'where', 'andWhere', 'orWhere',
+ 'setParameter', 'setMaxResults', 'orderBy', 'getQuery'
+ ])->getMock();
+
+ $qb->method('select')->willReturnSelf();
+ $qb->method('leftJoin')->willReturnSelf();
+ $qb->method('where')->willReturnSelf();
+ $qb->method('andWhere')->willReturnSelf();
+ $qb->method('orWhere')->willReturnSelf();
+ $qb->method('setParameter')->willReturnSelf();
+ $qb->method('setMaxResults')->willReturnSelf();
+ $qb->method('orderBy')->willReturnSelf();
+
+ $emMock = $this->createMock(EntityManagerInterface::class);
+ $classMetadata = new ClassMetadata(Part::class);
+ $emMock->method('getClassMetadata')->with(Part::class)->willReturn($classMetadata);
+
+ $translatorMock = $this->createMock(TranslatorInterface::class);
+ $translatorMock->method('trans')
+ ->willReturnCallback(static function (string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string {
+ return $id;
+ });
+
+ $ipnSuggestSettings = $this->createMock(IpnSuggestSettings::class);
+
+ $ipnSuggestSettings->suggestPartDigits = 4;
+ $ipnSuggestSettings->useDuplicateDescription = false;
+
+ $repo = $this->getMockBuilder(PartRepository::class)
+ ->setConstructorArgs([$emMock, $translatorMock, $ipnSuggestSettings])
+ ->onlyMethods(['createQueryBuilder'])
+ ->getMock();
+
+ $repo->expects(self::atLeastOnce())
+ ->method('createQueryBuilder')
+ ->with('part')
+ ->willReturn($qb);
+
+ $queryMock = $this->getMockBuilder(Query::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getResult'])
+ ->getMock();
+
+ $categoryParent = new Category();
+ $categoryParent->setName('Passive components');
+ $categoryParent->setPartIpnPrefix('PCOM');
+
+ $categoryChild = new Category();
+ $categoryChild->setName('Resistors');
+ $categoryChild->setPartIpnPrefix('RES');
+ $categoryChild->setParent($categoryParent);
+
+ $partForSuggestGeneration = new Part(); // create found part, because it is not saved in DB
+ $partForSuggestGeneration->setCategory($categoryChild);
+ $partForSuggestGeneration->setIpn('1810-1679_1');
+ $partForSuggestGeneration->setDescription('NETWORK-RESISTOR 4 0 OHM +5PCT 0.063W TKF SMT');
+
+ $queryMock->method('getResult')->willReturn([$partForSuggestGeneration]);
+ $qb->method('getQuery')->willReturn($queryMock);
+ $suggestions = $repo->autoCompleteIpn($partForSuggestGeneration, 'NETWORK-RESISTOR 4 0 OHM +5PCT 0.063W TKF SMT', 4);
+
+ // Check structure available
+ self::assertIsArray($suggestions);
+ self::assertArrayHasKey('commonPrefixes', $suggestions);
+ self::assertArrayHasKey('prefixesPartIncrement', $suggestions);
+ self::assertNotEmpty($suggestions['commonPrefixes']);
+ self::assertCount(2, $suggestions['commonPrefixes']);
+ self::assertNotEmpty($suggestions['prefixesPartIncrement']);
+ self::assertCount(2, $suggestions['prefixesPartIncrement']);
+
+ // Check expected values without any increment, for user to decide
+ self::assertSame('RES-', $suggestions['commonPrefixes'][0]['title']);
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category', $suggestions['commonPrefixes'][0]['description']);
+ self::assertSame('PCOM-RES-', $suggestions['commonPrefixes'][1]['title']);
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment', $suggestions['commonPrefixes'][1]['description']);
+
+ // Check expected values with next possible increment at category level
+ self::assertSame('RES-0001', $suggestions['prefixesPartIncrement'][0]['title']); // next possible free increment for given part category
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category.increment', $suggestions['prefixesPartIncrement'][0]['description']);
+ self::assertSame('PCOM-RES-0001', $suggestions['prefixesPartIncrement'][1]['title']); // next possible free increment for given part category
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.increment', $suggestions['prefixesPartIncrement'][1]['description']);
+
+ $ipnSuggestSettings->useDuplicateDescription = true;
+
+ $suggestionsWithSameDescription = $repo->autoCompleteIpn($partForSuggestGeneration, 'NETWORK-RESISTOR 4 0 OHM +5PCT 0.063W TKF SMT', 4);
+
+ // Check structure available
+ self::assertIsArray($suggestionsWithSameDescription);
+ self::assertArrayHasKey('commonPrefixes', $suggestionsWithSameDescription);
+ self::assertArrayHasKey('prefixesPartIncrement', $suggestionsWithSameDescription);
+ self::assertNotEmpty($suggestionsWithSameDescription['commonPrefixes']);
+ self::assertCount(2, $suggestionsWithSameDescription['commonPrefixes']);
+ self::assertNotEmpty($suggestionsWithSameDescription['prefixesPartIncrement']);
+ self::assertCount(4, $suggestionsWithSameDescription['prefixesPartIncrement']);
+
+ // Check expected values without any increment, for user to decide
+ self::assertSame('RES-', $suggestionsWithSameDescription['commonPrefixes'][0]['title']);
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category', $suggestionsWithSameDescription['commonPrefixes'][0]['description']);
+ self::assertSame('PCOM-RES-', $suggestionsWithSameDescription['commonPrefixes'][1]['title']);
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment', $suggestionsWithSameDescription['commonPrefixes'][1]['description']);
+
+ // Check expected values with next possible increment at part description level
+ self::assertSame('1810-1679_1', $suggestionsWithSameDescription['prefixesPartIncrement'][0]['title']); // current given value
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.description.current-increment', $suggestionsWithSameDescription['prefixesPartIncrement'][0]['description']);
+ self::assertSame('1810-1679_2', $suggestionsWithSameDescription['prefixesPartIncrement'][1]['title']); // next possible value
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.description.increment', $suggestionsWithSameDescription['prefixesPartIncrement'][1]['description']);
+
+ // Check expected values with next possible increment at category level
+ self::assertSame('RES-0001', $suggestionsWithSameDescription['prefixesPartIncrement'][2]['title']); // next possible free increment for given part category
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category.increment', $suggestionsWithSameDescription['prefixesPartIncrement'][2]['description']);
+ self::assertSame('PCOM-RES-0001', $suggestionsWithSameDescription['prefixesPartIncrement'][3]['title']); // next possible free increment for given part category
+ self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.increment', $suggestionsWithSameDescription['prefixesPartIncrement'][3]['description']);
+ }
+}
diff --git a/translations/messages.cs.xlf b/translations/messages.cs.xlf
index ea232228..52869ff5 100644
--- a/translations/messages.cs.xlf
+++ b/translations/messages.cs.xlf
@@ -13059,6 +13059,24 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
Seznam návrhů IPN součástek
+
+
+ settings.misc.ipn_suggest.regex
+ Regex
+
+
+
+
+ settings.misc.ipn_suggest.regex_help
+ Nápověda text
+
+
+
+
+ settings.misc.ipn_suggest.regex_help_description
+ Definujte svůj vlastní text nápovědy pro specifikaci formátu Regex.
+
+
settings.misc.ipn_suggest.autoAppendSuffix
diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf
index f316ae1d..d2f854ad 100644
--- a/translations/messages.de.xlf
+++ b/translations/messages.de.xlf
@@ -13139,6 +13139,24 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
Bauteil IPN-Vorschlagsliste
+
+
+ settings.misc.ipn_suggest.regex
+ Regex
+
+
+
+
+ settings.misc.ipn_suggest.regex_help
+ Hilfetext
+
+
+
+
+ settings.misc.ipn_suggest.regex_help_description
+ Definieren Sie Ihren eigenen Nuter-Hilfetext zur Regex Formatvorgabe.
+
+
settings.misc.ipn_suggest.autoAppendSuffix
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index bee33d30..71d166d2 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -13140,6 +13140,24 @@ Please note, that you can not impersonate a disabled user. If you try you will g
Part IPN Suggest
+
+
+ settings.misc.ipn_suggest.regex
+ Regex
+
+
+
+
+ settings.misc.ipn_suggest.regex_help
+ Help text
+
+
+
+
+ settings.misc.ipn_suggest.regex_help_description
+ Define your own user help text for the Regex format specification.
+
+
settings.misc.ipn_suggest.autoAppendSuffix