@@ -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);
+ }
+}