From 0447a7e6b3f6b9958ac5a7084d14381ad96ef035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 12 Nov 2023 21:53:45 +0100 Subject: [PATCH 01/20] Added basic data structures for part associations --- src/Entity/Parts/Part.php | 13 ++ src/Entity/Parts/PartAssociation.php | 117 ++++++++++++++++++ src/Entity/Parts/PartAssociationType.php | 37 ++++++ .../Parts/PartTraits/AssociationTrait.php | 92 ++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 src/Entity/Parts/PartAssociation.php create mode 100644 src/Entity/Parts/PartAssociationType.php create mode 100644 src/Entity/Parts/PartTraits/AssociationTrait.php diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index fa01d212..9f7cda95 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -41,6 +41,7 @@ use App\ApiPlatform\Filter\EntityFilter; use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\PartStoragelocationFilter; use App\Entity\Attachments\AttachmentTypeAttachment; +use App\Entity\Parts\PartTraits\AssociationTrait; use App\Repository\PartRepository; use Doctrine\DBAL\Types\Types; use App\Entity\Attachments\Attachment; @@ -58,6 +59,7 @@ use DateTime; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Jfcherng\Diff\Utility\Arr; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; @@ -112,6 +114,7 @@ class Part extends AttachmentContainingDBElement use OrderTrait; use ParametersTrait; use ProjectTrait; + use AssociationTrait; /** @var Collection */ @@ -165,6 +168,9 @@ class Part extends AttachmentContainingDBElement $this->parameters = new ArrayCollection(); $this->project_bom_entries = new ArrayCollection(); + $this->associated_parts_as_owner = new ArrayCollection(); + $this->associated_parts_as_other = new ArrayCollection(); + //By default, the part has no provider $this->providerReference = InfoProviderReference::noProvider(); } @@ -193,6 +199,13 @@ class Part extends AttachmentContainingDBElement $this->addParameter(clone $parameter); } + //Deep clone the owned part associations (the owned ones make not much sense without the owner) + $ownedAssociations = $this->associated_parts_as_owner; + $this->associated_parts_as_owner = new ArrayCollection(); + foreach ($ownedAssociations as $association) { + $this->addAssociatedPartsAsOwner(clone $association); + } + //Deep clone info provider $this->providerReference = clone $this->providerReference; } diff --git a/src/Entity/Parts/PartAssociation.php b/src/Entity/Parts/PartAssociation.php new file mode 100644 index 00000000..1fc0530d --- /dev/null +++ b/src/Entity/Parts/PartAssociation.php @@ -0,0 +1,117 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Entity\Parts; + +use App\Repository\DBElementRepository; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use App\Entity\Base\AbstractDBElement; +use App\Entity\Base\TimestampTrait; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * This entity describes a part association, which is a semantic connection between two parts. + * For example, a part association can be used to describe that a part is a replacement for another part. + */ +#[ORM\Entity(repositoryClass: DBElementRepository::class)] +#[ORM\HasLifecycleCallbacks] +class PartAssociation extends AbstractDBElement +{ + use TimestampTrait; + + /** + * @var PartAssociationType The type of this association (how the two parts are related) + */ + #[ORM\Column(type: Types::SMALLINT, enumType: PartAssociationType::class)] + protected PartAssociationType $type = PartAssociationType::OTHER; + + /** + * @var string|null A comment describing this association further. Can also be used to specify the OTHER type + * further. + */ + #[ORM\Column(type: Types::TEXT, nullable: true)] + protected ?string $comment = null; + + /** + * @var Part|null The part which "owns" this association, e.g. the part which is a replacement for another part + */ + #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'associated_parts_as_owner')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Assert\NotNull] + protected ?Part $owner = null; + + /** + * @var Part|null The part which is "owned" by this association, e.g. the part which is replaced by another part + */ + #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'associated_parts_as_other')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Assert\NotNull] + protected ?Part $other = null; + + public function getType(): PartAssociationType + { + return $this->type; + } + + public function setType(PartAssociationType $type): PartAssociation + { + $this->type = $type; + return $this; + } + + public function getComment(): ?string + { + return $this->comment; + } + + public function setComment(?string $comment): PartAssociation + { + $this->comment = $comment; + return $this; + } + + public function getOwner(): ?Part + { + return $this->owner; + } + + public function setOwner(?Part $owner): PartAssociation + { + $this->owner = $owner; + return $this; + } + + public function getOther(): ?Part + { + return $this->other; + } + + public function setOther(?Part $other): PartAssociation + { + $this->other = $other; + return $this; + } + + +} \ No newline at end of file diff --git a/src/Entity/Parts/PartAssociationType.php b/src/Entity/Parts/PartAssociationType.php new file mode 100644 index 00000000..d9320288 --- /dev/null +++ b/src/Entity/Parts/PartAssociationType.php @@ -0,0 +1,37 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Entity\Parts; + +/** + * The values of this enums are used to describe how two parts are associated with each other. + */ +enum PartAssociationType: int +{ + /** A user definable association type, which can be described in the comment field */ + case OTHER = 0; + /** The owning part is compatible with the other part */ + case COMPATIBLE = 1; + /** The owning part supersedes the other part (owner is newer version) */ + case SUPERSEDES = 2; +} diff --git a/src/Entity/Parts/PartTraits/AssociationTrait.php b/src/Entity/Parts/PartTraits/AssociationTrait.php new file mode 100644 index 00000000..82c46f27 --- /dev/null +++ b/src/Entity/Parts/PartTraits/AssociationTrait.php @@ -0,0 +1,92 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Entity\Parts\PartTraits; + +use App\Entity\Parts\PartAssociation; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Validator\Constraints\Valid; +use Doctrine\ORM\Mapping as ORM; + +trait AssociationTrait +{ + /** + * @var Collection All associations where this part is the owner + */ + #[Valid] + #[ORM\OneToMany(mappedBy: 'owner', targetEntity: PartAssociation::class, + cascade: ['persist', 'remove'], orphanRemoval: true)] + protected Collection $associated_parts_as_owner; + + /** + * @var Collection All associations where this part is the owned/other part + */ + #[Valid] + #[ORM\OneToMany(mappedBy: 'other', targetEntity: PartAssociation::class, + cascade: ['persist', 'remove'], orphanRemoval: true)] + protected Collection $associated_parts_as_other; + + /** + * Returns all associations where this part is the owner. + * @return Collection + */ + public function getAssociatedPartsAsOwner(): Collection + { + return $this->associated_parts_as_owner; + } + + /** + * Add a new association where this part is the owner. + * @param PartAssociation $association + * @return $this + */ + public function addAssociatedPartsAsOwner(PartAssociation $association): self + { + //Ensure that the association is really owned by this part + $association->setOwner($this); + + $this->associated_parts_as_owner->add($association); + return $this; + } + + /** + * Remove an association where this part is the owner. + * @param PartAssociation $association + * @return $this + */ + public function removeAssociatedPartsAsOwner(PartAssociation $association): self + { + $this->associated_parts_as_owner->removeElement($association); + return $this; + } + + /** + * Returns all associations where this part is the owned/other part. + * If you want to modify the association, do it on the owning part + * @return Collection + */ + public function getAssociatedPartsAsOther(): Collection + { + return $this->associated_parts_as_other; + } +} \ No newline at end of file From b7cfdebad58aaecd54df85f46cba5048b3312ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 12 Nov 2023 22:06:05 +0100 Subject: [PATCH 02/20] Added data field for vendor PartLot barcodes --- src/Entity/Parts/PartLot.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Entity/Parts/PartLot.php b/src/Entity/Parts/PartLot.php index 58fa2afb..fe683499 100644 --- a/src/Entity/Parts/PartLot.php +++ b/src/Entity/Parts/PartLot.php @@ -60,8 +60,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Entity] #[ORM\HasLifecycleCallbacks] #[ORM\Table(name: 'part_lots')] -#[ORM\Index(name: 'part_lots_idx_instock_un_expiration_id_part', columns: ['instock_unknown', 'expiration_date', 'id_part'])] -#[ORM\Index(name: 'part_lots_idx_needs_refill', columns: ['needs_refill'])] +#[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')] +#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')] +#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')] #[ValidPartLot] #[ApiResource( operations: [ @@ -154,6 +155,12 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named #[Groups(['part_lot:read', 'part_lot:write'])] protected ?User $owner = null; + /** + * @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor) + */ + #[ORM\Column(type: Types::STRING, nullable: true)] + protected ?string $vendor_barcode = null; + public function __clone() { if ($this->id) { From 8ab9cf14177c660908fd4c2d0229c6bece55cfd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 13 Nov 2023 00:11:58 +0100 Subject: [PATCH 03/20] Added very basic possibility to add an association --- migrations/Version20231112211329.php | 39 +++++++++++++ src/Entity/LogSystem/LogTargetType.php | 4 ++ ...ssociationType.php => AssociationType.php} | 2 +- src/Entity/Parts/PartAssociation.php | 10 ++-- src/Form/Part/PartAssociationType.php | 57 +++++++++++++++++++ src/Form/Part/PartBaseType.php | 10 ++++ .../parts/edit/_associated_parts.html.twig | 18 ++++++ templates/parts/edit/edit_part_info.html.twig | 9 +++ 8 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 migrations/Version20231112211329.php rename src/Entity/Parts/{PartAssociationType.php => AssociationType.php} (97%) create mode 100644 src/Form/Part/PartAssociationType.php create mode 100644 templates/parts/edit/_associated_parts.html.twig diff --git a/migrations/Version20231112211329.php b/migrations/Version20231112211329.php new file mode 100644 index 00000000..8fa7409c --- /dev/null +++ b/migrations/Version20231112211329.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE part_association (id INT AUTO_INCREMENT NOT NULL, owner_id INT NOT NULL, other_id INT NOT NULL, type SMALLINT NOT NULL, comment LONGTEXT DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, INDEX IDX_61B952E07E3C61F9 (owner_id), INDEX IDX_61B952E0998D9879 (other_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE part_association ADD CONSTRAINT FK_61B952E07E3C61F9 FOREIGN KEY (owner_id) REFERENCES `parts` (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE part_association ADD CONSTRAINT FK_61B952E0998D9879 FOREIGN KEY (other_id) REFERENCES `parts` (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE part_lots ADD vendor_barcode VARCHAR(255) DEFAULT NULL'); + $this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE part_association DROP FOREIGN KEY FK_61B952E07E3C61F9'); + $this->addSql('ALTER TABLE part_association DROP FOREIGN KEY FK_61B952E0998D9879'); + $this->addSql('DROP TABLE part_association'); + $this->addSql('DROP INDEX part_lots_idx_barcode ON part_lots'); + $this->addSql('ALTER TABLE part_lots DROP vendor_barcode'); + } +} diff --git a/src/Entity/LogSystem/LogTargetType.php b/src/Entity/LogSystem/LogTargetType.php index eb3346d8..6e413079 100644 --- a/src/Entity/LogSystem/LogTargetType.php +++ b/src/Entity/LogSystem/LogTargetType.php @@ -29,6 +29,7 @@ use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; +use App\Entity\Parts\PartAssociation; use App\Entity\Parts\PartLot; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; @@ -63,6 +64,8 @@ enum LogTargetType: int case PARAMETER = 18; case LABEL_PROFILE = 19; + case PART_ASSOCIATION = 20; + /** * Returns the class name of the target type or null if the target type is NONE. * @return string|null @@ -90,6 +93,7 @@ enum LogTargetType: int self::MEASUREMENT_UNIT => MeasurementUnit::class, self::PARAMETER => AbstractParameter::class, self::LABEL_PROFILE => LabelProfile::class, + self::PART_ASSOCIATION => PartAssociation::class, }; } diff --git a/src/Entity/Parts/PartAssociationType.php b/src/Entity/Parts/AssociationType.php similarity index 97% rename from src/Entity/Parts/PartAssociationType.php rename to src/Entity/Parts/AssociationType.php index d9320288..6980f600 100644 --- a/src/Entity/Parts/PartAssociationType.php +++ b/src/Entity/Parts/AssociationType.php @@ -26,7 +26,7 @@ namespace App\Entity\Parts; /** * The values of this enums are used to describe how two parts are associated with each other. */ -enum PartAssociationType: int +enum AssociationType: int { /** A user definable association type, which can be described in the comment field */ case OTHER = 0; diff --git a/src/Entity/Parts/PartAssociation.php b/src/Entity/Parts/PartAssociation.php index 1fc0530d..ae3e1271 100644 --- a/src/Entity/Parts/PartAssociation.php +++ b/src/Entity/Parts/PartAssociation.php @@ -41,10 +41,10 @@ class PartAssociation extends AbstractDBElement use TimestampTrait; /** - * @var PartAssociationType The type of this association (how the two parts are related) + * @var AssociationType The type of this association (how the two parts are related) */ - #[ORM\Column(type: Types::SMALLINT, enumType: PartAssociationType::class)] - protected PartAssociationType $type = PartAssociationType::OTHER; + #[ORM\Column(type: Types::SMALLINT, enumType: AssociationType::class)] + protected AssociationType $type = AssociationType::OTHER; /** * @var string|null A comment describing this association further. Can also be used to specify the OTHER type @@ -69,12 +69,12 @@ class PartAssociation extends AbstractDBElement #[Assert\NotNull] protected ?Part $other = null; - public function getType(): PartAssociationType + public function getType(): AssociationType { return $this->type; } - public function setType(PartAssociationType $type): PartAssociation + public function setType(AssociationType $type): PartAssociation { $this->type = $type; return $this; diff --git a/src/Form/Part/PartAssociationType.php b/src/Form/Part/PartAssociationType.php new file mode 100644 index 00000000..7163c257 --- /dev/null +++ b/src/Form/Part/PartAssociationType.php @@ -0,0 +1,57 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Part; + +use App\Entity\Parts\AssociationType; +use App\Entity\Parts\PartAssociation; +use App\Form\Type\PartSelectType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class PartAssociationType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('type', EnumType::class, [ + 'class' => AssociationType::class, + ]) + ->add('other', PartSelectType::class) + ->add('comment', TextType::class, [ + 'required' => false, + ]) + ; + + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => PartAssociation::class, + ]); + } +} \ No newline at end of file diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index b15ec29f..f7243e64 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -245,6 +245,16 @@ class PartBaseType extends AbstractType ], ]); + //Part associations + $builder->add('associated_parts_as_owner', CollectionType::class, [ + 'entry_type' => PartAssociationType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'reindex_enable' => true, + 'label' => false, + 'by_reference' => false, + ]); + $builder->add('log_comment', TextType::class, [ 'label' => 'edit.log_comment', 'mapped' => false, diff --git a/templates/parts/edit/_associated_parts.html.twig b/templates/parts/edit/_associated_parts.html.twig new file mode 100644 index 00000000..beef96be --- /dev/null +++ b/templates/parts/edit/_associated_parts.html.twig @@ -0,0 +1,18 @@ +{% form_theme form with ['parts/edit/edit_form_styles.html.twig'] %} +{% import 'components/collection_type.macro.html.twig' as collection %} + +
+ + + {% for assoc in form.associated_parts_as_owner %} + {{ form_widget(assoc) }} + {% endfor %} + +
+ + +
\ No newline at end of file diff --git a/templates/parts/edit/edit_part_info.html.twig b/templates/parts/edit/edit_part_info.html.twig index 51b5d865..4dae8949 100644 --- a/templates/parts/edit/edit_part_info.html.twig +++ b/templates/parts/edit/edit_part_info.html.twig @@ -58,6 +58,12 @@ {% trans %}part.edit.tab.specifications{% endtrans %} + {% endif %} + + {% if part.associatedPartsAll is not empty %} + + {% endif %} +