BOMValidator: Validierung für rekursive Baugruppen-Eintragsprüfung ergänzt

Es wurde eine neue Validierung hinzugefügt, um sicherzustellen, dass keine Baugruppe in ihrer eigenen Hierarchie als Unterbaugruppe referenziert wird. Diese Logik wurde in die entsprechenden Dateien integriert und unterstützt Mehrsprachigkeit durch neue Übersetzungen.
This commit is contained in:
Marcel Diegelmann 2025-07-24 09:11:28 +02:00
parent 9acca25ac7
commit a62866dfe3
17 changed files with 302 additions and 21 deletions

View file

@ -24,6 +24,7 @@ namespace App\Entity\AssemblySystem;
use App\Repository\AssemblyRepository;
use App\Validator\Constraints\AssemblySystem\AssemblyCycle;
use App\Validator\Constraints\AssemblySystem\AssemblyInvalidBomEntry;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
@ -111,11 +112,12 @@ class Assembly extends AbstractStructuralDBElement
* @var Collection<int, AssemblyBOMEntry>
*/
#[Assert\Valid]
#[AssemblyCycle]
#[AssemblyInvalidBomEntry]
#[UniqueReferencedAssembly]
#[Groups(['extended', 'full', 'import'])]
#[ORM\OneToMany(targetEntity: AssemblyBOMEntry::class, mappedBy: 'assembly', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[UniqueObjectCollection(message: 'assembly.bom_entry.part_already_in_bom', fields: ['part'])]
#[AssemblyCycle]
#[UniqueReferencedAssembly]
#[UniqueObjectCollection(message: 'assembly.bom_entry.project_already_in_bom', fields: ['project'])]
#[UniqueObjectCollection(message: 'assembly.bom_entry.name_already_in_bom', fields: ['name'])]
protected Collection $bom_entries;

View file

@ -39,6 +39,7 @@ use App\Entity\Contracts\TimeStampableInterface;
use App\Entity\ProjectSystem\Project;
use App\Repository\DBElementRepository;
use App\Validator\Constraints\AssemblySystem\AssemblyCycle;
use App\Validator\Constraints\AssemblySystem\AssemblyInvalidBomEntry;
use App\Validator\UniqueValidatableInterface;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
@ -142,6 +143,7 @@ class AssemblyBOMEntry extends AbstractDBElement implements UniqueValidatableInt
message: 'validator.assembly.bom_entry.only_part_or_assembly_allowed'
)]
#[AssemblyCycle]
#[AssemblyInvalidBomEntry]
#[ORM\ManyToOne(targetEntity: Assembly::class)]
#[ORM\JoinColumn(name: 'id_referenced_assembly', nullable: true, onDelete: 'SET NULL')]
#[Groups(['bom_entry:read', 'bom_entry:write', ])]

View file

