Compare commits

...

10 Commits

Author SHA1 Message Date
476502c91c feat : cause d'entrée bovin + confirmation EDNOTIF asynchrone + historique entrées
- Champ entryCause sur Bovine (enum App\Enum\CauseEntree : Achat/Naissance/PretOuPension)
- Sélecteur "Cause d'entrée" sur le formulaire de saisie (default Achat, required)
- ednotif_confirmed_at sur Bovine : timestamp set par le sync EDNOTIF la première
  fois qu'un bovin remonte dans getInventory. Backfill des bovins existants au
  jour de la migration.
- Inventaire (page + export + stats) filtre les bovins encore "en attente
  EDNOTIF" : ils n'apparaissent qu'une fois confirmés par le sync.
- getter getConfirmedBovineCount sur Reception, exposé en reception:read.
- Tableau Historique full-width sur /entry-exit listant les entrées validées,
  avec filtres de colonnes (numéro, date, fournisseur), compteur Confirmés/Saisis,
  et badge de statut "Confirmée" / "EDNOTIF en attente".
- Tableau récap de l'écran de saisie passé en useDataTableServerState pour
  bénéficier du loading et de la pagination serveur.
- Validation entrée : confirm window obligatoire, message renforcé en cas
  d'écart entre saisis et déclarés.
- Pattern projet "submitted" sur le formulaire d'ajout pour le visuel required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:29:46 +02:00
c64e0c7100 feat(front) : entrées et sorties en attente côte à côte
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:29:25 +02:00
348d7fc8f9 feat(front) : bouton Valider l'entrée avec confirmation si saisies incomplètes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:13:10 +02:00
38cedfccdf feat(front) : tableau récap des bovins saisis avec suppression
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:12:54 +02:00
69844bfebc feat(front) : logique Ajouter un bovin sur écran de saisie
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:11:24 +02:00
d71bd147d5 feat(front) : écran saisie entrée — layout header + formulaire
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:09:49 +02:00
ab1f9a3308 feat(front) : page liste entrée/sortie avec entrées en attente
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:05:24 +02:00
07d174398d feat(front) : renomme card CASES en Entrée/Sortie sur la home
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:04:11 +02:00
1328dcfdd7 feat(front) : ajout entryCompleted, registeredBovineCount, bovine.reception aux DTOs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:03:32 +02:00
9d3dbf98c1 fix : BovineProcessor utilise setBovineType avec auto-create
Le processor appelait setBreedCode() qui n'existe plus depuis la
migration vers la relation BovineType. Remplacé par setBovineType()
avec un resolveBovineType() qui findOneBy(code) ou crée un
placeholder réutilisable. Ajout de setSex() oublié au passage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:02:24 +02:00
15 changed files with 748 additions and 12 deletions

View File

