diff --git a/assets/controllers/elements/ipn_suggestion_controller.js b/assets/controllers/elements/ipn_suggestion_controller.js index e7289a91..c8b543cb 100644 --- a/assets/controllers/elements/ipn_suggestion_controller.js +++ b/assets/controllers/elements/ipn_suggestion_controller.js @@ -184,7 +184,7 @@ export default class extends Controller { if (categoryField) { categoryField.addEventListener("change", () => { const categoryId = Number(categoryField.value); - const description = String(descriptionField.value); + const description = String(descriptionField?.value ?? ''); // Check whether the category has changed compared to the previous ID if (categoryId !== this.previousCategoryId) { @@ -203,7 +203,7 @@ export default class extends Controller { if (descriptionField) { descriptionField.addEventListener("input", () => { const categoryId = Number(categoryField.value); - const description = String(descriptionField.value); + const description = String(descriptionField?.value ?? ''); // Check whether the description has changed compared to the previous one if (description !== this.previousDescription) { @@ -219,7 +219,7 @@ export default class extends Controller { const partId = this.partIdValue; const truncatedDescription = description.length > 150 ? description.substring(0, 150) : description; const encodedDescription = this.base64EncodeUtf8(truncatedDescription); - const url = `${baseUrl}?partId=${partId}&categoryId=${categoryId}&description=${encodedDescription}`; + const url = `${baseUrl}?partId=${partId}&categoryId=${categoryId}` + (description !== '' ? `&description=${encodedDescription}` : ''); fetch(url, { method: "GET", @@ -247,4 +247,4 @@ export default class extends Controller { const utf8Bytes = new TextEncoder().encode(text); return btoa(String.fromCharCode(...utf8Bytes)); }; -} \ No newline at end of file +} diff --git a/config/services.yaml b/config/services.yaml index 5491ce31..c701ff9e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -233,28 +233,16 @@ services: tags: - { name: 'doctrine.fixtures.purger_factory', alias: 'reset_autoincrement_purger' } - App\Controller\PartController: - bind: - $autocompletePartDigits: '%partdb.autocomplete_part_digits%' - - App\Controller\TypeaheadController: - bind: - $autocompletePartDigits: '%partdb.autocomplete_part_digits%' - App\Repository\PartRepository: arguments: $translator: '@translator' tags: ['doctrine.repository_service'] App\EventSubscriber\UserSystem\PartUniqueIpnSubscriber: - arguments: - $enforceUniqueIpn: '%partdb.users.enforce_unique_ipn%' tags: - - { name: doctrine.event_subscriber } + - { name: doctrine.event_listener, event: onFlush, connection: default } App\Validator\Constraints\UniquePartIpnValidator: - arguments: - $enforceUniqueIpn: '%partdb.users.enforce_unique_ipn%' tags: [ 'validator.constraint_validator' ] # We are needing this service inside a migration, where only the container is injected. So we need to define it as public, to access it from the container. diff --git a/docs/configuration.md b/docs/configuration.md index d04f1a3a..cccf5a54 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -116,10 +116,14 @@ 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 -* `AUTOCOMPLETE_PART_DIGITS`: Defines the fixed number of digits used as the increment at the end of an IPN (Internal Part Number). +* `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). IPN prefixes, maintained within part categories and their hierarchy, form the foundation for suggesting complete IPNs. These suggestions become accessible during IPN input of a part. The constant specifies the digits used to calculate and assign unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation. +* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the part’s description is used to find existing parts with the same + description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list. ### E-Mail settings (all env only) diff --git a/migrations/Version20250325073036.php b/migrations/Version20250325073036.php index 0070bcbe..3bae80ab 100644 --- a/migrations/Version20250325073036.php +++ b/migrations/Version20250325073036.php @@ -19,9 +19,6 @@ final class Version20250325073036 extends AbstractMultiPlatformMigration $this->addSql(<<<'SQL' ALTER TABLE categories ADD COLUMN part_ipn_prefix VARCHAR(255) NOT NULL DEFAULT '' SQL); - $this->addSql(<<<'SQL' - DROP INDEX UNIQ_6940A7FE3D721C14 ON parts - SQL); } public function mySQLDown(Schema $schema): void @@ -29,16 +26,13 @@ final class Version20250325073036 extends AbstractMultiPlatformMigration $this->addSql(<<<'SQL' ALTER TABLE categories DROP part_ipn_prefix SQL); - $this->addSql(<<<'SQL' - CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn) - SQL); } public function sqLiteUp(Schema $schema): void { $this->addSql(<<<'SQL' - CREATE TEMPORARY TABLE __temp__categories AS - SELECT + CREATE TEMPORARY TABLE __temp__categories AS + SELECT id, parent_id, id_preview_attachment, @@ -123,7 +117,7 @@ final class Version20250325073036 extends AbstractMultiPlatformMigration eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol - ) SELECT + ) SELECT id, parent_id, id_preview_attachment, @@ -164,17 +158,13 @@ final class Version20250325073036 extends AbstractMultiPlatformMigration $this->addSql(<<<'SQL' CREATE INDEX category_idx_parent_name ON categories (parent_id, name) SQL); - - $this->addSql(<<<'SQL' - DROP INDEX UNIQ_6940A7FE3D721C14 - SQL); } public function sqLiteDown(Schema $schema): void { $this->addSql(<<<'SQL' - CREATE TEMPORARY TABLE __temp__categories AS - SELECT + CREATE TEMPORARY TABLE __temp__categories AS + SELECT id, parent_id, id_preview_attachment, @@ -258,7 +248,7 @@ final class Version20250325073036 extends AbstractMultiPlatformMigration eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol - ) SELECT + ) SELECT id, parent_id, id_preview_attachment, @@ -299,10 +289,6 @@ final class Version20250325073036 extends AbstractMultiPlatformMigration $this->addSql(<<<'SQL' CREATE INDEX category_idx_parent_name ON categories (parent_id, name) SQL); - - $this->addSql(<<<'SQL' - CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn) - SQL); } public function postgreSQLUp(Schema $schema): void @@ -310,9 +296,6 @@ final class Version20250325073036 extends AbstractMultiPlatformMigration $this->addSql(<<<'SQL' ALTER TABLE categories ADD part_ipn_prefix VARCHAR(255) DEFAULT '' NOT NULL SQL); - $this->addSql(<<<'SQL' - DROP INDEX uniq_6940a7fe3d721c14 - SQL); } public function postgreSQLDown(Schema $schema): void @@ -320,8 +303,5 @@ final class Version20250325073036 extends AbstractMultiPlatformMigration $this->addSql(<<<'SQL' ALTER TABLE "categories" DROP part_ipn_prefix SQL); - $this->addSql(<<<'SQL' - CREATE UNIQUE INDEX uniq_6940a7fe3d721c14 ON "parts" (ipn) - SQL); } } diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index 29abfad8..1175ddb3 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -49,6 +49,7 @@ use App\Services\Parts\PricedetailHelper; use App\Services\AssemblySystem\AssemblyBuildPartHelper; use App\Services\ProjectSystem\ProjectBuildPartHelper; use App\Settings\BehaviorSettings\PartInfoSettings; +use App\Settings\MiscSettings\IpnSuggestSettings; use DateTime; use Doctrine\ORM\EntityManagerInterface; use Exception; @@ -76,7 +77,7 @@ final class PartController extends AbstractController private readonly EntityManagerInterface $em, private readonly EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings, - private readonly int $autocompletePartDigits, + private readonly IpnSuggestSettings $ipnSuggestSettings, ) { } @@ -464,7 +465,7 @@ final class PartController extends AbstractController $template, [ 'part' => $new_part, - 'ipnSuggestions' => $partRepository->autoCompleteIpn($data, base64_encode($data->getDescription()), $this->autocompletePartDigits), + '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, diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index fd08128c..7a7c2494 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -24,6 +24,7 @@ namespace App\Controller; use App\Entity\AssemblySystem\Assembly; use App\Entity\Parameters\AbstractParameter; +use App\Settings\MiscSettings\IpnSuggestSettings; use App\Entity\ProjectSystem\Project; use App\Services\Attachments\AssemblyPreviewGenerator; use App\Services\Attachments\ProjectPreviewGenerator; @@ -57,8 +58,6 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; -use Symfony\Contracts\Translation\TranslatorInterface; -use InvalidArgumentException; /** * In this controller the endpoints for the typeaheads are collected. @@ -69,8 +68,7 @@ class TypeaheadController extends AbstractController public function __construct( protected AttachmentURLGenerator $urlGenerator, protected Packages $assets, - protected TranslatorInterface $translator, - protected int $autocompletePartDigits, + protected IpnSuggestSettings $ipnSuggestSettings, ) { } @@ -119,22 +117,19 @@ class TypeaheadController extends AbstractController 'group' => GroupParameter::class, 'measurement_unit' => MeasurementUnitParameter::class, 'currency' => Currency::class, - default => throw new InvalidArgumentException('Invalid parameter type: '.$type), + default => throw new \InvalidArgumentException('Invalid parameter type: '.$type), }; } #[Route(path: '/parts/search/{query}', name: 'typeahead_parts')] - public function parts( - EntityManagerInterface $entityManager, - PartPreviewGenerator $previewGenerator, - AttachmentURLGenerator $attachmentURLGenerator, - string $query = "" - ): JsonResponse { + public function parts(EntityManagerInterface $entityManager, PartPreviewGenerator $previewGenerator, + AttachmentURLGenerator $attachmentURLGenerator, string $query = ""): JsonResponse + { $this->denyAccessUnlessGranted('@parts.read'); - $partRepository = $entityManager->getRepository(Part::class); + $repo = $entityManager->getRepository(Part::class); - $parts = $partRepository->autocompleteSearch($query, 100); + $parts = $repo->autocompleteSearch($query, 100); $data = []; foreach ($parts as $part) { @@ -154,88 +149,12 @@ class TypeaheadController extends AbstractController 'footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '', 'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'), 'image' => $preview_url, - ]; + ]; } return new JsonResponse($data); } - #[Route(path: '/projects/search/{query}', name: 'typeahead_projects')] - public function projects( - EntityManagerInterface $entityManager, - ProjectPreviewGenerator $projectPreviewGenerator, - AttachmentURLGenerator $attachmentURLGenerator, - string $query = "" - ): JsonResponse { - $this->denyAccessUnlessGranted('@projects.read'); - - $result = []; - - $projectRepository = $entityManager->getRepository(Project::class); - - $projects = $projectRepository->autocompleteSearch($query, 100); - - foreach ($projects as $project) { - $preview_attachment = $projectPreviewGenerator->getTablePreviewAttachment($project); - - if($preview_attachment instanceof Attachment) { - $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm'); - } else { - $preview_url = ''; - } - - /** @var Project $project */ - $result[] = [ - 'id' => $project->getID(), - 'name' => $project->getName(), - 'category' => '', - 'footprint' => '', - 'description' => mb_strimwidth($project->getDescription(), 0, 127, '...'), - 'image' => $preview_url, - ]; - } - - return new JsonResponse($result); - } - - #[Route(path: '/assemblies/search/{query}', name: 'typeahead_assemblies')] - public function assemblies( - EntityManagerInterface $entityManager, - AssemblyPreviewGenerator $assemblyPreviewGenerator, - AttachmentURLGenerator $attachmentURLGenerator, - string $query = "" - ): JsonResponse { - $this->denyAccessUnlessGranted('@assemblies.read'); - - $result = []; - - $assemblyRepository = $entityManager->getRepository(Assembly::class); - - $assemblies = $assemblyRepository->autocompleteSearch($query, 100); - - foreach ($assemblies as $assembly) { - $preview_attachment = $assemblyPreviewGenerator->getTablePreviewAttachment($assembly); - - if($preview_attachment instanceof Attachment) { - $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm'); - } else { - $preview_url = ''; - } - - /** @var Assembly $assembly */ - $result[] = [ - 'id' => $assembly->getID(), - 'name' => $assembly->getName(), - 'category' => '', - 'footprint' => '', - 'description' => mb_strimwidth($assembly->getDescription(), 0, 127, '...'), - 'image' => $preview_url, - ]; - } - - return new JsonResponse($result); - } - #[Route(path: '/parameters/{type}/search/{query}', name: 'typeahead_parameters', requirements: ['type' => '.+'])] public function parameters(string $type, EntityManagerInterface $entityManager, string $query = ""): JsonResponse { @@ -283,17 +202,18 @@ class TypeaheadController extends AbstractController $partId = null; } $categoryId = $request->query->getInt('categoryId'); - $description = $request->query->getString('description'); + $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->autocompletePartDigits); + $ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits); return new JsonResponse($ipnSuggestions); } diff --git a/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php b/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php index c5d22dbe..ecc25b4f 100644 --- a/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php +++ b/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php @@ -3,71 +3,95 @@ namespace App\EventSubscriber\UserSystem; use App\Entity\Parts\Part; +use App\Settings\MiscSettings\IpnSuggestSettings; use Doctrine\Common\EventSubscriber; -use Doctrine\Persistence\Event\LifecycleEventArgs; use Doctrine\ORM\Events; -use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Event\OnFlushEventArgs; class PartUniqueIpnSubscriber implements EventSubscriber { public function __construct( - private EntityManagerInterface $entityManager, - private readonly bool $enforceUniqueIpn = false + private IpnSuggestSettings $ipnSuggestSettings ) { } public function getSubscribedEvents(): array { return [ - Events::prePersist, - Events::preUpdate, + Events::onFlush, ]; } - public function prePersist(LifecycleEventArgs $args): void + public function onFlush(OnFlushEventArgs $args): void { - $entity = $args->getObject(); - - if ($entity instanceof Part) { - $this->ensureUniqueIpn($entity); - } - } - - public function preUpdate(LifecycleEventArgs $args): void - { - $entity = $args->getObject(); - - if ($entity instanceof Part) { - $this->ensureUniqueIpn($entity); - } - } - - private function ensureUniqueIpn(Part $part): void - { - if ($part->getIpn() === null || $part->getIpn() === '') { + if (!$this->ipnSuggestSettings->autoAppendSuffix) { return; } - $existingPart = $this->entityManager - ->getRepository(Part::class) - ->findOneBy(['ipn' => $part->getIpn()]); + $em = $args->getObjectManager(); + $uow = $em->getUnitOfWork(); + $meta = $em->getClassMetadata(Part::class); - if ($existingPart && $existingPart->getId() !== $part->getId()) { - if ($this->enforceUniqueIpn) { + // 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; } - // Anhang eines Inkrements bis ein einzigartiger Wert gefunden wird + // Check against IPNs already reserved in the current flush (except itself) + $originalIpn = $ipn; + $candidate = $originalIpn; $increment = 1; - $originalIpn = $part->getIpn(); - while ($this->entityManager - ->getRepository(Part::class) - ->findOneBy(['ipn' => $originalIpn . "_$increment"])) { + $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++; } - $part->setIpn($originalIpn . "_$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); + } } } } diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 1c904eaa..22ff2901 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -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,16 @@ 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'; + } + //Common section $builder ->add('name', TextType::class, [ @@ -84,11 +99,7 @@ class PartBaseType extends AbstractType 'empty_data' => '', 'label' => 'part.edit.description', 'mode' => 'markdown-single_line', - 'attr' => [ - 'placeholder' => 'part.edit.description.placeholder', - 'rows' => 2, - 'data-ipn-suggestion' => 'descriptionField', - ], + 'attr' => $descriptionAttr, ]) ->add('minAmount', SIUnitType::class, [ 'attr' => [ diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index 69361553..6974d254 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -25,6 +25,7 @@ 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; @@ -37,14 +38,17 @@ use Doctrine\ORM\EntityManagerInterface; class PartRepository extends NamedDBElementRepository { private TranslatorInterface $translator; + private IpnSuggestSettings $ipnSuggestSettings; public function __construct( EntityManagerInterface $em, - TranslatorInterface $translator + TranslatorInterface $translator, + IpnSuggestSettings $ipnSuggestSettings, ) { parent::__construct($em, $em->getClassMetadata(Part::class)); $this->translator = $translator; + $this->ipnSuggestSettings = $ipnSuggestSettings; } /** @@ -98,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.'%'); @@ -117,35 +120,24 @@ class PartRepository extends NamedDBElementRepository * 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 Base64-encoded description to assist in generating suggestions. - * @param int $autocompletePartDigits The number of digits used in autocomplete increments. + * @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 $autocompletePartDigits): array + public function autoCompleteIpn(Part $part, string $description, int $suggestPartDigits): array { $category = $part->getCategory(); $ipnSuggestions = ['commonPrefixes' => [], 'prefixesPartIncrement' => []]; - $description = base64_decode($description); if (strlen($description) > 150) { $description = substr($description, 0, 150); } - // 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, $autocompletePartDigits); - - $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') - ]; + if ($description !== '' && $this->ipnSuggestSettings->useDuplicateDescription) { + // Check if the description is already used in another part, $suggestionByDescription = $this->getIpnSuggestByDescription($description); @@ -162,6 +154,20 @@ class PartRepository extends NamedDBElementRepository '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, @@ -181,7 +187,7 @@ class PartRepository extends NamedDBElementRepository 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment') ]; - $increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $autocompletePartDigits); + $increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits); $ipnSuggestions['prefixesPartIncrement'][] = [ 'title' => $currentPath . '-' . $increment, @@ -249,18 +255,18 @@ class PartRepository extends NamedDBElementRepository * * @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 $autocompletePartDigits The number of digits reserved for the increment. + * @param int $suggestPartDigits The number of digits reserved for the increment. * - * @return string|null The next possible increment as a zero-padded string, or null if it cannot be generated. + * @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 $autocompletePartDigits): ?string + private function generateNextPossiblePartIncrement(string $currentPath, Part $currentPart, int $suggestPartDigits): string { $qb = $this->createQueryBuilder('part'); - $expectedLength = strlen($currentPath) + 1 + $autocompletePartDigits; // Path + '-' + $autocompletePartDigits digits + $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') @@ -281,14 +287,14 @@ class PartRepository extends NamedDBElementRepository if ($part->getId() === $currentPart->getId()) { // Extract and return the current part's increment directly - $incrementPart = substr($part->getIpn(), -$autocompletePartDigits); + $incrementPart = substr($part->getIpn(), -$suggestPartDigits); if (is_numeric($incrementPart)) { - return str_pad((string) $incrementPart, $autocompletePartDigits, '0', STR_PAD_LEFT); + return str_pad((string) $incrementPart, $suggestPartDigits, '0', STR_PAD_LEFT); } } // Extract last $autocompletePartDigits digits for possible available part increment - $incrementPart = substr($part->getIpn(), -$autocompletePartDigits); + $incrementPart = substr($part->getIpn(), -$suggestPartDigits); if (is_numeric($incrementPart)) { $usedIncrements[] = (int) $incrementPart; } @@ -298,11 +304,11 @@ class PartRepository extends NamedDBElementRepository // Generate the next free $autocompletePartDigits-digit increment $nextIncrement = 1; // Start at the beginning - while (in_array($nextIncrement, $usedIncrements)) { + while (in_array($nextIncrement, $usedIncrements, true)) { $nextIncrement++; } - return str_pad((string) $nextIncrement, $autocompletePartDigits, '0', STR_PAD_LEFT); + return str_pad((string) $nextIncrement, $suggestPartDigits, '0', STR_PAD_LEFT); } /** @@ -331,7 +337,7 @@ class PartRepository extends NamedDBElementRepository // Find the basic format (the IPN without suffix) from the first IPN $baseIpn = $givenIpns[0] ?? ''; - $baseIpn = preg_replace('/_\d+$/', '', $baseIpn); // Entferne vorhandene "_" + $baseIpn = preg_replace('/_\d+$/', '', $baseIpn); // Remove existing "_ " if ($baseIpn === '') { return null; diff --git a/src/Settings/MiscSettings/IpnSuggestSettings.php b/src/Settings/MiscSettings/IpnSuggestSettings.php new file mode 100644 index 00000000..96efcc33 --- /dev/null +++ b/src/Settings/MiscSettings/IpnSuggestSettings.php @@ -0,0 +1,60 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\MiscSettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +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.autoAppendSuffix"), + envVar: "bool:IPN_AUTO_APPEND_SUFFIX", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $autoAppendSuffix = true; + + #[SettingsParameter(label: new TM("settings.misc.ipn_suggest.suggestPartDigits"), + description: new TM("settings.misc.ipn_suggest.suggestPartDigits.help"), + formOptions: ['attr' => ['min' => 1, 'max' => 100]], + envVar: "int:IPN_SUGGEST_PART_DIGITS", envVarMode: EnvVarMode::OVERWRITE + )] + #[Assert\Range(min: 1, max: 6)] + public int $suggestPartDigits = 4; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.useDuplicateDescription"), + envVar: "bool:IPN_USE_DUPLICATE_DESCRIPTION", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $useDuplicateDescription = false; +} diff --git a/src/Settings/MiscSettings/MiscSettings.php b/src/Settings/MiscSettings/MiscSettings.php index 68d0cf6d..552aa955 100644 --- a/src/Settings/MiscSettings/MiscSettings.php +++ b/src/Settings/MiscSettings/MiscSettings.php @@ -37,4 +37,7 @@ class MiscSettings #[EmbeddedSettings] public ?AssemblySettings $assembly = null; + + #[EmbeddedSettings] + public ?IpnSuggestSettings $ipnSuggestSettings = null; } diff --git a/src/Validator/Constraints/UniquePartIpnConstraint.php b/src/Validator/Constraints/UniquePartIpnConstraint.php index 13fd0330..ca32f9ef 100644 --- a/src/Validator/Constraints/UniquePartIpnConstraint.php +++ b/src/Validator/Constraints/UniquePartIpnConstraint.php @@ -2,19 +2,21 @@ namespace App\Validator\Constraints; +use Attribute; use Symfony\Component\Validator\Constraint; -/** - * @Annotation - * @Target({"PROPERTY"}) - */ -#[\Attribute(\Attribute::TARGET_PROPERTY)] +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)] class UniquePartIpnConstraint extends Constraint { - public string $message = 'part.ipn.must_be_unique'; + 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; } -} \ No newline at end of file +} diff --git a/src/Validator/Constraints/UniquePartIpnValidator.php b/src/Validator/Constraints/UniquePartIpnValidator.php index 019202f8..5dbcafbe 100644 --- a/src/Validator/Constraints/UniquePartIpnValidator.php +++ b/src/Validator/Constraints/UniquePartIpnValidator.php @@ -3,6 +3,7 @@ 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; @@ -10,12 +11,12 @@ use Doctrine\ORM\EntityManagerInterface; class UniquePartIpnValidator extends ConstraintValidator { private EntityManagerInterface $entityManager; - private bool $enforceUniqueIpn; + private IpnSuggestSettings $ipnSuggestSettings; - public function __construct(EntityManagerInterface $entityManager, bool $enforceUniqueIpn) + public function __construct(EntityManagerInterface $entityManager, IpnSuggestSettings $ipnSuggestSettings) { $this->entityManager = $entityManager; - $this->enforceUniqueIpn = $enforceUniqueIpn; + $this->ipnSuggestSettings = $ipnSuggestSettings; } public function validate($value, Constraint $constraint) @@ -24,7 +25,12 @@ class UniquePartIpnValidator extends ConstraintValidator return; } - if (!$this->enforceUniqueIpn) { + if ($this->ipnSuggestSettings->autoAppendSuffix) { + return; + } + + // Stelle sicher, dass es unser eigenes Constraint ist (wichtig für PHPStan) + if (!$constraint instanceof UniquePartIpnConstraint) { return; } @@ -40,12 +46,10 @@ class UniquePartIpnValidator extends ConstraintValidator foreach ($existingParts as $existingPart) { if ($currentPart->getId() !== $existingPart->getId()) { - if ($this->enforceUniqueIpn) { - $this->context->buildViolation($constraint->message) - ->setParameter('{{ value }}', $value) - ->addViolation(); - } + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation(); } } } -} \ No newline at end of file +} diff --git a/translations/messages.cs.xlf b/translations/messages.cs.xlf index cf6d7588..f505ad24 100644 --- a/translations/messages.cs.xlf +++ b/translations/messages.cs.xlf @@ -4825,7 +4825,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn Název - + Part-DB1\src\DataTables\PartsDataTable.php:178 Part-DB1\src\DataTables\PartsDataTable.php:126 @@ -13528,6 +13528,36 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz Použít zástupný symbol %%ipn%% v názvu sestavy. Zástupný symbol bude při ukládání nahrazen vstupem IPN. + + + settings.misc.ipn_suggest + Seznam návrhů IPN součástek + + + + + settings.misc.ipn_suggest.autoAppendSuffix + Pokud je tato možnost povolena, bude při opětovném zadání existujícího IPN při ukládání k vstupu přidána přírůstková přípona. + + + + + settings.misc.ipn_suggest.suggestPartDigits + Počet čísel pro inkrement + + + + + settings.misc.ipn_suggest.useDuplicateDescription + Je-li povoleno, použije se popis součástky k nalezení existujících součástek se stejným popisem a k určení další volné IPN navýšením její číselné přípony pro seznam návrhů. + + + + + settings.misc.ipn_suggest.suggestPartDigits.help + Počet číslic použitých pro inkrementální číslování součástí v návrhovém systému IPN (Interní číslo součástky). + + settings.behavior.part_info diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 504433bd..02516563 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -14411,6 +14411,36 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön Verwenden Sie einen %%ipn%%-Platzhalter im Namen einer Baugruppe. Der Platzhalter wird beim Speichern durch die eingegebene IPN ersetzt. + + + settings.misc.ipn_suggest + Bauteil IPN-Vorschlagsliste + + + + + settings.misc.ipn_suggest.autoAppendSuffix + Wenn diese Option aktiviert ist, wird der Eingabe ein inkrementelles Suffix hinzugefügt, wenn eine vorhandene IPN beim Speichern erneut eingegeben wird. + + + + + settings.misc.ipn_suggest.suggestPartDigits + Stellen für numerisches Inkrement + + + + + settings.misc.ipn_suggest.useDuplicateDescription + Wenn aktiviert, wird die Bauteil-Beschreibung verwendet, um vorhandene Teile mit derselben Beschreibung zu finden und die nächste verfügbare IPN für die Vorschlagsliste zu ermitteln, indem der numerische Suffix entsprechend erhöht wird. + + + + + settings.misc.ipn_suggest.suggestPartDigits.help + Die Anzahl der Ziffern, die für die inkrementale Nummerierung von Teilen im IPN-Vorschlagssystem verwendet werden. + + settings.behavior.part_info diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index e972c0ba..3554fbfe 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -5803,12 +5803,6 @@ If you have done this incorrectly or if a computer is no longer trusted, you can Measuring unit - - - part.edit.partCustomState - Custom part state - - Part-DB1\src\Form\Part\PartBaseType.php:212 @@ -14412,6 +14406,36 @@ Please note, that you can not impersonate a disabled user. If you try you will g Use an %%ipn%% placeholder in the name of an assembly. Placeholder is replaced with the ipn input while saving. + + + settings.misc.ipn_suggest + Part IPN Suggest + + + + + settings.misc.ipn_suggest.autoAppendSuffix + Do you want an incremental number to be added to the user input when entering an existing IPN again upon saving? + + + + + settings.misc.ipn_suggest.suggestPartDigits + Increment Digits + + + + + settings.misc.ipn_suggest.useDuplicateDescription + When enabled, the part’s description is used to find existing parts with the same description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list. + + + + + settings.misc.ipn_suggest.suggestPartDigits.help + The number of digits used for the incremental numbering of parts in the IPN (Internal Part Number) suggestion system. + + settings.behavior.part_info