Added feature for part IPN suggest with category prefixes (#1054)

* Erweiterungstätigkeiten zur IPN-Vorschlagsliste anhand von Präfixen aus den Kategorien

* Umstellung Migrationen bzgl. Multi-Plattform-Support.
Zunächst MySQL, SQLite Statements integrieren.

* Postgre Statements integrieren

* SQL-Formatierung in Migration verbessern

* Erweitere IPN-Suggest um Bauteilbeschreibung.

Die Implementierung berücksichtigt nun zusätzlich die Bauteilbeschreibung zu maximal 150 Zeichen Länge für die Generierung von IPN-Vorschlägen und Inkrementen.

* Anpassungen aus Analyse vornehmen

* IPN-Validierung für Parts überarbeiten

* IPN-Vorschlagslogik um Konfiguration erweitert

* Anpassungen aus phpstan Analyse

* IPN-Vorschlagslogik erweitert und Bauteil-IPN vereindeutigt

Die IPN-Logik wurde um eine Konfiguration zur automatischen Suffix-Anfügung und die Berücksichtigung von doppelten Beschreibungen bei Bedarf ergänzt. Zudem wurde das Datenmodell angepasst, um eine eindeutige Speicherung der IPN zu gewährleisten.

* Regex-Konfigurationsmöglichkeit für IPN-Vorschläge einführen

Die Einstellungen für die IPN-Vorschlagslogik wurden um eine Regex-Validierung und eine Hilfetext-Konfiguration erweitert. Tests und Änderungen an den Formularoptionen wurden implementiert.

* Match range assert and form limits in suggestPartDigits

* Keep existing behavior with autoAppend suffix by default

* Show the regex hint in the browser validation notice.

* Improved translations

* Removed unnecessary service definition

* Removed german comments

---------

Co-authored-by: Marcel Diegelmann <marcel.diegelmann@gmail.com>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
web-devinition.de 2025-11-03 00:31:47 +01:00 committed by GitHub
parent 14a4f1f437
commit 771857e014
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2791 additions and 115 deletions

View file

@ -47,6 +47,7 @@ use App\Services\Parts\PartLotWithdrawAddHelper;
use App\Services\Parts\PricedetailHelper;
use App\Services\ProjectSystem\ProjectBuildPartHelper;
use App\Settings\BehaviorSettings\PartInfoSettings;
use App\Settings\MiscSettings\IpnSuggestSettings;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@ -74,6 +75,7 @@ final class PartController extends AbstractController
private readonly EntityManagerInterface $em,
private readonly EventCommentHelper $commentHelper,
private readonly PartInfoSettings $partInfoSettings,
private readonly IpnSuggestSettings $ipnSuggestSettings,
) {
}
@ -444,10 +446,13 @@ final class PartController extends AbstractController
$template = 'parts/edit/update_from_ip.html.twig';
}
$partRepository = $this->em->getRepository(Part::class);
return $this->render(
$template,
[
'part' => $new_part,
'ipnSuggestions' => $partRepository->autoCompleteIpn($data, $data->getDescription(), $this->ipnSuggestSettings->suggestPartDigits),
'form' => $form,
'merge_old_name' => $merge_infos['tname_before'] ?? null,
'merge_other' => $merge_infos['other_part'] ?? null,
@ -457,7 +462,6 @@ final class PartController extends AbstractController
);
}
#[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])]
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
{

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Parameters\AbstractParameter;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
@ -60,8 +61,11 @@ use Symfony\Component\Serializer\Serializer;
#[Route(path: '/typeahead')]
class TypeaheadController extends AbstractController
{
public function __construct(protected AttachmentURLGenerator $urlGenerator, protected Packages $assets)
{
public function __construct(
protected AttachmentURLGenerator $urlGenerator,
protected Packages $assets,
protected IpnSuggestSettings $ipnSuggestSettings,
) {
}
#[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')]
@ -183,4 +187,30 @@ class TypeaheadController extends AbstractController
return new JsonResponse($data, Response::HTTP_OK, [], true);
}
#[Route(path: '/parts/ipn-suggestions', name: 'ipn_suggestions', methods: ['GET'])]
public function ipnSuggestions(
Request $request,
EntityManagerInterface $entityManager
): JsonResponse {
$partId = $request->query->get('partId');
if ($partId === '0' || $partId === 'undefined' || $partId === 'null') {
$partId = null;
}
$categoryId = $request->query->getInt('categoryId');
$description = base64_decode($request->query->getString('description'), true);
/** @var Part $part */
$part = $partId !== null ? $entityManager->getRepository(Part::class)->find($partId) : new Part();
/** @var Category|null $category */
$category = $entityManager->getRepository(Category::class)->find($categoryId);
$clonedPart = clone $part;
$clonedPart->setCategory($category);
$partRepository = $entityManager->getRepository(Part::class);
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits);
return new JsonResponse($ipnSuggestions);
}
}

