From de76a77120bbb39ac114a78033dc8afe703e4308 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 6 May 2026 14:45:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(bovine)=20:=20suivi=20des=20mouvements=20i?= =?UTF-8?q?nternes=20(b=C3=A2timent/case)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entité BovineMovement (bovine, buildingCase|building, enteredAt, leftAt) + relation OneToMany sur Bovine ordonnée DESC - Endpoint POST /api/bovine_movements via BovineMovementProcessor : ferme le mouvement courant, ouvre le nouveau, synchronise bovine.buildingCase - Commande idempotente app:backfill-bovine-movements pour initialiser l'historique des bovins existants - Onglet Mouvement de la page Vie du bovin : form 3 colonnes (style admin) + UiDataTable avec filtres header (Bâtiment, Case actifs ; Du/Au/Durée désactivés) Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/pages/bovine/[id].vue | 190 +++++++++++++++++- migrations/Version20260506141455.php | 33 +++ .../BackfillBovineMovementsCommand.php | 93 +++++++++ src/Entity/Bovine.php | 40 ++++ src/Entity/BovineMovement.php | 124 ++++++++++++ src/Repository/BovineMovementRepository.php | 34 ++++ src/State/Bovin/BovineMovementProcessor.php | 44 ++++ 7 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 migrations/Version20260506141455.php create mode 100644 src/Command/BackfillBovineMovementsCommand.php create mode 100644 src/Entity/BovineMovement.php create mode 100644 src/Repository/BovineMovementRepository.php create mode 100644 src/State/Bovin/BovineMovementProcessor.php diff --git a/frontend/pages/bovine/[id].vue b/frontend/pages/bovine/[id].vue index 44cc1be..2e7650c 100644 --- a/frontend/pages/bovine/[id].vue +++ b/frontend/pages/bovine/[id].vue @@ -21,6 +21,78 @@ ]" /> +
+
+
+ + +
+
+ +
+ + + Ajouter + +
+ + + + + + + + + + +
+
@@ -78,10 +150,13 @@ diff --git a/migrations/Version20260506141455.php b/migrations/Version20260506141455.php new file mode 100644 index 0000000..2baa6cd --- /dev/null +++ b/migrations/Version20260506141455.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE bovine_movement (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, bovine_id INT NOT NULL, building_case_id INT DEFAULT NULL, building_id INT DEFAULT NULL, entered_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, left_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX idx_bovine_movement_bovine ON bovine_movement (bovine_id)'); + $this->addSql('CREATE INDEX idx_bovine_movement_timeline ON bovine_movement (bovine_id, entered_at)'); + $this->addSql('CREATE INDEX idx_bovine_movement_case ON bovine_movement (building_case_id)'); + $this->addSql('CREATE INDEX idx_bovine_movement_building ON bovine_movement (building_id)'); + $this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_bovine FOREIGN KEY (bovine_id) REFERENCES bovine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_case FOREIGN KEY (building_case_id) REFERENCES building_case (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_building FOREIGN KEY (building_id) REFERENCES building (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE bovine_movement'); + } +} diff --git a/src/Command/BackfillBovineMovementsCommand.php b/src/Command/BackfillBovineMovementsCommand.php new file mode 100644 index 0000000..3f85759 --- /dev/null +++ b/src/Command/BackfillBovineMovementsCommand.php @@ -0,0 +1,93 @@ +entityManager->createQueryBuilder() + ->select('b') + ->from(Bovine::class, 'b') + ->where('b.buildingCase IS NOT NULL OR b.building IS NOT NULL') + ->andWhere('NOT EXISTS (SELECT 1 FROM '.BovineMovement::class.' m WHERE m.bovine = b)') + ->getQuery() + ->getResult() + ; + + $total = count($bovines); + if (0 === $total) { + $io->success('Aucun bovin à backfiller.'); + + return Command::SUCCESS; + } + + $io->info(sprintf('%d bovin(s) à backfiller.', $total)); + + $now = new DateTimeImmutable(); + $created = 0; + $fallback = 0; + + foreach ($bovines as $i => $bovine) { + $movement = new BovineMovement(); + $movement->setBovine($bovine); + + if (null !== $bovine->getBuildingCase()) { + $movement->setBuildingCase($bovine->getBuildingCase()); + } else { + $movement->setBuilding($bovine->getBuilding()); + } + + $enteredAt = $bovine->getArrivalDate(); + if (null === $enteredAt) { + $enteredAt = $now; + ++$fallback; + } + $movement->setEnteredAt($enteredAt); + + $this->entityManager->persist($movement); + ++$created; + + if (0 === ($i + 1) % self::FLUSH_EVERY) { + $this->entityManager->flush(); + } + } + + $this->entityManager->flush(); + + $io->success(sprintf('%d mouvement(s) créé(s).', $created)); + if ($fallback > 0) { + $io->warning(sprintf("%d bovin(s) sans date d'arrivée → enteredAt = maintenant.", $fallback)); + } + + return Command::SUCCESS; + } +} diff --git a/src/Entity/Bovine.php b/src/Entity/Bovine.php index 2cc3796..bb464ea 100644 --- a/src/Entity/Bovine.php +++ b/src/Entity/Bovine.php @@ -17,6 +17,8 @@ use ApiPlatform\Metadata\Post; use App\Repository\BovineRepository; use App\State\Bovin\BovineProcessor; use DateTimeImmutable; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Groups; @@ -153,6 +155,19 @@ class Bovine #[ApiProperty(readableLink: true)] private ?BovineType $fatherBovineType = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: BovineMovement::class, mappedBy: 'bovine', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['enteredAt' => 'DESC'])] + #[Groups(['bovine:read'])] + private Collection $movements; + + public function __construct() + { + $this->movements = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -395,6 +410,31 @@ class Bovine return $this; } + /** + * @return Collection + */ + public function getMovements(): Collection + { + return $this->movements; + } + + public function addMovement(BovineMovement $movement): static + { + if (!$this->movements->contains($movement)) { + $this->movements->add($movement); + $movement->setBovine($this); + } + + return $this; + } + + public function removeMovement(BovineMovement $movement): static + { + $this->movements->removeElement($movement); + + return $this; + } + #[ORM\PrePersist] #[ORM\PreUpdate] public function refreshAgeMonths(): void diff --git a/src/Entity/BovineMovement.php b/src/Entity/BovineMovement.php new file mode 100644 index 0000000..d93b262 --- /dev/null +++ b/src/Entity/BovineMovement.php @@ -0,0 +1,124 @@ + ['bovine_movement:write']], + normalizationContext: ['groups' => ['bovine:read']], + processor: BovineMovementProcessor::class, + ), + ], + security: "is_granted('ROLE_USER')", +)] +class BovineMovement +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['bovine:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(inversedBy: 'movements')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['bovine_movement:write'])] + private Bovine $bovine; + + #[ORM\ManyToOne] + #[Groups(['bovine:read', 'bovine_movement:write'])] + #[ApiProperty(readableLink: true)] + private ?BuildingCase $buildingCase = null; + + #[ORM\ManyToOne] + #[Groups(['bovine:read'])] + #[ApiProperty(readableLink: true)] + private ?Building $building = null; + + #[ORM\Column(type: 'datetime_immutable')] + #[Groups(['bovine:read'])] + private DateTimeImmutable $enteredAt; + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + #[Groups(['bovine:read'])] + private ?DateTimeImmutable $leftAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getBovine(): Bovine + { + return $this->bovine; + } + + public function setBovine(Bovine $bovine): static + { + $this->bovine = $bovine; + + return $this; + } + + public function getBuildingCase(): ?BuildingCase + { + return $this->buildingCase; + } + + public function setBuildingCase(?BuildingCase $buildingCase): static + { + $this->buildingCase = $buildingCase; + + return $this; + } + + public function getBuilding(): ?Building + { + return $this->building; + } + + public function setBuilding(?Building $building): static + { + $this->building = $building; + + return $this; + } + + public function getEnteredAt(): DateTimeImmutable + { + return $this->enteredAt; + } + + public function setEnteredAt(DateTimeImmutable $enteredAt): static + { + $this->enteredAt = $enteredAt; + + return $this; + } + + public function getLeftAt(): ?DateTimeImmutable + { + return $this->leftAt; + } + + public function setLeftAt(?DateTimeImmutable $leftAt): static + { + $this->leftAt = $leftAt; + + return $this; + } +} diff --git a/src/Repository/BovineMovementRepository.php b/src/Repository/BovineMovementRepository.php new file mode 100644 index 0000000..421a3af --- /dev/null +++ b/src/Repository/BovineMovementRepository.php @@ -0,0 +1,34 @@ + + */ +final class BovineMovementRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BovineMovement::class); + } + + public function findOpenMovement(Bovine $bovine): ?BovineMovement + { + return $this->createQueryBuilder('m') + ->where('m.bovine = :bovine') + ->andWhere('m.leftAt IS NULL') + ->setParameter('bovine', $bovine) + ->orderBy('m.enteredAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } +} diff --git a/src/State/Bovin/BovineMovementProcessor.php b/src/State/Bovin/BovineMovementProcessor.php new file mode 100644 index 0000000..8c28cf8 --- /dev/null +++ b/src/State/Bovin/BovineMovementProcessor.php @@ -0,0 +1,44 @@ +persistProcessor->process($data, $operation, $uriVariables, $context); + } + + $now = new DateTimeImmutable(); + $data->setEnteredAt($now); + $data->setLeftAt(null); + $data->setBuilding(null); + + $bovine = $data->getBovine(); + + $openMovement = $this->movementRepository->findOpenMovement($bovine); + if (null !== $openMovement) { + $openMovement->setLeftAt($now); + } + + $bovine->setBuildingCase($data->getBuildingCase()); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } +}