feat: inventaire bovins #49

Merged
tristan merged 7 commits from feat/bovine-inventory into develop 2026-04-24 07:53:06 +00:00
19 changed files with 704 additions and 21 deletions

View File

@@ -19,7 +19,10 @@
v-for="(item, index) in paginatedItems"
:key="item.id ?? index"
class="grid gap-6 px-4 py-3 text-sm border-t border-slate-200"
:class="rowClickable ? 'hover:bg-slate-50 cursor-pointer' : ''"
:class="[
rowClickable ? 'hover:bg-slate-50 cursor-pointer' : '',
rowClass ? rowClass(item) : ''
]"
:style="{ gridTemplateColumns: gridCols }"
:role="rowClickable ? 'button' : undefined"
:tabindex="rowClickable ? 0 : undefined"
@@ -83,12 +86,12 @@
<nav aria-label="Pagination" class="flex items-center gap-1">
<button
type="button"
class="h-10 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
:disabled="currentPage <= 1"
aria-label="Page précédente"
@click="goToPage(currentPage - 1)"
>
Prev
Précédent
</button>
<template v-for="(entry, i) in visiblePages" :key="`${typeof entry}-${entry}-${i}`">
@@ -113,12 +116,12 @@
<button
type="button"
class="h-10 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
:disabled="currentPage >= totalPages"
aria-label="Page suivante"
@click="goToPage(currentPage + 1)"
>
Next
Suivant
</button>
</nav>
</div>
@@ -145,6 +148,7 @@ const props = withDefaults(defineProps<{
showActions?: boolean
emptyMessage?: string
loading?: boolean
rowClass?: (item: T) => string | undefined
}>(), {
totalItems: undefined,
page: 1,
@@ -153,7 +157,8 @@ const props = withDefaults(defineProps<{
rowClickable: false,
showActions: false,
emptyMessage: 'Aucune donnée',
loading: false
loading: false,
rowClass: undefined
})
const emit = defineEmits<{

View File

@@ -0,0 +1,108 @@
<template>
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
</label>
<input
:id="id"
v-maska="'##/##/####'"
type="text"
inputmode="numeric"
:value="displayValue"
:placeholder="placeholder"
:disabled="disabled"
v-bind="attrs"
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
:class="[
sizeClass,
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-text',
inputClass
]"
@input="onInput"
/>
</div>
</template>
<script setup lang="ts">
import { vMaska } from 'maska/vue'
import { computed, ref, useAttrs, watch } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
label?: string
modelValue: string | null | undefined
placeholder?: string
disabled?: boolean
size?: 'default' | 'compact'
wrapperClass?: string
labelClass?: string
inputClass?: string
}>(),
{
placeholder: 'JJ/MM/AAAA',
disabled: false,
size: 'default',
wrapperClass: '',
labelClass: '',
inputClass: ''
}
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const attrs = useAttrs()
const toDisplay = (iso: string | null | undefined): string => {
if (!iso) return ''
const parts = iso.split('-')
if (parts.length !== 3) return ''
const [year, month, day] = parts
if (year.length !== 4 || month.length !== 2 || day.length !== 2) return ''
return `${day}/${month}/${year}`
}
const toIso = (display: string): string | null => {
const match = display.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
if (!match) return null
const [, day, month, year] = match
return `${year}-${month}-${day}`
}
const displayValue = ref(toDisplay(props.modelValue))
watch(() => props.modelValue, (newIso) => {
const expected = toDisplay(newIso)
if (expected !== displayValue.value) {
displayValue.value = expected
}
})
const isEmpty = computed(() => !displayValue.value)
const sizeClass = computed(() =>
props.size === 'compact'
? 'text-sm h-8 font-normal normal-case tracking-normal'
: 'text-xl py-[6px]'
)
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
displayValue.value = target.value
if (target.value === '') {
emit('update:modelValue', '')
return
}
const iso = toIso(target.value)
emit('update:modelValue', iso ?? '')
}
</script>

View File

@@ -18,9 +18,9 @@
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
<card-link link="/" iconName="mdi:cow">
<card-link link="/inventory" iconName="mdi:cow">
<template #label>
PASSEPORT<br>DU BOVIN
INVENTAIRE<br>BOVINS
</template>
</card-link>
</div>

View File

@@ -59,7 +59,7 @@
/>
</template>
<template #header-arrivalDate>
<UiDateInput v-model="arrivalDateFilter" size="compact" />
<UiDateMaskedInput v-model="arrivalDateFilter" placeholder="Date d'arrivée" size="compact" />
</template>
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}

View File

@@ -0,0 +1,217 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="router.push('/')"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
<button
v-if="auth.isAdmin"
type="button"
:disabled="syncing"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2 disabled:cursor-not-allowed disabled:opacity-60"
@click="syncInventory"
>
<Icon name="mdi:sync" size="28" :class="syncing ? 'animate-spin' : ''" />
Rafraîchir
</button>
</div>
<div class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
:row-class="rowClass"
>
<template #header-nationalNumber>
<UiTextInput
v-model="filters.nationalNumber"
placeholder="N° National"
size="compact"
/>
</template>
<template #header-workNumber>
<UiTextInput
v-model="filters.workNumber"
placeholder="N° Travail"
size="compact"
/>
</template>
<template #header-sex>
<UiSelect
v-model="filters.sex"
placeholder="Sexe"
:options="sexOptions"
size="compact"
/>
</template>
<template #header-birthDate>
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
</template>
<template #header-breedCode>
<UiTextInput
v-model="filters.breedCode"
placeholder="Race"
size="compact"
/>
</template>
<template #header-arrivalDate>
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
</template>
<template #header-buildingCase.building.label>
<UiTextInput :model-value="''" placeholder="Bâtiment" size="compact" disabled />
</template>
<template #header-buildingCase.caseNumber>
<UiTextInput :model-value="''" placeholder="Case" size="compact" disabled />
</template>
<template #header-age>
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
</template>
<template #cell-birthDate="{ item }">
{{ formatDate(item.birthDate) }}
</template>
<template #cell-age="{ item }">
{{ formatAgeLabel(item.ageMonths) }}
</template>
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.buildingCase?.building?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
</template>
</UiDataTable>
</div>
</div>
</template>
<script setup lang="ts">
import type { BovineData } from '~/services/dto/bovine-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { formatAgeLabel } from '~/utils/bovine-age'
const router = useRouter()
const auth = useAuthStore()
const api = useApi()
const toast = useToast()
interface SyncResult {
created: number
updated: number
exited: number
total: number
}
const syncing = ref(false)
const syncInventory = async () => {
if (syncing.value) return
const confirmed = window.confirm(
"Lancer la synchronisation avec EDNOTIF ?\n\nLes bovins absents de la réponse seront marqués comme sortis."
)
if (!confirmed) return
syncing.value = true
try {
const result = await api.post<SyncResult>('bovines/sync-inventory')
toast.success({
title: 'Inventaire synchronisé',
message: `Créés : ${result.created} · Mis à jour : ${result.updated} · Sortis : ${result.exited} · Total EDNOTIF : ${result.total}`
})
reload()
} catch {
// error toast already handled by useApi onResponseError
} finally {
syncing.value = false
}
}
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<BovineData>(
'bovines',
{
'exists[exitedAt]': 'false',
nationalNumber: '',
workNumber: '',
breedCode: '',
sex: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '',
'birthDate[after]': '',
'birthDate[strictly_before]': ''
}
)
const sexOptions = [
{ value: 'M', label: 'Mâle' },
{ value: 'F', label: 'Femelle' }
]
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const singleDateFilter = (afterKey: string, beforeKey: string) =>
computed<string>({
get: () => (filters.value[afterKey] as string) ?? '',
set: (value: string) => {
if (!value) {
filters.value[afterKey] = ''
filters.value[beforeKey] = ''
return
}
filters.value[afterKey] = value
filters.value[beforeKey] = addOneDay(value)
}
})
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
const columns = [
{ key: 'nationalNumber', label: 'N° National', width: '160px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '120px' },
{ key: 'age', label: 'Age', width: '110px' },
{ key: 'breedCode', label: 'Race' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1.5fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '80px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '120px' }
]
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const rowClass = (item: BovineData): string => {
if (item.ageMonths === null || item.ageMonths === undefined) return ''
if (item.ageMonths >= 24) return 'bg-red-100 hover:bg-red-200'
if (item.ageMonths >= 22) return 'bg-orange-100 hover:bg-orange-200'
return ''
}
onMounted(reload)
</script>

View File

@@ -24,8 +24,9 @@
/>
</template>
<template #header-receptionDate>
<UiDateInput
<UiDateMaskedInput
v-model="receptionDateFilter"
placeholder="Date"
size="compact"
/>
</template>
@@ -119,7 +120,7 @@ const receptionDateFilter = computed<string>({
const columns = [
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
{ key: 'receptionDate', label: 'Date et heure', width: '120px' },
{ key: 'receptionDate', label: 'Date', width: '120px' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'receptionType.label', label: 'Type réception', width: '0.9fr' },

View File

@@ -18,7 +18,7 @@
@row-click="goToReception"
>
<template #header-receptionDate>
<UiDateInput v-model="receptionDateFilter" size="compact" />
<UiDateMaskedInput v-model="receptionDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-supplier.name>
<UiTextInput
@@ -122,7 +122,7 @@ const receptionDateFilter = computed<string>({
})
const columns = [
{ key: 'receptionDate', label: 'Date et heure', width: '120px' },
{ key: 'receptionDate', label: 'Date', width: '120px' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'receptionType.label', label: 'Type réception', width: '1.1fr' },

View File

@@ -24,7 +24,7 @@
/>
</template>
<template #header-shipmentDate>
<UiDateInput v-model="shipmentDateFilter" size="compact" />
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-customer.name>
<UiTextInput

View File

@@ -18,7 +18,7 @@
@row-click="goToShipment"
>
<template #header-shipmentDate>
<UiDateInput v-model="shipmentDateFilter" size="compact" />
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-customer.name>
<UiTextInput
@@ -134,7 +134,7 @@ const shipmentDateFilter = computed<string>({
})
const columns = [
{ key: 'shipmentDate', label: 'Date et heure', width: '120px' },
{ key: 'shipmentDate', label: 'Date', width: '120px' },
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'shipmentType.label', label: "Type d'expé.", width: '1.1fr' },

View File

@@ -1,10 +1,22 @@
export interface BovineBuildingCaseRef {
caseNumber: number | null
building: { label: string } | null
}
export interface BovineData {
id: number
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
buildingCase: string | null
exitDate: string | null
buildingCase: BovineBuildingCaseRef | null
supplier: string | null
workNumber: string | null
birthDate: string | null
breedCode: string | null
sex: string | null
ageMonths: number | null
exitedAt: string | null
}
export type BovinePayload = {

View File

@@ -0,0 +1,10 @@
export const formatAgeLabel = (months: number | null | undefined): string => {
if (months === null || months === undefined) return '—'
const years = Math.floor(months / 12)
const remaining = months % 12
let label = ''
if (years > 0) label = `${years} an${years > 1 ? 's' : ''}`
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} mois`
if (!label) label = '< 1 mois'
return label
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260422155300 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD sex VARCHAR(1) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD exited_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP sex');
$this->addSql('ALTER TABLE bovine DROP exited_at');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260423062250 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD exit_date DATE DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP exit_date');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260424074454 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD age_months INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP age_months');
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Bovin\BovineSyncInventoryProcessor;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/bovines/sync-inventory',
openapi: new OpenApiOperation(
summary: "Synchronise l'inventaire bovin local avec EDNOTIF.",
description: 'Upsert des bovins par numéro national ; marque comme sortis ceux absents de la réponse EDNOTIF.',
tags: ['Bovines'],
),
security: "is_granted('ROLE_ADMIN')",
input: false,
output: self::class,
processor: BovineSyncInventoryProcessor::class,
read: false,
),
]
)]
final class BovineSyncInventoryResult
{
#[ApiProperty(identifier: true)]
public string $id = 'current';
public int $created = 0;
public int $updated = 0;
public int $exited = 0;
public int $total = 0;
}

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
@@ -20,14 +22,19 @@ use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'bovine')]
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
#[ApiFilter(SearchFilter::class, properties: [
'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial',
'breedCode' => 'ipartial',
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate'])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
#[ApiResource(
operations: [
new Get(
@@ -76,6 +83,7 @@ class Bovine
#[ORM\ManyToOne(inversedBy: 'bovines')]
#[Groups(['bovine:read', 'bovine:write'])]
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
@@ -95,6 +103,24 @@ class Bovine
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $breedCode = null;
#[ORM\Column(length: 1, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $sex = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?int $ageMonths = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitDate = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitedAt = null;
public function getId(): ?int
{
return $this->id;
@@ -195,4 +221,66 @@ class Bovine
return $this;
}
public function getSex(): ?string
{
return $this->sex;
}
public function setSex(?string $sex): static
{
$this->sex = $sex;
return $this;
}
public function getExitDate(): ?DateTimeImmutable
{
return $this->exitDate;
}
public function setExitDate(?DateTimeImmutable $exitDate): static
{
$this->exitDate = $exitDate;
return $this;
}
public function getExitedAt(): ?DateTimeImmutable
{
return $this->exitedAt;
}
public function setExitedAt(?DateTimeImmutable $exitedAt): static
{
$this->exitedAt = $exitedAt;
return $this;
}
public function getAgeMonths(): ?int
{
return $this->ageMonths;
}
public function setAgeMonths(?int $ageMonths): static
{
$this->ageMonths = $ageMonths;
return $this;
}
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function refreshAgeMonths(): void
{
if (null === $this->birthDate) {
$this->ageMonths = null;
return;
}
$diff = $this->birthDate->diff(new DateTimeImmutable());
$this->ageMonths = ($diff->y * 12) + $diff->m;
}
}

View File

@@ -36,7 +36,7 @@ class Building
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Groups(['building:read', 'building:summary', 'reception:read'])]
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
private string $label = '';
#[ORM\Column(length: 50)]

View File

@@ -43,7 +43,7 @@ class BuildingCase
private ?int $id = null;
#[ORM\Column]
#[Groups(['building:read', 'building_case:read'])]
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
#[SerializedName('caseNumber')]
private ?int $case_number = null;
@@ -62,7 +62,7 @@ class BuildingCase
private Collection $id_case_position;
#[ORM\ManyToOne(inversedBy: 'buildingCases')]
#[Groups(['building_case:read'])]
#[Groups(['building_case:read', 'bovine:read'])]
#[SerializedName('building')]
private ?Building $id_building = null;

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
/**
* @implements ProcessorInterface<mixed, BovineSyncInventoryResult>
*/
final class BovineSyncInventoryProcessor implements ProcessorInterface
{
public function __construct(
private BovinApiInterface $bovinApi,
private EntityManagerInterface $em,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = [],
): BovineSyncInventoryResult {
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
$result = new BovineSyncInventoryResult();
$result->total = count($inventory->animals);
$existingByNationalNumber = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
}
$seen = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
continue;
}
$seen[$nationalNumber] = true;
if (isset($existingByNationalNumber[$nationalNumber])) {
$bovine = $existingByNationalNumber[$nationalNumber];
++$result->updated;
} else {
$bovine = new Bovine();
$bovine->setNationalNumber($nationalNumber);
$this->em->persist($bovine);
++$result->created;
}
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
}
$now = new DateTimeImmutable();
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
if (isset($seen[$nationalNumber])) {
continue;
}
if (null !== $bovine->getExitedAt()) {
continue;
}
$bovine->setExitedAt($now);
++$result->exited;
}
$this->em->flush();
return $result;
}
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
{
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBreedCode($identification->breedType);
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
}
$latestEntry = null;
$latestExit = null;
foreach ($animal->presencePeriods as $period) {
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
$latestEntry = $period->entry->date;
}
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
$latestExit = $period->exit->date;
}
}
$bovine->setArrivalDate($latestEntry);
$bovine->setExitDate($latestExit);
$bovine->refreshAgeMonths();
}
}