View file

@ -118,6 +118,13 @@ class Category extends AbstractPartsContainingDBElement
#[ORM\Column(type: Types::TEXT)]
protected string $partname_regex = '';
/**
* @var string The prefix for ipn generation for created parts in this category.
*/
#[Groups(['full', 'import', 'category:read', 'category:write'])]
#[ORM\Column(type: Types::STRING, length: 255, nullable: false, options: ['default' => ''])]
protected string $part_ipn_prefix = '';
/**
* @var bool Set to true, if the footprints should be disabled for parts this category (not implemented yet).
*/
@ -225,6 +232,16 @@ class Category extends AbstractPartsContainingDBElement
return $this;
}
public function getPartIpnPrefix(): string
{
return $this->part_ipn_prefix;
}
public function setPartIpnPrefix(string $part_ipn_prefix): void
{
$this->part_ipn_prefix = $part_ipn_prefix;
}
public function isDisableFootprints(): bool
{
return $this->disable_footprints;

View file

@ -61,7 +61,6 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@ -75,7 +74,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* @extends AttachmentContainingDBElement<PartAttachment>
* @template-use ParametersTrait<PartParameter>
*/
#[UniqueEntity(fields: ['ipn'], message: 'part.ipn.must_be_unique')]
#[ORM\Entity(repositoryClass: PartRepository::class)]
#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
#[ORM\Table('`parts`')]

View file

@ -30,6 +30,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\Length;
use App\Validator\Constraints\UniquePartIpnConstraint;
/**
* Advanced properties of a part, not related to a more specific group.
@ -65,6 +66,7 @@ trait AdvancedPropertyTrait
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::STRING, length: 100, unique: true, nullable: true)]
#[Length(max: 100)]
#[UniquePartIpnConstraint]
protected ?string $ipn = null;
/**

View file

@ -0,0 +1,97 @@
<?php
namespace App\EventSubscriber\UserSystem;
use App\Entity\Parts\Part;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\OnFlushEventArgs;
class PartUniqueIpnSubscriber implements EventSubscriber
{
public function __construct(
private IpnSuggestSettings $ipnSuggestSettings
) {
}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
if (!$this->ipnSuggestSettings->autoAppendSuffix) {
return;
}
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
$meta = $em->getClassMetadata(Part::class);
// Collect all IPNs already reserved in the current flush (so new entities do not collide with each other)
$reservedIpns = [];
// Helper to assign a collision-free IPN for a Part entity
$ensureUnique = function (Part $part) use ($em, $uow, $meta, &$reservedIpns) {
$ipn = $part->getIpn();
if ($ipn === null || $ipn === '') {
return;
}
// Check against IPNs already reserved in the current flush (except itself)
$originalIpn = $ipn;
$candidate = $originalIpn;
$increment = 1;
$conflicts = function (string $candidate) use ($em, $part, $reservedIpns) {
// Collision within the current flush session?
if (isset($reservedIpns[$candidate]) && $reservedIpns[$candidate] !== $part) {
return true;
}
// Collision with an existing DB row?
$existing = $em->getRepository(Part::class)->findOneBy(['ipn' => $candidate]);
return $existing !== null && $existing->getId() !== $part->getId();
};
while ($conflicts($candidate)) {
$candidate = $originalIpn . '_' . $increment;
$increment++;
}
if ($candidate !== $ipn) {
$before = $part->getIpn();
$part->setIpn($candidate);
// Recompute the change set so Doctrine writes the change
$uow->recomputeSingleEntityChangeSet($meta, $part);
$reservedIpns[$candidate] = $part;
// If the old IPN was reserved already, clean it up
if ($before !== null && isset($reservedIpns[$before]) && $reservedIpns[$before] === $part) {
unset($reservedIpns[$before]);
}
} else {
// Candidate unchanged, but reserve it so subsequent entities see it
$reservedIpns[$candidate] = $part;
}
};
// 1) Iterate over new entities
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof Part) {
$ensureUnique($entity);
}
}
// 2) Iterate over updates (if IPN changed, ensure uniqueness again)
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($entity instanceof Part) {
$ensureUnique($entity);
}
}
}
}

View file

@ -84,6 +84,17 @@ class CategoryAdminForm extends BaseEntityAdminForm
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);
$builder->add('part_ipn_prefix', TextType::class, [
'required' => false,
'empty_data' => '',
'label' => 'category.edit.part_ipn_prefix',
'help' => 'category.edit.part_ipn_prefix.help',
'attr' => [
'placeholder' => 'category.edit.part_ipn_prefix.placeholder',
],
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);
$builder->add('default_description', RichTextEditorType::class, [
'required' => false,
'empty_data' => '',

View file

@ -42,6 +42,7 @@ use App\Form\Type\StructuralEntityType;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\LogSystem\EventCommentNeededHelper;
use App\Services\LogSystem\EventCommentType;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@ -57,8 +58,12 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class PartBaseType extends AbstractType
{
public function __construct(protected Security $security, protected UrlGeneratorInterface $urlGenerator, protected EventCommentNeededHelper $event_comment_needed_helper)
{
public function __construct(
protected Security $security,
protected UrlGeneratorInterface $urlGenerator,
protected EventCommentNeededHelper $event_comment_needed_helper,
protected IpnSuggestSettings $ipnSuggestSettings,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@ -70,6 +75,39 @@ class PartBaseType extends AbstractType
/** @var PartDetailDTO|null $dto */
$dto = $options['info_provider_dto'];
$descriptionAttr = [
'placeholder' => 'part.edit.description.placeholder',
'rows' => 2,
];
if ($this->ipnSuggestSettings->useDuplicateDescription) {
// Only add attribute when duplicate description feature is enabled
$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;
$ipnAttr['title'] = $this->ipnSuggestSettings->regexHelp;
}
$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, [
@ -84,10 +122,7 @@ class PartBaseType extends AbstractType
'empty_data' => '',
'label' => 'part.edit.description',
'mode' => 'markdown-single_line',
'attr' => [
'placeholder' => 'part.edit.description.placeholder',
'rows' => 2,
],
'attr' => $descriptionAttr,
])
->add('minAmount', SIUnitType::class, [
'attr' => [
@ -105,6 +140,9 @@ class PartBaseType extends AbstractType
'disable_not_selectable' => true,
//Do not require category for new parts, so that the user must select the category by hand and cannot forget it (the requirement is handled by the constraint in the entity)
'required' => !$new_part,
'attr' => [
'data-ipn-suggestion' => 'categoryField',
]
])
->add('footprint', StructuralEntityType::class, [
'class' => Footprint::class,
@ -178,11 +216,7 @@ class PartBaseType extends AbstractType
'disable_not_selectable' => true,
'label' => 'part.edit.partCustomState',
])
->add('ipn', TextType::class, [
'required' => false,
'empty_data' => null,
'label' => 'part.edit.ipn',
]);
->add('ipn', TextType::class, $ipnOptions);
//Comment section
$builder->add('comment', RichTextEditorType::class, [

View file

@ -22,17 +22,35 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\Parts\Category;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Symfony\Contracts\Translation\TranslatorInterface;
use Doctrine\ORM\EntityManagerInterface;
/**
* @extends NamedDBElementRepository<Part>
*/
class PartRepository extends NamedDBElementRepository
{
private TranslatorInterface $translator;
private IpnSuggestSettings $ipnSuggestSettings;
public function __construct(
EntityManagerInterface $em,
TranslatorInterface $translator,
IpnSuggestSettings $ipnSuggestSettings,
) {
parent::__construct($em, $em->getClassMetadata(Part::class));
$this->translator = $translator;
$this->ipnSuggestSettings = $ipnSuggestSettings;
}
/**
* Gets the summed up instock of all parts (only parts without a measurement unit).
*
@ -84,8 +102,7 @@ class PartRepository extends NamedDBElementRepository
->where('ILIKE(part.name, :query) = TRUE')
->orWhere('ILIKE(part.description, :query) = TRUE')
->orWhere('ILIKE(category.name, :query) = TRUE')
->orWhere('ILIKE(footprint.name, :query) = TRUE')
;
->orWhere('ILIKE(footprint.name, :query) = TRUE');
$qb->setParameter('query', '%'.$query.'%');
@ -94,4 +111,240 @@ class PartRepository extends NamedDBElementRepository
return $qb->getQuery()->getResult();
}
/**
* Provides IPN (Internal Part Number) suggestions for a given part based on its category, description,
* and configured autocomplete digit length.
*
* This function generates suggestions for common prefixes and incremented prefixes based on
* the part's current category and its hierarchy. If the part is unsaved, a default "n.a." prefix is returned.
*
* @param Part $part The part for which autocomplete suggestions are generated.
* @param string $description description to assist in generating suggestions.
* @param int $suggestPartDigits The number of digits used in autocomplete increments.
*
* @return array An associative array containing the following keys:
* - 'commonPrefixes': List of common prefixes found for the part.
* - 'prefixesPartIncrement': Increments for the generated prefixes, including hierarchical prefixes.
*/
public function autoCompleteIpn(Part $part, string $description, int $suggestPartDigits): array
{
$category = $part->getCategory();
$ipnSuggestions = ['commonPrefixes' => [], 'prefixesPartIncrement' => []];
if (strlen($description) > 150) {
$description = substr($description, 0, 150);
}
if ($description !== '' && $this->ipnSuggestSettings->useDuplicateDescription) {
// Check if the description is already used in another part,
$suggestionByDescription = $this->getIpnSuggestByDescription($description);
if ($suggestionByDescription !== null && $suggestionByDescription !== $part->getIpn() && $part->getIpn() !== null && $part->getIpn() !== '') {
$ipnSuggestions['prefixesPartIncrement'][] = [
'title' => $part->getIpn(),
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.description.current-increment')
];
}
if ($suggestionByDescription !== null) {
$ipnSuggestions['prefixesPartIncrement'][] = [
'title' => $suggestionByDescription,
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.description.increment')
];
}
}
// Validate the category and ensure it's an instance of Category
if ($category instanceof Category) {
$currentPath = $category->getPartIpnPrefix();
$directIpnPrefixEmpty = $category->getPartIpnPrefix() === '';
$currentPath = $currentPath === '' ? 'n.a.' : $currentPath;
$increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits);
$ipnSuggestions['commonPrefixes'][] = [
'title' => $currentPath . '-',
'description' => $directIpnPrefixEmpty ? $this->translator->trans('part.edit.tab.advanced.ipn.prefix_empty.direct_category', ['%name%' => $category->getName()]) : $this->translator->trans('part.edit.tab.advanced.ipn.prefix.direct_category')
];
$ipnSuggestions['prefixesPartIncrement'][] = [
'title' => $currentPath . '-' . $increment,
'description' => $directIpnPrefixEmpty ? $this->translator->trans('part.edit.tab.advanced.ipn.prefix_empty.direct_category', ['%name%' => $category->getName()]) : $this->translator->trans('part.edit.tab.advanced.ipn.prefix.direct_category.increment')
];
// Process parent categories
$parentCategory = $category->getParent();
while ($parentCategory instanceof Category) {
// Prepend the parent category's prefix to the current path
$currentPath = $parentCategory->getPartIpnPrefix() . '-' . $currentPath;
$currentPath = $parentCategory->getPartIpnPrefix() === '' ? 'n.a.-' . $currentPath : $currentPath;
$ipnSuggestions['commonPrefixes'][] = [
'title' => $currentPath . '-',
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment')
];
$increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits);
$ipnSuggestions['prefixesPartIncrement'][] = [
'title' => $currentPath . '-' . $increment,
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.increment')
];
// Move to the next parent category
$parentCategory = $parentCategory->getParent();
}
} elseif ($part->getID() === null) {
$ipnSuggestions['commonPrefixes'][] = [
'title' => 'n.a.',
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.not_saved')
];
}
return $ipnSuggestions;
}
/**
* Suggests the next IPN (Internal Part Number) based on the provided part description.
*
* Searches for parts with similar descriptions and retrieves their existing IPNs to calculate the next suggestion.
* Returns null if the description is empty or no suggestion can be generated.
*
* @param string $description The part description to search for.
*
* @return string|null The suggested IPN, or null if no suggestion is possible.
*
* @throws NonUniqueResultException
*/
public function getIpnSuggestByDescription(string $description): ?string
{
if ($description === '') {
return null;
}
$qb = $this->createQueryBuilder('part');
$qb->select('part')
->where('part.description LIKE :descriptionPattern')
->setParameter('descriptionPattern', $description.'%')
->orderBy('part.id', 'ASC');
$partsBySameDescription = $qb->getQuery()->getResult();
$givenIpnsWithSameDescription = [];
foreach ($partsBySameDescription as $part) {
if ($part->getIpn() === null || $part->getIpn() === '') {
continue;
}
$givenIpnsWithSameDescription[] = $part->getIpn();
}
return $this->getNextIpnSuggestion($givenIpnsWithSameDescription);
}
/**
* Generates the next possible increment for a part within a given category, while ensuring uniqueness.
*
* This method calculates the next available increment for a part's identifier (`ipn`) based on the current path
* and the number of digits specified for the autocomplete feature. It ensures that the generated identifier
* aligns with the expected length and does not conflict with already existing identifiers in the same category.
*
* @param string $currentPath The base path or prefix for the part's identifier.
* @param Part $currentPart The part entity for which the increment is being generated.
* @param int $suggestPartDigits The number of digits reserved for the increment.
*
* @return string The next possible increment as a zero-padded string.
*
* @throws NonUniqueResultException If the query returns non-unique results.
* @throws NoResultException If the query fails to return a result.
*/
private function generateNextPossiblePartIncrement(string $currentPath, Part $currentPart, int $suggestPartDigits): string
{
$qb = $this->createQueryBuilder('part');
$expectedLength = strlen($currentPath) + 1 + $suggestPartDigits; // Path + '-' + $suggestPartDigits digits
// Fetch all parts in the given category, sorted by their ID in ascending order
$qb->select('part')
->where('part.ipn LIKE :ipnPattern')
->andWhere('LENGTH(part.ipn) = :expectedLength')
->setParameter('ipnPattern', $currentPath . '%')
->setParameter('expectedLength', $expectedLength)
->orderBy('part.id', 'ASC');
$parts = $qb->getQuery()->getResult();
// Collect all used increments in the category
$usedIncrements = [];
foreach ($parts as $part) {
if ($part->getIpn() === null || $part->getIpn() === '') {
continue;
}
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)) {
return str_pad((string) $incrementPart, $suggestPartDigits, '0', STR_PAD_LEFT);
}
}
// Extract last $autocompletePartDigits digits for possible available part increment
$incrementPart = substr($part->getIpn(), -$suggestPartDigits);
if (is_numeric($incrementPart)) {
$usedIncrements[] = (int) $incrementPart;
}
}
// Generate the next free $autocompletePartDigits-digit increment
$nextIncrement = 1; // Start at the beginning
while (in_array($nextIncrement, $usedIncrements, true)) {
$nextIncrement++;
}
return str_pad((string) $nextIncrement, $suggestPartDigits, '0', STR_PAD_LEFT);
}
/**
* Generates the next IPN suggestion based on the maximum numeric suffix found in the given IPNs.
*
* The new IPN is constructed using the base format of the first provided IPN,
* incremented by the next free numeric suffix. If no base IPNs are found,
* returns null.
*
* @param array $givenIpns List of IPNs to analyze.
*
* @return string|null The next suggested IPN, or null if no base IPNs can be derived.
*/
private function getNextIpnSuggestion(array $givenIpns): ?string {
$maxSuffix = 0;
foreach ($givenIpns as $ipn) {
// Check whether the IPN contains a suffix "_ <number>"
if (preg_match('/_(\d+)$/', $ipn, $matches)) {
$suffix = (int)$matches[1];
if ($suffix > $maxSuffix) {
$maxSuffix = $suffix; // Höchste Nummer speichern
}
}
}
// Find the basic format (the IPN without suffix) from the first IPN
$baseIpn = $givenIpns[0] ?? '';
$baseIpn = preg_replace('/_\d+$/', '', $baseIpn); // Remove existing "_ <number>"
if ($baseIpn === '') {
return null;
}
// Generate next free possible IPN
return $baseIpn . '_' . ($maxSuffix + 1);
}
}