@@ -153,7 +153,7 @@ const props = withDefaults(defineProps<{
totalItems: undefined,
page: 1,
perPage: 10,
perPageOptions: () => [10, 25, 50],
perPageOptions: () => [5, 10, 25, 50],
rowClickable: false,
showActions: false,
emptyMessage: 'Aucune donnée',

View File

@@ -0,0 +1,342 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-start gap-6 relative">
<Icon
@click="router.push('/entry-exit')"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500 absolute -left-[60px]"
/>
<div>
<h1 class="font-bold text-3xl uppercase text-primary-500">
Entrée bovins {{ reception?.identificationNumber ?? '' }}
</h1>
</div>
</div>
<p class="text-sm text-slate-600 mt-1 mb-8">
{{ reception?.supplier?.name ?? '—' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovinesTotal }}
</p>
<form
class="grid grid-cols-4 gap-x-16 gap-y-8 mb-12 items-end"
:class="{ submitted }"
@submit.prevent="addBovine"
>
<UiTextInput
v-model="form.nationalNumber"
label="Numéro national"
required
/>
<UiSelect
v-model="form.entryCause"
label="Cause d'entrée"
:options="entryCauseOptions"
required
/>
<UiDateMaskedInput
v-model="form.arrivalDate"
label="Date d'entrée"
/>
<UiSelect
v-model="form.supplierId"
label="Vendeur"
:options="supplierOptions"
/>
<UiSelect
v-model="form.buildingId"
label="Bâtiment"
:options="buildingOptions"
/>
<UiSelect
v-model="form.caseId"
label="Case"
:options="caseOptions"
:disabled="!form.buildingId"
/>
<UiNumberInput
v-model="form.receivedWeight"
label="Poids (kg)"
wrapperClass="flex-col"
labelClass="font-bold uppercase text-xl text-primary-700"
:min="1"
/>
<UiNumberInput
v-model="form.pricePerKg"
label="Prix au kilo (€)"
wrapperClass="flex-col"
labelClass="font-bold uppercase text-xl text-primary-700"
:min="0"
:step="0.01"
/>
</form>
<div class="flex justify-center mb-12">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
:disabled="isAdding"
:loading="isAdding"
@click="addBovine"
>
Ajouter
</UiButton>
</div>
<UiDataTable
v-model:page="recapPage"
v-model:per-page="recapPerPage"
:columns="recapColumns"
:items="savedBovines"
:total-items="savedBovinesTotal"
:loading="savedBovinesLoading"
:show-actions="true"
>
<template #cell-birthDate="{ item }">
{{ formatDate(item.birthDate) }}
</template>
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-finalPrice="{ item }">
{{ formatPrice(item.finalPrice) }}
</template>
<template #cell-pricePerKg="{ item }">
{{ formatPrice(item.pricePerKg) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.effectiveBuilding?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
</template>
<template #cell-bovineType.label="{ item }">
{{ item.bovineType?.label ?? '—' }}
</template>
<template #actions="{ item }">
<Icon
name="mdi:delete-outline"
size="24"
class="cursor-pointer text-red-500 hover:text-red-700"
@click="confirmDeleteBovine(item)"
/>
</template>
</UiDataTable>
<div class="flex justify-center mt-8">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
:disabled="savedBovinesTotal === 0 || isValidating"
:loading="isValidating"
@click="validateEntry"
>
Valider l'entrée
</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
import type { ReceptionData } from '~/services/dto/reception-data'
import type { BovineData } from '~/services/dto/bovine-data'
import type { SupplierData } from '~/services/dto/supplier-data'
import type { BuildingData } from '~/services/dto/building-data'
import { getSupplierList } from '~/services/supplier'
import { getBuildingList } from '~/services/building'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const route = useRoute()
const router = useRouter()
const api = useApi()
const receptionId = computed(() => Number(route.params.id))
const reception = ref<ReceptionData | null>(null)
const suppliers = ref<SupplierData[]>([])
const buildings = ref<BuildingData[]>([])
const {
items: savedBovines,
totalItems: savedBovinesTotal,
page: recapPage,
perPage: recapPerPage,
loading: savedBovinesLoading,
reload: reloadSavedBovines
} = useDataTableServerState<BovineData>(
'bovines',
{ reception: receptionId.value },
{ initialPerPage: 50 }
)
const isAdding = ref(false)
const isValidating = ref(false)
const submitted = ref(false)
const recapColumns = [
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
{ key: 'workNumber', label: 'N° Travail', width: '90px' },
{ key: 'bovineType.label', label: 'Race', width: '110px' },
{ key: 'sex', label: 'Sexe', width: '60px' },
{ key: 'birthDate', label: 'Né le', width: '90px' },
{ key: 'receivedWeight', label: 'Poids', width: '70px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' },
{ key: 'pricePerKg', label: 'Prix/kg', width: '80px' },
{ key: 'finalPrice', label: 'Prix total', width: '90px' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '60px' }
]
const formatDate = (date: string | null | undefined) => {
if (!date) return '—'
const d = new Date(date.replace(' ', 'T'))
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
const formatPrice = (price: number | null | undefined) => {
if (price === null || price === undefined) return '—'
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
const confirmDeleteBovine = async (bovine: BovineData) => {
const confirmed = window.confirm(`Supprimer le bovin ${bovine.nationalNumber} ?`)
if (!confirmed) return
await api.delete(`bovines/${bovine.id}`)
reloadSavedBovines()
}
const validateEntry = async () => {
if (savedBovinesTotal.value === 0 || isValidating.value) return
const message = savedBovinesTotal.value !== declaredCount.value
? `Attention : ${savedBovinesTotal.value} bovins saisis sur ${declaredCount.value} déclarés. Êtes-vous sûr de vouloir valider l'entrée ?`
: `Êtes-vous sûr de vouloir valider l'entrée ?`
if (!window.confirm(message)) return
isValidating.value = true
try {
await api.patch(`receptions/${receptionId.value}`, { entryCompleted: true })
router.push('/entry-exit')
} finally {
isValidating.value = false
}
}
type EntryCause = 'A' | 'N' | 'P'
interface FormState {
nationalNumber: string
entryCause: EntryCause
arrivalDate: string
supplierId: string | number | null
buildingId: string | number | null
caseId: string | number | null
receivedWeight: number | null
pricePerKg: number | null
}
const entryCauseOptions = [
{ value: 'A', label: 'Achat' },
{ value: 'N', label: 'Naissance' },
{ value: 'P', label: 'Prêt ou pension' }
]
const initialForm = (): FormState => ({
nationalNumber: '',
entryCause: 'A',
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
supplierId: reception.value?.supplier?.id ?? null,
buildingId: reception.value?.buildings?.[0]?.id ?? null,
caseId: null,
receivedWeight: null,
pricePerKg: null
})
const form = reactive<FormState>(initialForm())
const supplierOptions = computed(() =>
suppliers.value.map(s => ({ value: s.id, label: s.name }))
)
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(form.buildingId))
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(() => form.buildingId, (newVal, oldVal) => {
if (newVal !== oldVal) form.caseId = null
})
const declaredCount = computed(() => reception.value?.declaredBovineCount ?? 0)
const isFormValid = computed(() =>
form.nationalNumber.trim() !== ''
&& !!form.entryCause
)
const resetForm = () => {
Object.assign(form, initialForm())
}
const loadReception = async () => {
reception.value = await api.get<ReceptionData>(`receptions/${receptionId.value}`)
resetForm()
}
const focusFirstField = () => {
const el = document.querySelector<HTMLInputElement>('form input[type="text"]')
el?.focus()
}
const addBovine = async () => {
submitted.value = true
if (!isFormValid.value || isAdding.value) return
isAdding.value = true
try {
const payload: Record<string, unknown> = {
nationalNumber: form.nationalNumber.trim(),
entryCause: form.entryCause,
reception: `/api/receptions/${receptionId.value}`
}
if (form.receivedWeight !== null) payload.receivedWeight = form.receivedWeight
if (form.pricePerKg !== null) payload.pricePerKg = form.pricePerKg
if (form.arrivalDate) payload.arrivalDate = form.arrivalDate
if (form.supplierId !== null) payload.supplier = `/api/suppliers/${form.supplierId}`
if (form.caseId !== null) payload.buildingCase = `/api/building_cases/${form.caseId}`
await api.post<BovineData>('bovines', payload, {
headers: { 'Content-Type': 'application/ld+json' }
})
reloadSavedBovines()
resetForm()
submitted.value = false
await nextTick()
focusFirstField()
} finally {
isAdding.value = false
}
}
onMounted(async () => {
[suppliers.value, buildings.value] = await Promise.all([
getSupplierList(),
getBuildingList()
])
await loadReception()
reloadSavedBovines()
})
</script>

View File

@@ -0,0 +1,216 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-start gap-10 relative">
<Icon
@click="router.push('/')"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500 absolute -left-[60px]"
/>
<h1 class="font-bold text-3xl uppercase text-primary-500">Entrée / Sortie</h1>
</div>
<div class="mt-8 grid grid-cols-2 gap-8">
<section>
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées en attente</h2>
<UiDataTable
v-model:page="entryPage"
v-model:per-page="entryPerPage"
:columns="entryColumns"
:items="entries"
:total-items="totalEntries"
:loading="entriesLoading"
row-clickable
@row-click="goToEntry"
>
<template #cell-identificationNumber="{ item }">
{{ item.identificationNumber }}
</template>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
</template>
<template #cell-declaredCount="{ item }">
{{ item.declaredBovineCount ?? 0 }}
</template>
<template #cell-registeredBovineCount="{ item }">
{{ item.registeredBovineCount ?? 0 }}
</template>
</UiDataTable>
</section>
<section>
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties en attente</h2>
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
À venir
</div>
</section>
</div>
<section class="mt-12 mb-16">
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Historique</h2>
<UiDataTable
v-model:page="historyPage"
v-model:per-page="historyPerPage"
:columns="historyColumns"
:items="history"
:total-items="totalHistory"
:loading="historyLoading"
>
<template #header-identificationNumber>
<UiTextInput
v-model="historyFilters.identificationNumber"
placeholder="Numéro"
size="compact"
/>
</template>
<template #header-receptionDate>
<UiDateMaskedInput v-model="historyDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-supplier.name>
<UiTextInput
v-model="historyFilters['supplier.name']"
placeholder="Fournisseur"
size="compact"
/>
</template>
<template #header-registeredBovineCount>
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
</template>
<template #header-confirmedBovineCount>
<UiTextInput :model-value="''" placeholder="Confirmés" size="compact" disabled />
</template>
<template #header-status>
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
</template>
<template #cell-identificationNumber="{ item }">
{{ item.identificationNumber }}
</template>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
</template>
<template #cell-registeredBovineCount="{ item }">
{{ item.registeredBovineCount ?? 0 }}
</template>
<template #cell-confirmedBovineCount="{ item }">
{{ item.confirmedBovineCount ?? 0 }} / {{ item.registeredBovineCount ?? 0 }}
</template>
<template #cell-status="{ item }">
<span
v-if="(item.confirmedBovineCount ?? 0) >= (item.registeredBovineCount ?? 0) && (item.registeredBovineCount ?? 0) > 0"
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
>
Confirmée
</span>
<span
v-else
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
>
EDNOTIF en attente
</span>
</template>
</UiDataTable>
</section>
</div>
</template>
<script setup lang="ts">
import type { ReceptionData } from '~/services/dto/reception-data'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter()
const {
items: entries,
totalItems: totalEntries,
page: entryPage,
perPage: entryPerPage,
loading: entriesLoading,
reload
} = useDataTableServerState<ReceptionData>(
'receptions',
{
'isValid': 'true',
'entryCompleted': 'false',
'receptionType.code': 'BOVINS'
},
{ initialPerPage: 5 }
)
const {
items: history,
totalItems: totalHistory,
page: historyPage,
perPage: historyPerPage,
filters: historyFilters,
loading: historyLoading,
reload: reloadHistory
} = useDataTableServerState<ReceptionData>(
'receptions',
{
'isValid': 'true',
'entryCompleted': 'true',
'receptionType.code': 'BOVINS',
'identificationNumber': '',
'supplier.name': '',
'receptionDate[after]': '',
'receptionDate[strictly_before]': ''
},
{ initialPerPage: 10 }
)
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 historyDateFilter = computed<string>({
get: () => (historyFilters.value['receptionDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
historyFilters.value['receptionDate[after]'] = ''
historyFilters.value['receptionDate[strictly_before]'] = ''
return
}
historyFilters.value['receptionDate[after]'] = value
historyFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
}
})
const entryColumns = [
{ key: 'identificationNumber', label: 'Numéro', width: '80px' },
{ key: 'receptionDate', label: 'Date', width: '75px' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1fr' },
{ key: 'declaredCount', label: 'Déclarés', width: '85px' },
{ key: 'registeredBovineCount', label: 'Saisis', width: '50px' }
]
const historyColumns = [
{ key: 'identificationNumber', label: 'Numéro', width: '110px' },
{ key: 'receptionDate', label: 'Date', width: '110px' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1fr' },
{ key: 'registeredBovineCount', label: 'Saisis', width: '80px' },
{ key: 'confirmedBovineCount', label: 'Confirmés', width: '110px' },
{ key: 'status', label: 'Statut', width: '170px' }
]
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date.replace(' ', 'T'))
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const goToEntry = (reception: ReceptionData) => {
router.push(`/entry-exit/entry/${reception.id}`)
}
onMounted(() => {
reload()
reloadHistory()
})
</script>

View File

@@ -15,7 +15,11 @@
EXPÉDITIONS<br>EN ATTENTE
</template>
</card-link>
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
<card-link link="/entry-exit" iconName="mdi:swap-horizontal-bold">
<template #label>
Entrée<br>Sortie
</template>
</card-link>
<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="/inventory" iconName="mdi:cow">

View File

@@ -247,6 +247,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
'bovines',
{
'exists[exitedAt]': 'false',
'exists[ednotifConfirmedAt]': 'true',
nationalNumber: '',
workNumber: '',
'bovineType.label': '',

View File

@@ -25,6 +25,9 @@ export interface BovineData {
sex: string | null
ageMonths: number | null
exitedAt: string | null
reception?: string | null
entryCause?: 'A' | 'N' | 'P' | null
ednotifConfirmedAt?: string | null
}
export type BovinePayload = {
@@ -34,4 +37,6 @@ export type BovinePayload = {
arrivalDate?: string | null
buildingCase?: string | null
supplier?: string | null
reception?: string | null
entryCause?: 'A' | 'N' | 'P' | null
}

View File

@@ -18,6 +18,10 @@ export interface ReceptionData {
receptionDate: string
currentStep: number
isValid: boolean
entryCompleted?: boolean
registeredBovineCount?: number
confirmedBovineCount?: number
declaredBovineCount?: number
receptionType?: ReceptionTypeData | null
merchandiseType?: MerchandiseTypeData | null
merchandiseDetail?: string | null
@@ -63,6 +67,7 @@ export type ReceptionPayload = {
receptionDate?: string
currentStep?: number
isValid?: boolean
entryCompleted?: boolean
receptionType?: string | null
merchandiseType?: string | null
merchandiseDetail?: string | null

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260429101011 extends AbstractMigration
{
public function getDescription(): string
{
return "Ajout de la cause d'entrée (enum CauseEntree EDNOTIF) sur bovine.";
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine ADD entry_cause VARCHAR(1) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine DROP entry_cause');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260429143822 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajout de bovine.ednotif_confirmed_at (timestamp de confirmation EDNOTIF par le sync inventory).';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine ADD ednotif_confirmed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
// Backfill : les bovins déjà en BDD ont été synchronisés depuis EDNOTIF
// historiquement (commande sync-inventory), on les considère confirmés.
// Les nouveaux bovins créés via le workflow entrée auront `NULL` par
// défaut et seront confirmés au prochain sync.
$this->addSql('UPDATE bovine SET ednotif_confirmed_at = NOW() WHERE ednotif_confirmed_at IS NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine DROP ednotif_confirmed_at');
}
}

View File

@@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\CauseEntree;
use App\Repository\BovineRepository;
use App\State\Bovin\BovineProcessor;
use DateTimeImmutable;
@@ -38,7 +39,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
'reception' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt', 'ednotifConfirmedAt'])]
#[ApiResource(
order: ['birthDate' => 'ASC'],
operations: [
@@ -106,6 +107,10 @@ class Bovine
#[ApiProperty(readableLink: false)]
private ?Reception $reception = null;
#[ORM\Column(type: 'string', length: 1, nullable: true, enumType: CauseEntree::class)]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?CauseEntree $entryCause = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
@@ -147,6 +152,11 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitedAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i:s'])]
private ?DateTimeImmutable $ednotifConfirmedAt = null;
public function getId(): ?int
{
return $this->id;
@@ -235,6 +245,18 @@ class Bovine
return $this;
}
public function getEntryCause(): ?CauseEntree
{
return $this->entryCause;
}
public function setEntryCause(?CauseEntree $entryCause): static
{
$this->entryCause = $entryCause;
return $this;
}
public function getBuilding(): ?Building
{
return $this->building;
@@ -341,6 +363,18 @@ class Bovine
return $this;
}
public function getEdnotifConfirmedAt(): ?DateTimeImmutable
{
return $this->ednotifConfirmedAt;
}
public function setEdnotifConfirmedAt(?DateTimeImmutable $ednotifConfirmedAt): static
{
$this->ednotifConfirmedAt = $ednotifConfirmedAt;
return $this;
}
public function getAgeMonths(): ?int
{
return $this->ageMonths;

View File

@@ -301,6 +301,32 @@ class Reception
return $this->bovines->count();
}
#[Groups(['reception:read'])]
public function getConfirmedBovineCount(): int
{
$count = 0;
foreach ($this->bovines as $bovine) {
if (null !== $bovine->getEdnotifConfirmedAt()) {
++$count;
}
}
return $count;
}
#[Groups(['reception:read'])]
public function getDeclaredBovineCount(): int
{
$fromTypes = 0;
foreach ($this->bovines_types as $rb) {
$fromTypes += (int) ($rb->getQuantity() ?? 0);
}
$fromOther = is_numeric($this->bovineDetail) ? (int) $this->bovineDetail : 0;
return $fromTypes + $fromOther;
}
#[Groups(['reception:read'])]
public function getReceptionDate(): ?DateTimeImmutable
{

27
src/Enum/CauseEntree.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* Cause d'une entrée de bovin sur l'exploitation (opération `IpBCreateEntree`).
*
* Source : `resources/ednotif-ws/CauseEntree.XSD` + doc IPG Table 9.
* Le `.value` est le code IPG transmis dans le payload SOAP.
*
* Note : duplique l'enum `Malio\EdnotifBundle\Bovin\Enum\CauseEntree` (pas
* encore présente dans la release installée v0.0.6). À remplacer par l'import
* bundle quand une version embarquant l'enum sera publiée.
*/
enum CauseEntree: string
{
/** Entrée par achat. */
case Achat = 'A';
/** Entrée par naissance. */
case Naissance = 'N';
/** Entrée par prêt ou pension. */
case PretOuPension = 'P';
}

View File

@@ -35,6 +35,7 @@ final class BovineRepository extends ServiceEntityRepository
{
$qb = $this->createQueryBuilder('b')
->where('b.exitedAt IS NULL')
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
->orderBy('b.birthDate', 'ASC')
;
@@ -81,6 +82,7 @@ final class BovineRepository extends ServiceEntityRepository
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
)
->where('b.exitedAt IS NULL')
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
;
if (null !== $buildingCaseId) {

View File

@@ -7,6 +7,8 @@ namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Bovine;
use App\Entity\BovineType;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Throwable;
@@ -15,6 +17,7 @@ final class BovineProcessor implements ProcessorInterface
{
public function __construct(
private readonly BovinApiInterface $bovinApi,
private readonly EntityManagerInterface $em,
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
) {}
@@ -41,28 +44,35 @@ final class BovineProcessor implements ProcessorInterface
return;
}
$bovine->setSex($identification->sex);
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setBreedCode($this->normalizeBreedCode($identification->breedType));
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
} catch (Throwable) {
// External service unavailable — persist bovine without enrichment.
}
}
private function normalizeBreedCode(mixed $breedType): ?string
/**
* Trouve un BovineType par code, sinon en crée un placeholder
* (l'admin pourra le renommer ensuite dans /admin/bovin/bovin-list).
*/
private function resolveBovineType(?string $code): ?BovineType
{
if (null === $breedType) {
if (null === $code || '' === $code) {
return null;
}
if (is_numeric($breedType)) {
return (string) $breedType;
$existing = $this->em->getRepository(BovineType::class)->findOneBy(['code' => $code]);
if (null !== $existing) {
return $existing;
}
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
return $matches[0];
}
$bovineType = new BovineType();
$bovineType->setCode($code);
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
$this->em->persist($bovineType);
return null;
return $bovineType;
}
}

View File

@@ -72,6 +72,12 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
// Marque la confirmation EDNOTIF si c'est la première fois qu'on
// voit ce bovin remonter dans l'inventaire.
if (null === $bovine->getEdnotifConfirmedAt()) {
$bovine->setEdnotifConfirmedAt(new DateTimeImmutable());
}
}
$now = new DateTimeImmutable();