@ -49,49 +49,78 @@ class AssemblyCycleValidator extends ConstraintValidator
return;
}
$bomEntries = $value->getBomEntries()->toArray();
$availableViolations = $this->context->getViolations();
if (count($availableViolations) > 0) {
//already violations given, currently no more needed to check
return;
}
$bomEntries = [];
// Consider additional entries from the form
if ($this->context->getRoot() instanceof Form && $this->context->getRoot()->has('bom_entries')) {
$formBomEntries = $this->context->getRoot()->get('bom_entries')->getData();
if ($formBomEntries) {
$given = is_array($formBomEntries) ? $formBomEntries : iterator_to_array($formBomEntries);
foreach ($given as $givenIdx => $entry) {
if (in_array($entry, $bomEntries, true)) {
continue;
} else {
$bomEntries[$givenIdx] = $entry;
}
}
$bomEntries = $this->context->getRoot()->get('bom_entries')->getData();
$bomEntries = is_array($bomEntries) ? $bomEntries : iterator_to_array($bomEntries);
} elseif ($this->context->getRoot() instanceof Assembly) {
$bomEntries = $value->getBomEntries()->toArray();
}
$relevantEntries = [];
foreach ($bomEntries as $bomEntry) {
if ($bomEntry->getReferencedAssembly() !== null) {
$relevantEntries[$bomEntry->getId()] = $bomEntry;
}
}
$visitedAssemblies = [];
foreach ($bomEntries as $bomEntry) {
foreach ($relevantEntries as $bomEntry) {
if ($this->hasCycle($bomEntry->getReferencedAssembly(), $value, $visitedAssemblies)) {
$this->addViolation($value, $constraint);
}
}
}
private function hasCycle(?Assembly $currentAssembly, Assembly $originalAssembly, array &$visitedAssemblies): bool
/**
* Determines if there is a cyclic dependency in the assembly hierarchy.
*
* This method checks if a cycle exists in the hierarchy of referenced assemblies starting
* from a given assembly. It traverses through the Bill of Materials (BOM) entries of each
* assembly recursively and keeps track of visited assemblies to detect cycles.
*
* @param Assembly|null $currentAssembly The current assembly being checked for cycles.
* @param Assembly $originalAssembly The original assembly from where the cycle detection started.
* @param Assembly[] $visitedAssemblies A list of assemblies that have been visited during the current traversal.
*
* @return bool True if a cycle is detected, false otherwise.
*/
private function hasCycle(?Assembly $currentAssembly, Assembly $originalAssembly, array $visitedAssemblies = []): bool
{
//No referenced assembly → no cycle
if ($currentAssembly === null) {
return false;
}
if (in_array($currentAssembly, $visitedAssemblies, true)) {
//If the assembly has already been visited, there is a cycle
if (in_array($currentAssembly->getId(), array_map(fn($a) => $a->getId(), $visitedAssemblies), true)) {
return true;
}
//Add the current assembly to the visited
$visitedAssemblies[] = $currentAssembly;
//Go through the bom entries of the current assembly
foreach ($currentAssembly->getBomEntries() as $bomEntry) {
if ($this->hasCycle($bomEntry->getReferencedAssembly(), $originalAssembly, $visitedAssemblies)) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
if ($referencedAssembly !== null && $this->hasCycle($referencedAssembly, $originalAssembly, $visitedAssemblies)) {
return true;
}
}
//Remove the current assembly from the list of visit (recursion completed)
array_pop($visitedAssemblies);
return false;
}
@ -102,11 +131,11 @@ class AssemblyCycleValidator extends ConstraintValidator
* already exists in the context. If such a violation is found, the current violation is not added again.
* The process involves reflection to access private or protected properties of violation objects.
*
* @param mixed $value The value that triggered the violation.
* @param Constraint $constraint The constraint containing the validation details.
* @param mixed $value The value that triggered the violation.
* @param Constraint $constraint The constraint containing the validation details.
*
*/
private function addViolation($value, Constraint $constraint): void
private function addViolation(mixed $value, Constraint $constraint): void
{
/** @var ConstraintViolationBuilder $buildViolation */
$buildViolation = $this->context->buildViolation($constraint->message)

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints\AssemblySystem;
use Symfony\Component\Validator\Constraint;
/**
* This constraint ensures that no BOM entries in the assembly reference its own children.
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class AssemblyInvalidBomEntry extends Constraint
{
public string $message = 'assembly.bom_entry.invalid_child_entry';
public function validatedBy(): string
{
return AssemblyInvalidBomEntryValidator::class;
}
}

View file

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints\AssemblySystem;
use App\Entity\AssemblySystem\Assembly;
use Symfony\Component\Form\Form;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilder;
use ReflectionClass;
/**
* Validator to check that no child assemblies are referenced in BOM entries.
*/
class AssemblyInvalidBomEntryValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof AssemblyInvalidBomEntry) {
throw new UnexpectedTypeException($constraint, AssemblyInvalidBomEntry::class);
}
if (!$value instanceof Assembly) {
return;
}
$availableViolations = $this->context->getViolations();
if (count($availableViolations) > 0) {
//already violations given, currently no more needed to check
return;
}
$bomEntries = [];
if ($this->context->getRoot() instanceof Form && $this->context->getRoot()->has('bom_entries')) {
$bomEntries = $this->context->getRoot()->get('bom_entries')->getData();
$bomEntries = is_array($bomEntries) ? $bomEntries : iterator_to_array($bomEntries);
} elseif ($this->context->getRoot() instanceof Assembly) {
$bomEntries = $value->getBomEntries()->toArray();
}
$relevantEntries = [];
foreach ($bomEntries as $bomEntry) {
if ($bomEntry->getReferencedAssembly() !== null) {
$relevantEntries[$bomEntry->getId()] = $bomEntry;
}
}
foreach ($relevantEntries as $bomEntry) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
if ($bomEntry->getAssembly()->getParent()?->getId() === $referencedAssembly->getParent()?->getId()) {
//Save on the same assembly level
continue;
} elseif ($this->isInvalidBomEntry($referencedAssembly, $bomEntry->getAssembly())) {
$this->addViolation($value, $constraint);
}
}
}
/**
* Determines whether a Bill of Materials (BOM) entry is invalid based on the relationship
* between the current assembly and the parent assembly.
*
* @param Assembly|null $currentAssembly The current assembly being analyzed. Null indicates no assembly is referenced.
* @param Assembly $parentAssembly The parent assembly to check against the current assembly.
*
* @return bool Returns
*/
private function isInvalidBomEntry(?Assembly $currentAssembly, Assembly $parentAssembly): bool
{
//No assembly referenced -> no problems
if ($currentAssembly === null) {
return false;
}
//Check: is the current assembly a descendant of the parent assembly?
if ($currentAssembly->isChildOf($parentAssembly)) {
return true;
}
//Recursive check: Analyze the current assembly list
foreach ($currentAssembly->getBomEntries() as $bomEntry) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
if ($this->isInvalidBomEntry($referencedAssembly, $parentAssembly)) {
return true;
}
}
return false;
}
private function isOnSameLevel(Assembly $assembly1, Assembly $assembly2): bool
{
$parent1 = $assembly1->getParent();
$parent2 = $assembly2->getParent();
if ($parent1 === null || $parent2 === null) {
return false;
}
// Beide Assemblies teilen denselben Parent
return $parent1 !== null && $parent2 !== null && $parent1->getId() === $parent2->getId();
}
/**
* Adds a violation to the current context if it hasnt already been added.
*
* This method checks whether a violation with the same property path as the current violation
* already exists in the context. If such a violation is found, the current violation is not added again.
* The process involves reflection to access private or protected properties of violation objects.
*
* @param mixed $value The value that triggered the violation.
* @param Constraint $constraint The constraint containing the validation details.
*
*/
private function addViolation($value, Constraint $constraint): void
{
/** @var ConstraintViolationBuilder $buildViolation */
$buildViolation = $this->context->buildViolation($constraint->message)
->setParameter('%name%', $value->getName());
$alreadyAdded = false;
try {
$reflectionClass = new ReflectionClass($buildViolation);
$property = $reflectionClass->getProperty('propertyPath');
$propertyPath = $property->getValue($buildViolation);
$availableViolations = $this->context->getViolations();
foreach ($availableViolations as $tmpViolation) {
$tmpReflectionClass = new ReflectionClass($tmpViolation);
$tmpProperty = $tmpReflectionClass->getProperty('propertyPath');
$tmpPropertyPath = $tmpProperty->getValue($tmpViolation);
if ($tmpPropertyPath === $propertyPath) {
$alreadyAdded = true;
}
}
} catch (\ReflectionException) {
}
if (!$alreadyAdded) {
$buildViolation->addViolation();
}
}
}