View file

@ -0,0 +1,80 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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\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;
use Symfony\Component\Translation\TranslatableMessage as TM;
use Symfony\Component\Validator\Constraints as Assert;
#[Settings(label: new TM("settings.misc.ipn_suggest"))]
#[SettingsIcon("fa-list")]
class IpnSuggestSettings
{
use SettingsTrait;
#[SettingsParameter(
label: new TM("settings.misc.ipn_suggest.regex"),
description: new TM("settings.misc.ipn_suggest.regex.help"),
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: 34 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,
)]
public bool $autoAppendSuffix = false;
#[SettingsParameter(label: new TM("settings.misc.ipn_suggest.suggestPartDigits"),
description: new TM("settings.misc.ipn_suggest.suggestPartDigits.help"),
formOptions: ['attr' => ['min' => 1, 'max' => 8]],
envVar: "int:IPN_SUGGEST_PART_DIGITS", envVarMode: EnvVarMode::OVERWRITE
)]
#[Assert\Range(min: 1, max: 8)]
public int $suggestPartDigits = 4;
#[SettingsParameter(
label: new TM("settings.misc.ipn_suggest.useDuplicateDescription"),
description: new TM("settings.misc.ipn_suggest.useDuplicateDescription.help"),
envVar: "bool:IPN_USE_DUPLICATE_DESCRIPTION", envVarMode: EnvVarMode::OVERWRITE,
)]
public bool $useDuplicateDescription = false;
}

View file

@ -35,4 +35,7 @@ class MiscSettings
#[EmbeddedSettings]
public ?ExchangeRateSettings $exchangeRate = null;
#[EmbeddedSettings]
public ?IpnSuggestSettings $ipnSuggestSettings = null;
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Validator\Constraints;
use Attribute;
use Symfony\Component\Validator\Constraint;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
class UniquePartIpnConstraint extends Constraint
{
public string $message = 'part.ipn.must_be_unique';
public function getTargets(): string|array
{
return [self::CLASS_CONSTRAINT, self::PROPERTY_CONSTRAINT];
}
public function validatedBy(): string
{
return UniquePartIpnValidator::class;
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Validator\Constraints;
use App\Entity\Parts\Part;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Doctrine\ORM\EntityManagerInterface;
class UniquePartIpnValidator extends ConstraintValidator
{
private EntityManagerInterface $entityManager;
private IpnSuggestSettings $ipnSuggestSettings;
public function __construct(EntityManagerInterface $entityManager, IpnSuggestSettings $ipnSuggestSettings)
{
$this->entityManager = $entityManager;
$this->ipnSuggestSettings = $ipnSuggestSettings;
}
public function validate($value, Constraint $constraint): void
{
if (null === $value || '' === $value) {
return;
}
//If the autoAppendSuffix option is enabled, the IPN becomes unique automatically later
if ($this->ipnSuggestSettings->autoAppendSuffix) {
return;
}
if (!$constraint instanceof UniquePartIpnConstraint) {
return;
}
/** @var Part $currentPart */
$currentPart = $this->context->getObject();
if (!$currentPart instanceof Part) {
return;
}
$repository = $this->entityManager->getRepository(Part::class);
$existingParts = $repository->findBy(['ipn' => $value]);
foreach ($existingParts as $existingPart) {
if ($currentPart->getId() !== $existingPart->getId()) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
}
}
}