feat(bovine) : suivi des mouvements internes (bâtiment/case)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 14:45:35 +02:00
parent 642ee43c53
commit de76a77120
7 changed files with 556 additions and 2 deletions

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Bovine;
use App\Entity\BovineMovement;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function count;
#[AsCommand(
name: 'app:backfill-bovine-movements',
description: 'Crée un mouvement initial pour chaque bovin ayant une case ou un bâtiment mais aucun mouvement enregistré.'
)]
class BackfillBovineMovementsCommand extends Command
{
private const FLUSH_EVERY = 100;
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$bovines = $this->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;
}
}

View File

@@ -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<int, BovineMovement>
*/
#[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<int, BovineMovement>
*/
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

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Repository\BovineMovementRepository;
use App\State\Bovin\BovineMovementProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: BovineMovementRepository::class)]
#[ORM\Table(name: 'bovine_movement')]
#[ORM\Index(name: 'idx_bovine_movement_timeline', columns: ['bovine_id', 'entered_at'])]
#[ApiResource(
operations: [
new Post(
denormalizationContext: ['groups' => ['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;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Bovine;
use App\Entity\BovineMovement;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BovineMovement>
*/
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()
;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\BovineMovement;
use App\Repository\BovineMovementRepository;
use DateTimeImmutable;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class BovineMovementProcessor implements ProcessorInterface
{
public function __construct(
private readonly BovineMovementRepository $movementRepository,
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof BovineMovement) {
return $this->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);
}
}