diff --git a/frontend/pages/entry-exit/entry/[id].vue b/frontend/pages/entry-exit/entry/[id].vue index af6a7d8..6a0b2d7 100644 --- a/frontend/pages/entry-exit/entry/[id].vue +++ b/frontend/pages/entry-exit/entry/[id].vue @@ -1,6 +1,6 @@ - + - + {{ reception?.supplier?.name ?? '—' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovinesTotal }} - - - - - - - - - - - - - + - Ajouter - - + + + + + + + + + Ajouter + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ formatDate(item.birthDate) }} {{ formatDate(item.arrivalDate) }} - - {{ formatPrice(item.finalPrice) }} - - - {{ formatPrice(item.pricePerKg) }} - - - {{ item.effectiveBuilding?.label ?? '—' }} - - - {{ item.buildingCase?.caseNumber ?? '—' }} - {{ item.bovineType?.label ?? '—' }} + + {{ supplierName(item.supplier) }} + + + {{ entryCauseLabel(item.entryCause) }} + + + + Validé + + + En attente + + - + Number(route.params.id)) const reception = ref(null) const suppliers = ref([]) -const buildings = ref([]) const { items: savedBovines, totalItems: savedBovinesTotal, page: recapPage, perPage: recapPerPage, + filters: recapFilters, loading: savedBovinesLoading, reload: reloadSavedBovines } = useDataTableServerState( 'bovines', - { reception: receptionId.value }, - { initialPerPage: 50 } + { + reception: receptionId.value, + nationalNumber: '', + workNumber: '', + 'bovineType.label': '', + sex: '', + 'birthDate[after]': '', + 'birthDate[strictly_before]': '', + 'arrivalDate[after]': '', + 'arrivalDate[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 birthDateFilter = computed({ + get: () => (recapFilters.value['birthDate[after]'] as string) ?? '', + set: (value: string) => { + if (!value) { + recapFilters.value['birthDate[after]'] = '' + recapFilters.value['birthDate[strictly_before]'] = '' + return + } + recapFilters.value['birthDate[after]'] = value + recapFilters.value['birthDate[strictly_before]'] = addOneDay(value) + } +}) + +const arrivalDateFilter = computed({ + get: () => (recapFilters.value['arrivalDate[after]'] as string) ?? '', + set: (value: string) => { + if (!value) { + recapFilters.value['arrivalDate[after]'] = '' + recapFilters.value['arrivalDate[strictly_before]'] = '' + return + } + recapFilters.value['arrivalDate[after]'] = value + recapFilters.value['arrivalDate[strictly_before]'] = addOneDay(value) + } +}) + 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 isConsultationMode = computed(() => reception.value?.entryCompleted === true) + +const recapColumns = computed(() => { + const cols: Array<{ key: string; label: string; width: string }> = [ + { key: 'nationalNumber', label: 'N° National', width: '100px' }, + { key: 'workNumber', label: 'N° Travail', width: '110px' }, + { key: 'bovineType.label', label: 'Race', width: '1fr' }, + { key: 'sex', label: 'Sexe', width: '70px' }, + { key: 'birthDate', label: 'Né le', width: '75px' }, + { key: 'arrivalDate', label: 'Entrée le', width: '75px' }, + { key: 'supplier.name', label: 'Vendeur', width: '150px' }, + { key: 'entryCause', label: 'Cause', width: '100px' } + ] + if (isConsultationMode.value) { + cols.push({ key: 'ednotifConfirmedAt', label: 'EDNOTIF', width: '110px' }) + } + return cols +}) + +const entryCauseLabel = (code: string | null | undefined) => { + if (!code) return '—' + return entryCauseOptions.find(o => o.value === code)?.label ?? code +} + +const supplierName = (supplier: BovineData['supplier']) => { + if (supplier && typeof supplier === 'object') return supplier.name + return '—' +} const formatDate = (date: string | null | undefined) => { if (!date) return '—' @@ -193,11 +264,6 @@ const formatDate = (date: string | null | undefined) => { 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 @@ -231,10 +297,6 @@ interface FormState { entryCause: EntryCause arrivalDate: string supplierId: string | number | null - buildingId: string | number | null - caseId: string | number | null - receivedWeight: number | null - pricePerKg: number | null } const entryCauseOptions = [ @@ -247,11 +309,7 @@ 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 + supplierId: reception.value?.supplier?.id ?? null }) const form = reactive(initialForm()) @@ -260,30 +318,13 @@ 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 + && !!form.arrivalDate + && form.supplierId !== null ) const resetForm = () => { @@ -306,16 +347,13 @@ const addBovine = async () => { isAdding.value = true try { - const payload: Record = { + const payload = { nationalNumber: form.nationalNumber.trim(), entryCause: form.entryCause, + arrivalDate: form.arrivalDate, + supplier: `/api/suppliers/${form.supplierId}`, 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('bovines', payload, { headers: { 'Content-Type': 'application/ld+json' } @@ -332,10 +370,7 @@ const addBovine = async () => { } onMounted(async () => { - [suppliers.value, buildings.value] = await Promise.all([ - getSupplierList(), - getBuildingList() - ]) + suppliers.value = await getSupplierList() await loadReception() reloadSavedBovines() }) diff --git a/frontend/pages/entry-exit/index.vue b/frontend/pages/entry-exit/index.vue index 7beda27..f53c6d4 100644 --- a/frontend/pages/entry-exit/index.vue +++ b/frontend/pages/entry-exit/index.vue @@ -23,6 +23,25 @@ row-clickable @row-click="goToEntry" > + + + + + + + + + + + + + + + {{ item.identificationNumber }} @@ -35,81 +54,90 @@ {{ item.registeredBovineCount ?? 0 }} + + + Attente saisie + + + Attente EDNOTIF + + + + Entrées validées + + + + + + + + + + + + + + + + + + {{ item.identificationNumber }} + + + {{ formatDate(item.receptionDate) }} + + + {{ item.registeredBovineCount ?? 0 }} + + + {{ formatDate(item.validatedAt) }} + + + + Validée + + + + + + + Sorties en attente À venir - - - Historique - - - - - - - - - - - - - - - - - - - - - {{ item.identificationNumber }} - - - {{ formatDate(item.receptionDate) }} - - - {{ item.registeredBovineCount ?? 0 }} - - - {{ item.confirmedBovineCount ?? 0 }} / {{ item.registeredBovineCount ?? 0 }} - - - - Confirmée - - - EDNOTIF en attente - - - - + + Sorties validées + + À venir + + + @@ -124,38 +152,41 @@ const { totalItems: totalEntries, page: entryPage, perPage: entryPerPage, + filters: entryFilters, loading: entriesLoading, reload } = useDataTableServerState( 'receptions', { 'isValid': 'true', - 'entryCompleted': 'false', - 'receptionType.code': 'BOVINS' + 'exists[validatedAt]': 'false', + 'receptionType.code': 'BOVINS', + 'identificationNumber': '', + 'receptionDate[after]': '', + 'receptionDate[strictly_before]': '' }, { initialPerPage: 5 } ) const { - items: history, - totalItems: totalHistory, - page: historyPage, - perPage: historyPerPage, - filters: historyFilters, - loading: historyLoading, - reload: reloadHistory + items: validated, + totalItems: totalValidated, + page: validatedPage, + perPage: validatedPerPage, + filters: validatedFilters, + loading: validatedLoading, + reload: reloadValidated } = useDataTableServerState( 'receptions', { 'isValid': 'true', - 'entryCompleted': 'true', + 'exists[validatedAt]': 'true', 'receptionType.code': 'BOVINS', 'identificationNumber': '', - 'supplier.name': '', 'receptionDate[after]': '', 'receptionDate[strictly_before]': '' }, - { initialPerPage: 10 } + { initialPerPage: 5 } ) const addOneDay = (dateString: string): string => { @@ -164,37 +195,49 @@ const addOneDay = (dateString: string): string => { return next.toISOString().slice(0, 10) } -const historyDateFilter = computed({ - get: () => (historyFilters.value['receptionDate[after]'] as string) ?? '', +const entryDateFilter = computed({ + get: () => (entryFilters.value['receptionDate[after]'] as string) ?? '', set: (value: string) => { if (!value) { - historyFilters.value['receptionDate[after]'] = '' - historyFilters.value['receptionDate[strictly_before]'] = '' + entryFilters.value['receptionDate[after]'] = '' + entryFilters.value['receptionDate[strictly_before]'] = '' return } - historyFilters.value['receptionDate[after]'] = value - historyFilters.value['receptionDate[strictly_before]'] = addOneDay(value) + entryFilters.value['receptionDate[after]'] = value + entryFilters.value['receptionDate[strictly_before]'] = addOneDay(value) + } +}) + +const validatedDateFilter = computed({ + get: () => (validatedFilters.value['receptionDate[after]'] as string) ?? '', + set: (value: string) => { + if (!value) { + validatedFilters.value['receptionDate[after]'] = '' + validatedFilters.value['receptionDate[strictly_before]'] = '' + return + } + validatedFilters.value['receptionDate[after]'] = value + validatedFilters.value['receptionDate[strictly_before]'] = addOneDay(value) } }) const entryColumns = [ - { key: 'identificationNumber', label: 'Numéro', width: '80px' }, + { key: 'identificationNumber', label: 'Numéro', width: '75px' }, { 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' } + { key: 'declaredCount', label: 'Déclarés', width: '75px' }, + { key: 'registeredBovineCount', label: 'Saisis', width: '70px' }, + { key: 'status', label: 'Statut', width: '1fr' } ] -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 validatedColumns = [ + { key: 'identificationNumber', label: 'Numéro', width: '75px' }, + { key: 'receptionDate', label: 'Date', width: '75px' }, + { key: 'registeredBovineCount', label: 'Saisis', width: '50px' }, + { key: 'validatedAt', label: 'Validée le', width: '75px' }, + { key: 'status', label: 'Statut', width: '1fr' } ] -const formatDate = (date: string | null) => { +const formatDate = (date: string | null | undefined) => { if (!date) return '—' const d = new Date(date.replace(' ', 'T')) if (isNaN(d.getTime())) return date @@ -211,6 +254,6 @@ const goToEntry = (reception: ReceptionData) => { onMounted(() => { reload() - reloadHistory() + reloadValidated() }) diff --git a/frontend/services/dto/bovine-data.ts b/frontend/services/dto/bovine-data.ts index 146e109..de2425d 100644 --- a/frontend/services/dto/bovine-data.ts +++ b/frontend/services/dto/bovine-data.ts @@ -18,7 +18,7 @@ export interface BovineData { buildingCase: BovineBuildingCaseRef | null building: BovineBuildingRef | null effectiveBuilding: BovineBuildingRef | null - supplier: string | null + supplier: { id: number; name: string } | string | null workNumber: string | null birthDate: string | null bovineType: { id: number; label: string; code: string } | null diff --git a/frontend/services/dto/reception-data.ts b/frontend/services/dto/reception-data.ts index 71e5195..2cbe8dc 100644 --- a/frontend/services/dto/reception-data.ts +++ b/frontend/services/dto/reception-data.ts @@ -19,6 +19,7 @@ export interface ReceptionData { currentStep: number isValid: boolean entryCompleted?: boolean + validatedAt?: string | null registeredBovineCount?: number confirmedBovineCount?: number declaredBovineCount?: number diff --git a/migrations/Version20260430090000.php b/migrations/Version20260430090000.php new file mode 100644 index 0000000..26c33d4 --- /dev/null +++ b/migrations/Version20260430090000.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE reception ADD validated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE reception DROP validated_at'); + } +} diff --git a/src/Entity/Bovine.php b/src/Entity/Bovine.php index a3bc0d4..622bf00 100644 --- a/src/Entity/Bovine.php +++ b/src/Entity/Bovine.php @@ -118,6 +118,7 @@ class Bovine #[ORM\ManyToOne] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])] + #[ApiProperty(readableLink: true)] private ?Supplier $supplier = null; #[ORM\Column(length: 50, nullable: true)] diff --git a/src/Entity/Reception.php b/src/Entity/Reception.php index 3f7df37..e09dc03 100644 --- a/src/Entity/Reception.php +++ b/src/Entity/Reception.php @@ -6,6 +6,7 @@ namespace App\Entity; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; 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; @@ -32,6 +33,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; #[ORM\HasLifecycleCallbacks] #[ORM\Table(name: 'reception')] #[ApiFilter(BooleanFilter::class, properties: ['isValid', 'entryCompleted'])] +#[ApiFilter(ExistsFilter::class, properties: ['validatedAt'])] #[ApiFilter(SearchFilter::class, properties: [ 'identificationNumber' => 'ipartial', 'supplier.name' => 'ipartial', @@ -115,6 +117,10 @@ class Reception #[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])] private bool $entryCompleted = false; + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + #[Groups(['reception:read', 'reception-bovine:read'])] + private ?DateTimeImmutable $validatedAt = null; + #[ORM\Column(name: 'date_reception', type: 'datetime_immutable')] #[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])] #[Context( @@ -295,6 +301,45 @@ class Reception return $this; } + public function getValidatedAt(): ?DateTimeImmutable + { + return $this->validatedAt; + } + + public function setValidatedAt(?DateTimeImmutable $validatedAt): self + { + $this->validatedAt = $validatedAt; + + return $this; + } + + public function isFullyConfirmed(): bool + { + if ($this->bovines->isEmpty()) { + return false; + } + foreach ($this->bovines as $bovine) { + if (null === $bovine->getEdnotifConfirmedAt()) { + return false; + } + } + + return true; + } + + public function tryValidate(): void + { + if ($this->entryCompleted && null === $this->validatedAt && $this->isFullyConfirmed()) { + $this->validatedAt = new DateTimeImmutable(); + } + } + + #[ORM\PreUpdate] + public function onPreUpdate(): void + { + $this->tryValidate(); + } + #[Groups(['reception:read'])] public function getRegisteredBovineCount(): int { diff --git a/src/Entity/Supplier.php b/src/Entity/Supplier.php index 2e60707..7dcea41 100644 --- a/src/Entity/Supplier.php +++ b/src/Entity/Supplier.php @@ -54,11 +54,11 @@ class Supplier #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['supplier:read', 'reception:read'])] + #[Groups(['supplier:read', 'reception:read', 'bovine:read'])] private ?int $id = null; #[ORM\Column(length: 180)] - #[Groups(['supplier:read', 'reception:read', 'supplier:write'])] + #[Groups(['supplier:read', 'reception:read', 'supplier:write', 'bovine:read'])] private string $name = ''; #[ORM\Column(length: 180, nullable: true)] diff --git a/src/State/Bovin/BovineSyncInventoryProcessor.php b/src/State/Bovin/BovineSyncInventoryProcessor.php index f66159f..c1b1c93 100644 --- a/src/State/Bovin/BovineSyncInventoryProcessor.php +++ b/src/State/Bovin/BovineSyncInventoryProcessor.php @@ -52,7 +52,8 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface $existingByNationalNumber[$bovine->getNationalNumber()] = $bovine; } - $seen = []; + $seen = []; + $impactedReceptions = []; foreach ($inventory->animals as $animal) { $nationalNumber = $animal->identification?->bovin?->nationalNumber; if (null === $nationalNumber || '' === $nationalNumber) { @@ -77,9 +78,17 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface // voit ce bovin remonter dans l'inventaire. if (null === $bovine->getEdnotifConfirmedAt()) { $bovine->setEdnotifConfirmedAt(new DateTimeImmutable()); + $reception = $bovine->getReception(); + if (null !== $reception) { + $impactedReceptions[$reception->getId()] = $reception; + } } } + foreach ($impactedReceptions as $reception) { + $reception->tryValidate(); + } + $now = new DateTimeImmutable(); foreach ($existingByNationalNumber as $nationalNumber => $bovine) { if (isset($seen[$nationalNumber])) {
+
{{ reception?.supplier?.name ?? '—' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovinesTotal }}