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

This commit is contained in:
Marcel Diegelmann 2025-04-01 16:10:10 +02:00
parent cbfe1d4cc8
commit 7162199e61
32 changed files with 1482 additions and 6 deletions

View file

@ -76,6 +76,7 @@ final class PartController extends AbstractController
private readonly EntityManagerInterface $em,
private readonly EventCommentHelper $commentHelper,
private readonly PartInfoSettings $partInfoSettings,
private readonly int $autocompletePartDigits,
) {
}
@ -457,10 +458,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, $this->autocompletePartDigits),
'form' => $form,
'merge_old_name' => $merge_infos['tname_before'] ?? null,
'merge_other' => $merge_infos['other_part'] ?? null,
@ -470,7 +474,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

@ -69,7 +69,8 @@ class TypeaheadController extends AbstractController
public function __construct(
protected AttachmentURLGenerator $urlGenerator,
protected Packages $assets,
protected TranslatorInterface $translator
protected TranslatorInterface $translator,
protected int $autocompletePartDigits,
) {
}
@ -271,4 +272,28 @@ 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');
/** @var Part $part */
$part = $partId !== null ? $entityManager->getRepository(Part::class)->find($partId) : new Part();
$category = $entityManager->getRepository(Category::class)->find($categoryId);
$clonedPart = clone $part;
$clonedPart->setCategory($category);
$partRepository = $entityManager->getRepository(Part::class);
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $this->autocompletePartDigits);
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)]
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

@ -62,7 +62,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;
@ -76,7 +75,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

@ -29,6 +29,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.
@ -62,8 +63,9 @@ trait AdvancedPropertyTrait
*/
#[Assert\Length(max: 100)]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::STRING, length: 100, unique: true, nullable: true)]
#[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
#[Length(max: 100)]
#[UniquePartIpnConstraint]
protected ?string $ipn = null;
/**

View file

@ -0,0 +1,73 @@
<?php
namespace App\EventSubscriber\UserSystem;
use App\Entity\Parts\Part;
use Doctrine\Common\EventSubscriber;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\EntityManagerInterface;
class PartUniqueIpnSubscriber implements EventSubscriber
{
public function __construct(
private EntityManagerInterface $entityManager,
private readonly bool $enforceUniqueIpn = false
) {
}
public function getSubscribedEvents(): array
{
return [
Events::prePersist,
Events::preUpdate,
];
}
public function prePersist(LifecycleEventArgs $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() === '') {
return;
}
$existingPart = $this->entityManager
->getRepository(Part::class)
->findOneBy(['ipn' => $part->getIpn()]);
if ($existingPart && $existingPart->getId() !== $part->getId()) {
if ($this->enforceUniqueIpn) {
return;
}
// Anhang eines Inkrements bis ein einzigartiger Wert gefunden wird
$increment = 1;
$originalIpn = $part->getIpn();
while ($this->entityManager
->getRepository(Part::class)
->findOneBy(['ipn' => $originalIpn . "_$increment"])) {
$increment++;
}
$part->setIpn($originalIpn . "_$increment");
}
}
}

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

@ -104,6 +104,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,
@ -175,6 +178,11 @@ class PartBaseType extends AbstractType
'required' => false,
'empty_data' => null,
'label' => 'part.edit.ipn',
'attr' => [
'class' => 'ipn-suggestion-field',
'data-elements--ipn-suggestion-target' => 'input',
'autocomplete' => 'off',
]
]);
//Comment section

View file

@ -22,17 +22,31 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\Parts\Category;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
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;
public function __construct(
EntityManagerInterface $em,
TranslatorInterface $translator
) {
parent::__construct($em, $em->getClassMetadata(Part::class));
$this->translator = $translator;
}
/**
* Gets the summed up instock of all parts (only parts without a measurement unit).
*
@ -94,4 +108,109 @@ class PartRepository extends NamedDBElementRepository
return $qb->getQuery()->getResult();
}
public function autoCompleteIpn(Part $part, int $autocompletePartDigits): array
{
$category = $part->getCategory();
$ipnSuggestions = ['commonPrefixes' => [], 'prefixesPartIncrement' => []];
// 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')
];
$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, $autocompletePartDigits);
$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;
}
public function generateNextPossiblePartIncrement(string $currentPath, Part $currentPart, int $autocompletePartDigits): string
{
$qb = $this->createQueryBuilder('part');
$expectedLength = strlen($currentPath) + 1 + $autocompletePartDigits; // Path + '-' + $autocompletePartDigits 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()) {
// Extract and return the current part's increment directly
$incrementPart = substr($part->getIpn(), -$autocompletePartDigits);
if (is_numeric($incrementPart)) {
return str_pad((string) $incrementPart, $autocompletePartDigits, '0', STR_PAD_LEFT);
}
}
// Extract last $autocompletePartDigits digits for possible available part increment
$incrementPart = substr($part->getIpn(), -$autocompletePartDigits);
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)) {
$nextIncrement++;
}
return str_pad((string) $nextIncrement, $autocompletePartDigits, '0', STR_PAD_LEFT);
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
* @Target({"PROPERTY"})
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UniquePartIpnConstraint extends Constraint
{
public string $message = 'part.ipn.must_be_unique';
public function validatedBy(): string
{
return UniquePartIpnValidator::class;
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Validator\Constraints;
use App\Entity\Parts\Part;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Doctrine\ORM\EntityManagerInterface;
class UniquePartIpnValidator extends ConstraintValidator
{
private EntityManagerInterface $entityManager;
private bool $enforceUniqueIpn;
public function __construct(EntityManagerInterface $entityManager, bool $enforceUniqueIpn)
{
$this->entityManager = $entityManager;
$this->enforceUniqueIpn = $enforceUniqueIpn;
}
public function validate($value, Constraint $constraint)
{
if (null === $value || '' === $value) {
return;
}
$repository = $this->entityManager->getRepository(Part::class);
$existingPart = $repository->findOneBy(['ipn' => $value]);
if ($existingPart) {
if ($this->enforceUniqueIpn) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
}
}
}