diff --git a/config/services.yaml b/config/services.yaml
index 8256daa1..659098ff 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -168,6 +168,9 @@ services:
arguments:
$useAssemblyIpnPlaceholder: '%partdb.create_assembly_use_ipn_placeholder_in_name%'
+ App\Validator\Constraints\AssemblySystem\AssemblyCycleValidator:
+ tags: [ 'validator.constraint_validator' ]
+
####################################################################################################################
# Table settings
####################################################################################################################
diff --git a/src/Entity/AssemblySystem/Assembly.php b/src/Entity/AssemblySystem/Assembly.php
index 9593dbb5..5ce060d8 100644
--- a/src/Entity/AssemblySystem/Assembly.php
+++ b/src/Entity/AssemblySystem/Assembly.php
@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Entity\AssemblySystem;
use App\Repository\AssemblyRepository;
+use App\Validator\Constraints\AssemblySystem\AssemblyCycle;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
@@ -113,6 +114,7 @@ class Assembly extends AbstractStructuralDBElement
#[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'])]
diff --git a/src/Entity/AssemblySystem/AssemblyBOMEntry.php b/src/Entity/AssemblySystem/AssemblyBOMEntry.php
index 820fc2f5..6a3e82d3 100644
--- a/src/Entity/AssemblySystem/AssemblyBOMEntry.php
+++ b/src/Entity/AssemblySystem/AssemblyBOMEntry.php
@@ -38,6 +38,7 @@ use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Contracts\TimeStampableInterface;
use App\Entity\ProjectSystem\Project;
use App\Repository\DBElementRepository;
+use App\Validator\Constraints\AssemblySystem\AssemblyCycle;
use App\Validator\UniqueValidatableInterface;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
@@ -140,6 +141,7 @@ class AssemblyBOMEntry extends AbstractDBElement implements UniqueValidatableInt
'(this.getPart() === null or this.getReferencedAssembly() === null) and (this.getName() === null or (this.getName() != null and this.getName() != ""))',
message: 'validator.assembly.bom_entry.only_part_or_assembly_allowed'
)]
+ #[AssemblyCycle]
#[ORM\ManyToOne(targetEntity: Assembly::class)]
#[ORM\JoinColumn(name: 'id_referenced_assembly', nullable: true, onDelete: 'SET NULL')]
#[Groups(['bom_entry:read', 'bom_entry:write', ])]
diff --git a/src/Validator/Constraints/AssemblySystem/AssemblyCycle.php b/src/Validator/Constraints/AssemblySystem/AssemblyCycle.php
new file mode 100644
index 00000000..9d79b879
--- /dev/null
+++ b/src/Validator/Constraints/AssemblySystem/AssemblyCycle.php
@@ -0,0 +1,39 @@
+.
+ */
+namespace App\Validator\Constraints\AssemblySystem;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * This constraint checks that there is no cycle in bom configuration of the assembly
+ */
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
+class AssemblyCycle extends Constraint
+{
+ public string $message = 'assembly.bom_entry.assembly_cycle';
+
+ public function validatedBy(): string
+ {
+ return AssemblyCycleValidator::class;
+ }
+}
\ No newline at end of file
diff --git a/src/Validator/Constraints/AssemblySystem/AssemblyCycleValidator.php b/src/Validator/Constraints/AssemblySystem/AssemblyCycleValidator.php
new file mode 100644
index 00000000..3483f94a
--- /dev/null
+++ b/src/Validator/Constraints/AssemblySystem/AssemblyCycleValidator.php
@@ -0,0 +1,139 @@
+.
+ */
+namespace App\Validator\Constraints\AssemblySystem;
+
+use App\Entity\AssemblySystem\Assembly;
+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 class to check for cycles in assemblies based on BOM entries.
+ *
+ * This validator ensures that the structure of assemblies does not contain circular dependencies
+ * by validating each entry in the Bill of Materials (BOM) of the given assembly. Additionally,
+ * it can handle form-submitted BOM entries to include these in the validation process.
+ */
+class AssemblyCycleValidator extends ConstraintValidator
+{
+ public function validate($value, Constraint $constraint): void
+ {
+ if (!$constraint instanceof AssemblyCycle) {
+ throw new UnexpectedTypeException($constraint, AssemblyCycle::class);
+ }
+
+ if (!$value instanceof Assembly) {
+ return;
+ }
+
+ $bomEntries = $value->getBomEntries()->toArray();
+
+ // Consider additional entries from the form
+ if ($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;
+ }
+ }
+ }
+ }
+
+ $visitedAssemblies = [];
+ foreach ($bomEntries as $bomEntry) {
+ if ($this->hasCycle($bomEntry->getReferencedAssembly(), $value, $visitedAssemblies)) {
+ $this->addViolation($value, $constraint);
+ }
+ }
+ }
+
+ private function hasCycle(?Assembly $currentAssembly, Assembly $originalAssembly, array &$visitedAssemblies): bool
+ {
+ if ($currentAssembly === null) {
+ return false;
+ }
+
+ if (in_array($currentAssembly, $visitedAssemblies, true)) {
+ return true;
+ }
+
+ $visitedAssemblies[] = $currentAssembly;
+
+ foreach ($currentAssembly->getBomEntries() as $bomEntry) {
+ if ($this->hasCycle($bomEntry->getReferencedAssembly(), $originalAssembly, $visitedAssemblies)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Adds a violation to the current context if it hasn’t 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();
+ }
+ }
+}
\ No newline at end of file
diff --git a/translations/validators.cs.xlf b/translations/validators.cs.xlf
index 1731c90c..7ee171b2 100644
--- a/translations/validators.cs.xlf
+++ b/translations/validators.cs.xlf
@@ -395,6 +395,12 @@
Tato sestava již existuje jako položka v seznamu materiálů!
+
+
+ assembly.bom_entry.assembly_cycle
+ Byl zjištěn cyklus: Sestava "%name%" nepřímo odkazuje sama na sebe.
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.da.xlf b/translations/validators.da.xlf
index 239e3572..24fa330a 100644
--- a/translations/validators.da.xlf
+++ b/translations/validators.da.xlf
@@ -371,6 +371,12 @@
Denne samling findes allerede som en post!
+
+
+ assembly.bom_entry.assembly_cycle
+ En cyklus blev opdaget: Samlingen "%name%" refererer indirekte til sig selv.
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.de.xlf b/translations/validators.de.xlf
index 3c3abb2b..549dfe63 100644
--- a/translations/validators.de.xlf
+++ b/translations/validators.de.xlf
@@ -395,6 +395,12 @@
Diese Baugruppe existiert bereits als Eintrag!
+
+
+ assembly.bom_entry.assembly_cycle
+ Ein Zyklus wurde entdeckt: Die Baugruppe "%name%" referenziert sich indirekt selbst.
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.el.xlf b/translations/validators.el.xlf
index 4e4278da..bc9b0947 100644
--- a/translations/validators.el.xlf
+++ b/translations/validators.el.xlf
@@ -37,6 +37,12 @@
Αυτή η συναρμολόγηση υπάρχει ήδη ως εγγραφή!
+
+
+ assembly.bom_entry.assembly_cycle
+ Εντοπίστηκε κύκλος: Η συναρμολόγηση "%name%" αναφέρεται έμμεσα στον εαυτό της.
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.en.xlf b/translations/validators.en.xlf
index 59cabf55..7d9beb4e 100644
--- a/translations/validators.en.xlf
+++ b/translations/validators.en.xlf
@@ -395,6 +395,12 @@
This assembly already exists as an entry!
+
+
+ assembly.bom_entry.assembly_cycle
+ A cycle was detected: the assembly "%name%" indirectly references itself.
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.fr.xlf b/translations/validators.fr.xlf
index aff68a18..1c9c5302 100644
--- a/translations/validators.fr.xlf
+++ b/translations/validators.fr.xlf
@@ -227,6 +227,12 @@
Cet assemblage existe déjà en tant qu'entrée !
+
+
+ assembly.bom_entry.assembly_cycle
+ Un cycle a été détecté : L'assemblage "%name%" se réfère indirectement à lui-même.
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.hr.xlf b/translations/validators.hr.xlf
index 1ee5c06f..89d470e7 100644
--- a/translations/validators.hr.xlf
+++ b/translations/validators.hr.xlf
@@ -389,6 +389,12 @@
Ova se montaža već nalazi kao zapis!
+
+
+ assembly.bom_entry.assembly_cycle
+ Otkriven je ciklus: Sklop "%name%" neizravno referencira samog sebe.
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.it.xlf b/translations/validators.it.xlf
index ac57a2cc..e9b528bb 100644
--- a/translations/validators.it.xlf
+++ b/translations/validators.it.xlf
@@ -389,6 +389,12 @@
Questo assemblaggio è già presente come voce!
+
+
+ assembly.bom_entry.assembly_cycle
+ È stato rilevato un ciclo: L'assemblaggio "%name%" fa riferimento indirettamente a sé stesso.
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.ja.xlf b/translations/validators.ja.xlf
index a316707a..80ec65ff 100644
--- a/translations/validators.ja.xlf
+++ b/translations/validators.ja.xlf
@@ -227,6 +227,12 @@
このアセンブリはすでにエントリとして存在します!
+
+
+ assembly.bom_entry.assembly_cycle
+ 循環が検出されました: アセンブリ「%name%」が間接的に自身を参照しています。
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.pl.xlf b/translations/validators.pl.xlf
index 95c44ab4..5df01cd6 100644
--- a/translations/validators.pl.xlf
+++ b/translations/validators.pl.xlf
@@ -389,6 +389,12 @@
To zestawienie jest już dodane jako wpis!
+
+
+ assembly.bom_entry.assembly_cycle
+ 循環が検出されました: アセンブリ「%name%」が間接的に自身を参照しています。
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.ru.xlf b/translations/validators.ru.xlf
index 425ede5f..8bf08ab3 100644
--- a/translations/validators.ru.xlf
+++ b/translations/validators.ru.xlf
@@ -389,6 +389,12 @@
Этот сборочный узел уже добавлен как запись!
+
+
+ assembly.bom_entry.assembly_cycle
+ Обнаружен цикл: Сборка «%name%» косвенно ссылается на саму себя.
+
+
assembly.bom_entry.project_already_in_bom
diff --git a/translations/validators.zh.xlf b/translations/validators.zh.xlf
index 4a02523b..87c507c1 100644
--- a/translations/validators.zh.xlf
+++ b/translations/validators.zh.xlf
@@ -377,6 +377,12 @@
此装配已经作为条目存在!
+
+
+ assembly.bom_entry.assembly_cycle
+ 检测到循环:装配体“%name%”间接引用了其自身。
+
+
assembly.bom_entry.project_already_in_bom