From 642ee43c53611baa01e3c5871ec702b077215f39 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 6 May 2026 11:59:23 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(bovine)=20:=20page=20Vie=20du=20bovin?= =?UTF-8?q?=20+=20tabs=20r=C3=A9utilisables=20+=20parents=20EDNOTIF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouvelle page /bovine/[id] avec tabs Mouvement / Passeport bovin / Santé - Composant UiTabs partagé, réutilisé sur réception et expédition - Champs père/mère (numéro national + type de race) sur Bovine, alimentés via la sync EDNOTIF - Inventaire : ligne cliquable vers le passeport Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/components/ui/UiTabs.vue | 35 +++++ frontend/pages/bovine/[id].vue | 135 ++++++++++++++++++ frontend/pages/inventory.vue | 2 + frontend/pages/reception/update/[[id]].vue | 40 ++---- frontend/pages/shipment/update/[[id]].vue | 29 +--- migrations/Version20260504125011.php | 40 ++++++ src/Entity/Bovine.php | 66 +++++++++ .../Bovin/BovineSyncInventoryProcessor.php | 5 + 8 files changed, 298 insertions(+), 54 deletions(-) create mode 100644 frontend/components/ui/UiTabs.vue create mode 100644 frontend/pages/bovine/[id].vue create mode 100644 migrations/Version20260504125011.php diff --git a/frontend/components/ui/UiTabs.vue b/frontend/components/ui/UiTabs.vue new file mode 100644 index 0000000..25891b3 --- /dev/null +++ b/frontend/components/ui/UiTabs.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/pages/bovine/[id].vue b/frontend/pages/bovine/[id].vue new file mode 100644 index 0000000..44cc1be --- /dev/null +++ b/frontend/pages/bovine/[id].vue @@ -0,0 +1,135 @@ + + + diff --git a/frontend/pages/inventory.vue b/frontend/pages/inventory.vue index e2b06b0..e3faa7b 100644 --- a/frontend/pages/inventory.vue +++ b/frontend/pages/inventory.vue @@ -57,6 +57,8 @@ :items="items" :total-items="totalItems" :loading="loading" + row-clickable + @row-click="(item: BovineData) => router.push(`/bovine/${item.id}`)" > 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); + } +} -- 2.39.5 From 2f8aa1dd32d9b91ce2355b88115b93bb287030a4 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 6 May 2026 14:50:41 +0200 Subject: [PATCH 3/7] =?UTF-8?q?feat(bovine)=20:=20placeholder=20onglet=20S?= =?UTF-8?q?ant=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/pages/bovine/[id].vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/pages/bovine/[id].vue b/frontend/pages/bovine/[id].vue index 2e7650c..d1ed0f8 100644 --- a/frontend/pages/bovine/[id].vue +++ b/frontend/pages/bovine/[id].vue @@ -146,6 +146,12 @@ + +
+
+ À venir +
+
-- 2.39.5 From ee766311e3ad93f0b80a0f60f9c1e20fbc3bb328 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 6 May 2026 15:08:03 +0200 Subject: [PATCH 4/7] =?UTF-8?q?refactor(bovine)=20:=20redirige=20page=20ca?= =?UTF-8?q?se=20vers=20Vie=20du=20bovin=20et=20supprime=20l'=C3=A9dition?= =?UTF-8?q?=20front?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - infrastructure/case : clic ligne → /bovine/{id}, bouton Ajouter retiré, row-clickable ouvert à tous - infrastructure/bovine.vue supprimée (création/édition de bovin gérée via EDNOTIF) - bouton précédent de la page Vie du bovin : router.back avec fallback /inventory Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/pages/bovine/[id].vue | 12 +- frontend/pages/infrastructure/bovine.vue | 182 ----------------------- frontend/pages/infrastructure/case.vue | 23 +-- 3 files changed, 12 insertions(+), 205 deletions(-) delete mode 100644 frontend/pages/infrastructure/bovine.vue diff --git a/frontend/pages/bovine/[id].vue b/frontend/pages/bovine/[id].vue index d1ed0f8..4db4650 100644 --- a/frontend/pages/bovine/[id].vue +++ b/frontend/pages/bovine/[id].vue @@ -3,7 +3,7 @@
-
+
{ + if (window.history.state?.back) { + router.back() + } else { + router.push('/inventory') + } +} + const bovine = ref(null) const buildings = ref([]) const newMovementBuildingId = ref(null) diff --git a/frontend/pages/infrastructure/bovine.vue b/frontend/pages/infrastructure/bovine.vue deleted file mode 100644 index 3eeeb35..0000000 --- a/frontend/pages/infrastructure/bovine.vue +++ /dev/null @@ -1,182 +0,0 @@ - - - diff --git a/frontend/pages/infrastructure/case.vue b/frontend/pages/infrastructure/case.vue index 8de1836..220ee52 100644 --- a/frontend/pages/infrastructure/case.vue +++ b/frontend/pages/infrastructure/case.vue @@ -23,14 +23,6 @@
- - - Ajouter -
@@ -56,7 +48,7 @@ :items="items" :total-items="totalItems" :loading="loading" - :row-clickable="auth.isAdmin" + row-clickable empty-message="Aucun bovin dans cette case." @row-click="goToBovine" > @@ -134,7 +126,6 @@ useHead({ title: 'Cases' }) import type { BuildingCaseData } from '~/services/dto/building-case-data' import type { BovineData } from '~/services/dto/bovine-data' -import { useAuthStore } from '~/stores/auth' import { useDataTableServerState } from '~/composables/useDataTableServerState' import { useBovineColumns } from '~/composables/useBovineColumns' import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age' @@ -143,7 +134,6 @@ const route = useRoute() const router = useRouter() const { printPdf } = usePdfPrinter() const api = useApi() -const auth = useAuthStore() const caseId = computed(() => Number(route.query.id)) const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0) @@ -233,11 +223,6 @@ const title = computed(() => { return `${buildingLabel} case ${caseNumber}`.trim() }) -const addBovineRoute = computed(() => ({ - path: '/infrastructure/bovine', - query: { caseId: String(caseId.value) } -})) - const formatDate = (date: string | null) => { if (!date) return '—' const d = new Date(date) @@ -270,11 +255,7 @@ const printCaseReport = async () => { } const goToBovine = (bovine: BovineData) => { - if (!auth.isAdmin) return - router.push({ - path: '/infrastructure/bovine', - query: { id: String(bovine.id), caseId: String(caseId.value) } - }) + router.push(`/bovine/${bovine.id}`) } watch(caseId, (id) => { -- 2.39.5 From b932798a87594831f7cd26cbfca7d42ed3384ac4 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 6 May 2026 15:14:05 +0200 Subject: [PATCH 5/7] feat : update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1c9e2..095a2f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Ajouter dans le fichier .env du frontend * [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente * [#FER-17] Ecran d'ajout de bovin * [#FER-18] Mise à jour du tableau d'arrivage +* [#FER-26] Passeport du bovin ### Changed -- 2.39.5 From 5b24d642bbc1b9dea066579bb11b4a6d0b1818c3 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 7 May 2026 08:46:11 +0200 Subject: [PATCH 6/7] fix : label age bovin --- frontend/utils/bovine-age.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/utils/bovine-age.ts b/frontend/utils/bovine-age.ts index 2cad363..b400b37 100644 --- a/frontend/utils/bovine-age.ts +++ b/frontend/utils/bovine-age.ts @@ -4,7 +4,7 @@ export const formatAgeLabel = (months: number | null | undefined): string => { const remaining = months % 12 let label = '' if (years > 0) label = `${years} an${years > 1 ? 's' : ''}` - if (remaining > 0) label += `${label ? ' ' : ''}${remaining} mois` + if (remaining > 0) label += `${label ? ' ' : ''}${remaining} m` if (!label) label = '< 1 mois' return label } -- 2.39.5 From 754898da39cc5a503fb5937d6b3cf58272f353ac Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 13 May 2026 14:10:54 +0200 Subject: [PATCH 7/7] =?UTF-8?q?fix=20:=20ajout=20d'une=20date=20de=20mouve?= =?UTF-8?q?ment=20et=20protection=20sur=20le=20r=C3=B4le=20Bureau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- .idea/db-forest-config.xml | 10 ++ .idea/ferme.iml | 5 + .idea/php.xml | 5 + .idea/workspace.xml | 145 +++++++++++--------- frontend/pages/bovine/[id].vue | 44 ++++-- src/Entity/BovineMovement.php | 9 +- src/State/Bovin/BovineMovementProcessor.php | 6 +- 8 files changed, 150 insertions(+), 78 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0a5facb..a908a2f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(npm run:*)", "WebFetch(domain:geo.api.gouv.fr)", "Bash(pip3 install:*)", - "Bash(python3 -c \":*)" + "Bash(python3 -c \":*)", + "Bash(make cache-clear *)", + "Bash(make test *)" ] } } diff --git a/.idea/db-forest-config.xml b/.idea/db-forest-config.xml index 235c8ff..21f9f78 100644 --- a/.idea/db-forest-config.xml +++ b/.idea/db-forest-config.xml @@ -1,5 +1,15 @@ + + . + ---------------------------------------- + 1:0:9cad43df-2147-4989-b7a4-443067034884 + 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc + 3:0:f407a514-c6b4-4b26-9555-445a85892502 + 4:0:09e221b8-067a-488b-9c1d-4e155a333079 + 5:0:9d8c1ad3-2491-4642-964a-666003c14128 + . + diff --git a/.idea/ferme.iml b/.idea/ferme.iml index 9cfc341..fad0a5a 100644 --- a/.idea/ferme.iml +++ b/.idea/ferme.iml @@ -155,6 +155,11 @@ + + + + + diff --git a/.idea/php.xml b/.idea/php.xml index a081fbb..94dd869 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -174,6 +174,11 @@ + + + + + diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 3afb783..4adf605 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,12 +4,16 @@ - + + + + + - - - + + + - - - - @@ -792,10 +809,14 @@ - - diff --git a/frontend/pages/bovine/[id].vue b/frontend/pages/bovine/[id].vue index 4db4650..941c3b6 100644 --- a/frontend/pages/bovine/[id].vue +++ b/frontend/pages/bovine/[id].vue @@ -14,14 +14,10 @@ -
+
-
+
@@ -158,11 +160,19 @@