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:
@@ -21,6 +21,78 @@
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div v-show="activeTab === 'mouvement'">
|
||||||
|
<form :class="{ submitted: movementSubmitted }" @submit.prevent="submitMovement">
|
||||||
|
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
||||||
|
<UiSelect
|
||||||
|
id="movement-building"
|
||||||
|
v-model="newMovementBuildingId"
|
||||||
|
label="Bâtiment"
|
||||||
|
:options="buildingOptions"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<UiSelect
|
||||||
|
id="movement-case"
|
||||||
|
v-model="newMovementCaseId"
|
||||||
|
label="Case"
|
||||||
|
:options="caseOptions"
|
||||||
|
:disabled="!newMovementBuildingId"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div class="w-[280px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center mb-11">
|
||||||
|
<UiButton
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center gap-2 text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80"
|
||||||
|
:disabled="isSubmittingMovement"
|
||||||
|
:loading="isSubmittingMovement"
|
||||||
|
@click="movementSubmitted = true"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:plus" size="28" />
|
||||||
|
Ajouter
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<UiDataTable
|
||||||
|
:columns="movementColumns"
|
||||||
|
:items="filteredMovementRows"
|
||||||
|
:per-page="10"
|
||||||
|
>
|
||||||
|
<template #header-building>
|
||||||
|
<UiTextInput
|
||||||
|
v-model="movementFilters.building"
|
||||||
|
placeholder="Bâtiment"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-case>
|
||||||
|
<UiTextInput
|
||||||
|
v-model="movementFilters.case"
|
||||||
|
placeholder="Case"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-enteredAt>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Du" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-leftAt>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Au" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-duration>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Durée" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #cell-leftAt="{ item }">
|
||||||
|
<span v-if="item.leftAt">{{ item.leftAt }}</span>
|
||||||
|
<span v-else class="italic text-slate-500">En cours</span>
|
||||||
|
</template>
|
||||||
|
</UiDataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-show="activeTab === 'passeport'">
|
<div v-show="activeTab === 'passeport'">
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
|
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
|
||||||
@@ -78,10 +150,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getBuildingList } from '~/services/building'
|
||||||
|
import type { BuildingData } from '~/services/dto/building-data'
|
||||||
|
|
||||||
useHead({ title: 'Vie du bovin' })
|
useHead({ title: 'Vie du bovin' })
|
||||||
|
|
||||||
type BovineTab = 'mouvement' | 'passeport' | 'sante'
|
type BovineTab = 'mouvement' | 'passeport' | 'sante'
|
||||||
const activeTab = ref<BovineTab>('passeport')
|
const activeTab = ref<BovineTab>('mouvement')
|
||||||
|
|
||||||
interface BovineTypeRef {
|
interface BovineTypeRef {
|
||||||
id: number
|
id: number
|
||||||
@@ -89,17 +164,37 @@ interface BovineTypeRef {
|
|||||||
code: string | null
|
code: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BuildingRef {
|
||||||
|
label: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildingCaseRef {
|
||||||
|
caseNumber: number | null
|
||||||
|
building: BuildingRef | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BovineMovementData {
|
||||||
|
id: number
|
||||||
|
enteredAt: string
|
||||||
|
leftAt: string | null
|
||||||
|
buildingCase: BuildingCaseRef | null
|
||||||
|
building: BuildingRef | null
|
||||||
|
}
|
||||||
|
|
||||||
interface BovinePassportData {
|
interface BovinePassportData {
|
||||||
id: number
|
id: number
|
||||||
nationalNumber: string
|
nationalNumber: string
|
||||||
workNumber: string | null
|
workNumber: string | null
|
||||||
sex: string | null
|
sex: string | null
|
||||||
birthDate: string | null
|
birthDate: string | null
|
||||||
|
exitedAt: string | null
|
||||||
|
exitDate: string | null
|
||||||
bovineType: BovineTypeRef | null
|
bovineType: BovineTypeRef | null
|
||||||
motherNationalNumber: string | null
|
motherNationalNumber: string | null
|
||||||
motherBovineType: BovineTypeRef | null
|
motherBovineType: BovineTypeRef | null
|
||||||
fatherNationalNumber: string | null
|
fatherNationalNumber: string | null
|
||||||
fatherBovineType: BovineTypeRef | null
|
fatherBovineType: BovineTypeRef | null
|
||||||
|
movements: BovineMovementData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -107,6 +202,12 @@ const route = useRoute()
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const bovine = ref<BovinePassportData | null>(null)
|
const bovine = ref<BovinePassportData | null>(null)
|
||||||
|
const buildings = ref<BuildingData[]>([])
|
||||||
|
const newMovementBuildingId = ref<string | number | null>(null)
|
||||||
|
const newMovementCaseId = ref<string | number | null>(null)
|
||||||
|
const isSubmittingMovement = ref(false)
|
||||||
|
const movementSubmitted = ref(false)
|
||||||
|
const movementFilters = ref({ building: '', case: '' })
|
||||||
|
|
||||||
const bovineId = computed(() => {
|
const bovineId = computed(() => {
|
||||||
const raw = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
const raw = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
@@ -128,8 +229,93 @@ const formatDate = (date: string | null | undefined) => {
|
|||||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildingOptions = computed(() =>
|
||||||
|
buildings.value.map(b => ({ value: b.id, label: b.label }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const caseOptions = computed(() => {
|
||||||
|
const building = buildings.value.find(b => b.id === Number(newMovementBuildingId.value))
|
||||||
|
if (!building?.buildingCases) return []
|
||||||
|
return [...building.buildingCases]
|
||||||
|
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
|
||||||
|
.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(newMovementBuildingId, () => {
|
||||||
|
newMovementCaseId.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
const movementColumns = [
|
||||||
|
{ key: 'building', label: 'Bâtiment' },
|
||||||
|
{ key: 'case', label: 'Case' },
|
||||||
|
{ key: 'enteredAt', label: 'Du' },
|
||||||
|
{ key: 'leftAt', label: 'Au' },
|
||||||
|
{ key: 'duration', label: 'Durée' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const movementEndDate = (movement: BovineMovementData): string | null => {
|
||||||
|
return movement.leftAt ?? bovine.value?.exitedAt ?? bovine.value?.exitDate ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (movement: BovineMovementData): string => {
|
||||||
|
const start = new Date(movement.enteredAt)
|
||||||
|
if (isNaN(start.getTime())) return '—'
|
||||||
|
const endRaw = movementEndDate(movement)
|
||||||
|
const end = endRaw ? new Date(endRaw) : new Date()
|
||||||
|
if (isNaN(end.getTime())) return '—'
|
||||||
|
const days = Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86_400_000))
|
||||||
|
return `${days} j`
|
||||||
|
}
|
||||||
|
|
||||||
|
const movementRows = computed(() => {
|
||||||
|
const list = bovine.value?.movements ?? []
|
||||||
|
return list.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
building: m.buildingCase?.building?.label ?? m.building?.label ?? '—',
|
||||||
|
case: m.buildingCase?.caseNumber != null ? `Case ${m.buildingCase.caseNumber}` : '—',
|
||||||
|
enteredAt: formatDate(m.enteredAt),
|
||||||
|
leftAt: m.leftAt ? formatDate(m.leftAt) : null,
|
||||||
|
duration: formatDuration(m)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredMovementRows = computed(() => {
|
||||||
|
const buildingFilter = movementFilters.value.building.trim().toLowerCase()
|
||||||
|
const caseFilter = movementFilters.value.case.trim().toLowerCase()
|
||||||
|
return movementRows.value.filter(row => {
|
||||||
|
if (buildingFilter && !row.building.toLowerCase().includes(buildingFilter)) return false
|
||||||
|
if (caseFilter && !row.case.toLowerCase().includes(caseFilter)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitMovement = async () => {
|
||||||
|
if (!newMovementCaseId.value || bovineId.value === null) return
|
||||||
|
isSubmittingMovement.value = true
|
||||||
|
try {
|
||||||
|
await api.post('bovine_movements', {
|
||||||
|
bovine: `/api/bovines/${bovineId.value}`,
|
||||||
|
buildingCase: `/api/building_cases/${newMovementCaseId.value}`
|
||||||
|
}, { toastSuccessMessage: 'Mouvement enregistré' })
|
||||||
|
bovine.value = await api.get<BovinePassportData>(`bovines/${bovineId.value}`)
|
||||||
|
newMovementBuildingId.value = null
|
||||||
|
newMovementCaseId.value = null
|
||||||
|
movementSubmitted.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmittingMovement.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (bovineId.value === null) return
|
if (bovineId.value === null) return
|
||||||
bovine.value = await api.get<BovinePassportData>(`bovines/${bovineId.value}`)
|
const [bovineData, buildingList] = await Promise.all([
|
||||||
|
api.get<BovinePassportData>(`bovines/${bovineId.value}`),
|
||||||
|
getBuildingList()
|
||||||
|
])
|
||||||
|
bovine.value = bovineData
|
||||||
|
buildings.value = buildingList
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
33
migrations/Version20260506141455.php
Normal file
33
migrations/Version20260506141455.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260506141455 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create bovine_movement table to track internal building/case history.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/Command/BackfillBovineMovementsCommand.php
Normal file
93
src/Command/BackfillBovineMovementsCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ use ApiPlatform\Metadata\Post;
|
|||||||
use App\Repository\BovineRepository;
|
use App\Repository\BovineRepository;
|
||||||
use App\State\Bovin\BovineProcessor;
|
use App\State\Bovin\BovineProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Context;
|
use Symfony\Component\Serializer\Attribute\Context;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
@@ -153,6 +155,19 @@ class Bovine
|
|||||||
#[ApiProperty(readableLink: true)]
|
#[ApiProperty(readableLink: true)]
|
||||||
private ?BovineType $fatherBovineType = null;
|
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
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -395,6 +410,31 @@ class Bovine
|
|||||||
return $this;
|
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\PrePersist]
|
||||||
#[ORM\PreUpdate]
|
#[ORM\PreUpdate]
|
||||||
public function refreshAgeMonths(): void
|
public function refreshAgeMonths(): void
|
||||||
|
|||||||
124
src/Entity/BovineMovement.php
Normal file
124
src/Entity/BovineMovement.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Repository/BovineMovementRepository.php
Normal file
34
src/Repository/BovineMovementRepository.php
Normal 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()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/State/Bovin/BovineMovementProcessor.php
Normal file
44
src/State/Bovin/BovineMovementProcessor.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user