Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions
5cc9b7855f chore: bump version to v0.0.101
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m20s
2026-05-13 15:50:34 +00:00
b61321c7b7 fix : wip cleanup
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-05-13 17:50:23 +02:00
gitea-actions
b130d44054 chore: bump version to v0.0.100
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m29s
2026-05-13 12:14:23 +00:00
dfa29ffc7a [#FER-26] Passeport du bovin (!53)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #53
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-13 12:14:16 +00:00
gitea-actions
cde2c4fbb7 chore: bump version to v0.0.99
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m23s
2026-05-07 06:39:49 +00:00
5552d98935 feat : amélioration du tableau bovin
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-05-07 08:39:39 +02:00
27 changed files with 914 additions and 609 deletions

View File

@@ -4,7 +4,9 @@
"Bash(npm run:*)",
"WebFetch(domain:geo.api.gouv.fr)",
"Bash(pip3 install:*)",
"Bash(python3 -c \":*)"
"Bash(python3 -c \":*)",
"Bash(make cache-clear *)",
"Bash(make test *)"
]
}
}

View File

@@ -1,5 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:9cad43df-2147-4989-b7a4-443067034884
2:0:ae622167-c834-4e7b-87a5-c1721036f5dc
3:0:f407a514-c6b4-4b26-9555-445a85892502
4:0:09e221b8-067a-488b-9c1d-4e155a333079
5:0:9d8c1ad3-2491-4642-964a-666003c14128
.</data>
</component>
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:f407a514-c6b4-4b26-9555-445a85892502&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:9cad43df-2147-4989-b7a4-443067034884&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>

5
.idea/ferme.iml generated
View File

@@ -155,6 +155,11 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/maennchen/zipstream-php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/markbaker/complex" />
<excludeFolder url="file://$MODULE_DIR$/vendor/markbaker/matrix" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpoffice/phpspreadsheet" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/simple-cache" />
<excludePattern pattern="reference.php" />
</content>
<orderEntry type="inheritedJdk" />

5
.idea/php.xml generated
View File

@@ -174,6 +174,11 @@
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />

145
.idea/workspace.xml generated
View File

@@ -4,12 +4,16 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : label age bovin">
<change beforePath="$PROJECT_DIR$/.claude/settings.local.json" beforeDir="false" afterPath="$PROJECT_DIR$/.claude/settings.local.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/db-forest-config.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/db-forest-config.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/ferme.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/ferme.iml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/php.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/php.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/bovine/[id].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/bovine/[id].vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/BovineMovement.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/BovineMovement.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/State/Bovin/BovineMovementProcessor.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/State/Bovin/BovineMovementProcessor.php" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -41,7 +45,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
<entry key="$PROJECT_DIR$" value="feat/entree-sortie" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -213,6 +217,11 @@
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
</include_path>
</component>
<component name="ProjectColorInfo">{
@@ -232,7 +241,9 @@
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "fix/FER-15-fix-droit-de-suppression-reception-expedition-util",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feat/vie-du-bovin",
"git.auto.fetch.suggestion.counter": "3",
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
@@ -274,7 +285,7 @@
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.32098.40" />
<option value="bundled-php-predefined-a98d8de5180a-022fa7b8ab75-com.jetbrains.php.sharedIndexes-PS-261.23567.149" />
</set>
</attachedChunks>
</component>
@@ -327,54 +338,16 @@
<workItem from="1773824491213" duration="24805000" />
<workItem from="1774275549972" duration="51000" />
<workItem from="1774276665015" duration="33750000" />
</task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" />
<created>1769529522614</created>
<option name="number" value="00037" />
<option name="presentableId" value="LOCAL-00037" />
<option name="project" value="LOCAL" />
<updated>1769529522614</updated>
</task>
<task id="LOCAL-00038" summary="feat : ajout du numéro identification des receptions et ajustement du bon de reception">
<option name="closed" value="true" />
<created>1769676223697</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<option name="project" value="LOCAL" />
<updated>1769676223697</updated>
</task>
<task id="LOCAL-00039" summary="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception">
<option name="closed" value="true" />
<created>1769700808988</created>
<option name="number" value="00039" />
<option name="presentableId" value="LOCAL-00039" />
<option name="project" value="LOCAL" />
<updated>1769700808988</updated>
</task>
<task id="LOCAL-00040" summary="feat : mise en place de composant UI pour les select, checkbox, date, text">
<option name="closed" value="true" />
<created>1769705141157</created>
<option name="number" value="00040" />
<option name="presentableId" value="LOCAL-00040" />
<option name="project" value="LOCAL" />
<updated>1769705141157</updated>
</task>
<task id="LOCAL-00041" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1769705240487</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1769705240487</updated>
</task>
<task id="LOCAL-00042" summary="feat : ajout de commentaire">
<option name="closed" value="true" />
<created>1769760766200</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1769760766200</updated>
<workItem from="1776755742205" duration="88521000" />
<workItem from="1777453284124" duration="86000" />
<workItem from="1777453433907" duration="337000" />
<workItem from="1777454070632" duration="17254000" />
<workItem from="1777540415843" duration="13205000" />
<workItem from="1777877316149" duration="29389000" />
<workItem from="1777982616362" duration="23909000" />
<workItem from="1778482021120" duration="1280000" />
<workItem from="1778656317630" duration="279000" />
<workItem from="1778664396844" duration="2576000" />
</task>
<task id="LOCAL-00043" summary="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception">
<option name="closed" value="true" />
@@ -720,7 +693,55 @@
<option name="project" value="LOCAL" />
<updated>1774543840891</updated>
</task>
<option name="localTasksCounter" value="86" />
<task id="LOCAL-00086" summary="fix : update icon entrée/sortie">
<option name="closed" value="true" />
<created>1777896558092</created>
<option name="number" value="00086" />
<option name="presentableId" value="LOCAL-00086" />
<option name="project" value="LOCAL" />
<updated>1777896558092</updated>
</task>
<task id="LOCAL-00087" summary="fix : wording">
<option name="closed" value="true" />
<created>1777983048277</created>
<option name="number" value="00087" />
<option name="presentableId" value="LOCAL-00087" />
<option name="project" value="LOCAL" />
<updated>1777983048278</updated>
</task>
<task id="LOCAL-00088" summary="fix : wording">
<option name="closed" value="true" />
<created>1777983581324</created>
<option name="number" value="00088" />
<option name="presentableId" value="LOCAL-00088" />
<option name="project" value="LOCAL" />
<updated>1777983581324</updated>
</task>
<task id="LOCAL-00089" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1778073247660</created>
<option name="number" value="00089" />
<option name="presentableId" value="LOCAL-00089" />
<option name="project" value="LOCAL" />
<updated>1778073247660</updated>
</task>
<task id="LOCAL-00090" summary="feat : amélioration du tableau bovin">
<option name="closed" value="true" />
<created>1778135981350</created>
<option name="number" value="00090" />
<option name="presentableId" value="LOCAL-00090" />
<option name="project" value="LOCAL" />
<updated>1778135981350</updated>
</task>
<task id="LOCAL-00091" summary="fix : label age bovin">
<option name="closed" value="true" />
<created>1778136373027</created>
<option name="number" value="00091" />
<option name="presentableId" value="LOCAL-00091" />
<option name="project" value="LOCAL" />
<updated>1778136373027</updated>
</task>
<option name="localTasksCounter" value="92" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -770,10 +791,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="feat : changelog update" />
<MESSAGE value="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" />
<MESSAGE value="fix : espacement et changelog" />
<MESSAGE value="fix : espacement" />
<MESSAGE value="fix : text" />
<MESSAGE value="feat : front page admin bovin et changelog" />
@@ -792,10 +809,14 @@
<MESSAGE value="feat : système de blocage utilisateur" />
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<MESSAGE value="fix : update icon entrée/sortie" />
<MESSAGE value="fix : wording" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : amélioration du tableau bovin" />
<MESSAGE value="fix : label age bovin" />
<option name="LAST_COMMIT_MESSAGE" value="fix : label age bovin" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>

View File

@@ -65,6 +65,7 @@ Ajouter dans le fichier .env du frontend
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
* [#FER-17] Ecran d'ajout de bovin
* [#FER-18] Mise à jour du tableau d'arrivage
* [#FER-26] Passeport du bovin
### Changed

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.98'
app.version: '0.0.101'

View File

@@ -0,0 +1,35 @@
<template>
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
v-for="tab in tabs"
:key="tab.key"
class="font-bold text-3xl uppercase px-12 cursor-pointer"
:class="[
modelValue === tab.key
? 'border-b-[6px] border-primary-500 text-primary-500'
: 'text-primary-500/50',
tab.error ? '!text-red-500 !border-red-500' : ''
]"
@click="emit('update:modelValue', tab.key)"
>
{{ tab.label }}
</h1>
</div>
</template>
<script setup lang="ts" generic="T extends string">
export interface UiTab<K extends string = string> {
key: K
label: string
error?: boolean
}
defineProps<{
modelValue: T
tabs: UiTab<T>[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: T): void
}>()
</script>

View File

@@ -0,0 +1,358 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between relative mb-10">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="goBack"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="font-bold text-3xl uppercase text-primary-500">Vie du bovin</h1>
</div>
<UiTabs
v-model="activeTab"
:tabs="tabs"
/>
<div v-if="auth.isBureau" v-show="activeTab === 'mouvement'">
<form :class="{ submitted: movementSubmitted }" @submit.prevent="submitMovement">
<div class="flex flex-cols-3 justify-between mb-10">
<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
/>
<UiDateInput
id="movement-date"
v-model="newMovementDate"
label="Date mouvement"
wrapper-class="w-[280px]"
required
/>
</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 class="mt-6">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Veau</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Sexe</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-b border-black px-2 py-1 text-center font-semibold text-sm">Date de naissance</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.nationalNumber) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.workNumber) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.sex) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.code) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.label) }}</div>
<div class="px-2 py-1 text-center">{{ formatDate(bovine?.birthDate) }}</div>
</div>
</div>
<div class="mt-9">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Père</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherNationalNumber) }}</div>
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.fatherNationalNumber)) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.code) }}</div>
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.label) }}</div>
</div>
</div>
<div class="mt-9">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Mère</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherNationalNumber) }}</div>
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.motherNationalNumber)) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.code) }}</div>
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.label) }}</div>
</div>
</div>
</div>
<div v-show="activeTab === 'sante'">
<div class="border-2 border-dashed border-primary-500 rounded-md py-16 text-center text-primary-500 font-bold uppercase text-2xl">
À venir
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { getBuildingList } from '~/services/building'
import type { BuildingData } from '~/services/dto/building-data'
import { useAuthStore } from '~/stores/auth'
useHead({ title: 'Vie du bovin' })
const auth = useAuthStore()
type BovineTab = 'mouvement' | 'passeport' | 'sante'
const tabs = computed(() => [
...(auth.isBureau ? [{ key: 'mouvement' as const, label: 'Mouvement' }] : []),
{ key: 'passeport' as const, label: 'Passeport bovin' },
{ key: 'sante' as const, label: 'Santé' }
])
const activeTab = ref<BovineTab>(auth.isBureau ? 'mouvement' : 'passeport')
interface BovineTypeRef {
id: number
label: 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
}
interface BovinePassportData {
id: number
nationalNumber: string
workNumber: string | null
sex: string | null
birthDate: string | null
exitedAt: string | null
exitDate: string | null
bovineType: BovineTypeRef | null
motherNationalNumber: string | null
motherBovineType: BovineTypeRef | null
fatherNationalNumber: string | null
fatherBovineType: BovineTypeRef | null
movements: BovineMovementData[]
}
const router = useRouter()
const route = useRoute()
const api = useApi()
const goBack = () => {
if (window.history.state?.back) {
router.back()
} else {
router.push('/inventory')
}
}
const todayIso = () => new Date().toISOString().slice(0, 10)
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 newMovementDate = ref<string>(todayIso())
const isSubmittingMovement = ref(false)
const movementSubmitted = ref(false)
const movementFilters = ref({ building: '', case: '' })
const bovineId = computed(() => {
const raw = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const n = Number(raw)
return Number.isFinite(n) ? n : null
})
const display = (value: string | null | undefined) => (value && value !== '' ? value : '—')
const workNumberFromNational = (nationalNumber: string | null | undefined) => {
if (!nationalNumber) return null
return nationalNumber.slice(-4)
}
const formatDate = (date: string | null | undefined) => {
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 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 ?? '—',
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 || !newMovementDate.value || bovineId.value === null) return
const buildingLabel = buildingOptions.value.find(o => o.value === Number(newMovementBuildingId.value))?.label ?? '—'
const caseLabel = caseOptions.value.find(o => o.value === Number(newMovementCaseId.value))?.label ?? '—'
const dateLabel = formatDate(newMovementDate.value)
const confirmed = window.confirm(
`Confirmer la création du mouvement ?\n\nBâtiment : ${buildingLabel}\nCase : ${caseLabel}\nDate : ${dateLabel}`
)
if (!confirmed) return
isSubmittingMovement.value = true
try {
await api.post('bovine_movements', {
bovine: `/api/bovines/${bovineId.value}`,
buildingCase: `/api/building_cases/${newMovementCaseId.value}`,
enteredAt: newMovementDate.value
}, { toastSuccessMessage: 'Mouvement enregistré' })
bovine.value = await api.get<BovinePassportData>(`bovines/${bovineId.value}`)
newMovementBuildingId.value = null
newMovementCaseId.value = null
newMovementDate.value = todayIso()
movementSubmitted.value = false
} finally {
isSubmittingMovement.value = false
}
}
onMounted(async () => {
if (bovineId.value === null) return
const [bovineData, buildingList] = await Promise.all([
api.get<BovinePassportData>(`bovines/${bovineId.value}`),
getBuildingList()
])
bovine.value = bovineData
buildings.value = buildingList
})
</script>

View File

@@ -1,182 +0,0 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="goBack"
name="gg:arrow-left-o"
size="40"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ isEdit ? 'Modification d\'un bovin' : 'Ajout d\'un bovin' }}
</h1>
</div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput
id="bovine-national-number"
v-model="form.nationalNumber"
label="Numéro national"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
required
/>
<UiNumberInput
id="bovine-received-weight"
v-model="form.receivedWeight"
label="Poids à l'arrivée (kg)"
:min="0"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px] flex-col"
label-class="font-bold uppercase"
/>
<UiDateInput
id="bovine-arrival-date"
v-model="form.arrivalDate"
label="Date d'arrivée"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
/>
</div>
<div class="flex flex-cols-3 justify-between mb-11">
<UiSelect
id="bovine-supplier"
v-model="form.supplierId"
label="Vendeur"
:options="supplierOptions"
:loading="isLoadingSuppliers"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
/>
<div class="w-[280px]" />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center">
<UiButton
type="submit"
:disabled="!auth.isAdmin || isLoading"
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@click="submitted = true"
>
<Icon :name="isEdit ? '' : 'mdi:plus'" size="28" />
{{ isEdit ? 'Valider' : 'Ajouter' }}
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
useHead({ title: 'Bovins' })
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
import type { BovinePayload } from '~/services/dto/bovine-data'
import type { SupplierData } from '~/services/dto/supplier-data'
import { getSupplierList } from '~/services/supplier'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const caseId = computed(() => {
const raw = Number(route.query.caseId)
return Number.isFinite(raw) && raw > 0 ? raw : null
})
const bovineId = computed(() => {
const raw = Number(route.query.id)
return Number.isFinite(raw) && raw > 0 ? raw : null
})
const isEdit = computed(() => bovineId.value !== null)
const form = reactive<{
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
supplierId: string
}>({
nationalNumber: '',
receivedWeight: null,
arrivalDate: null,
supplierId: ''
})
const isLoading = ref(false)
const submitted = ref(false)
const suppliers = ref<SupplierData[]>([])
const isLoadingSuppliers = ref(false)
const supplierOptions = computed(() =>
suppliers.value.map(s => ({ value: String(s.id), label: s.name }))
)
const backRoute = computed(() => ({
path: '/infrastructure/case',
query: caseId.value ? { id: String(caseId.value) } : {}
}))
const goBack = () => {
router.push(backRoute.value)
}
const loadSuppliers = async () => {
isLoadingSuppliers.value = true
try {
suppliers.value = await getSupplierList()
} finally {
isLoadingSuppliers.value = false
}
}
const hydrate = async () => {
if (!isEdit.value || bovineId.value === null) {
return
}
isLoading.value = true
try {
const bovine = await getBovine(bovineId.value)
form.nationalNumber = bovine.nationalNumber ?? ''
form.receivedWeight = bovine.receivedWeight ?? null
form.arrivalDate = bovine.arrivalDate ?? null
if (bovine.supplier) {
const supplierId = bovine.supplier.replace(/.*\//, '')
form.supplierId = supplierId
}
} finally {
isLoading.value = false
}
}
const validate = async () => {
if (isLoading.value || !auth.isAdmin) return
if (!caseId.value) return
if (!form.nationalNumber.trim()) return
const payload: BovinePayload = {
nationalNumber: form.nationalNumber.trim(),
receivedWeight: form.receivedWeight,
arrivalDate: form.arrivalDate,
buildingCase: `/api/building_cases/${caseId.value}`,
supplier: form.supplierId ? `/api/suppliers/${form.supplierId}` : null
}
isLoading.value = true
try {
if (isEdit.value && bovineId.value !== null) {
await updateBovine(bovineId.value, payload)
} else {
await createBovine(payload)
}
router.push(backRoute.value)
} finally {
isLoading.value = false
}
}
onMounted(loadSuppliers)
watch(bovineId, hydrate, { immediate: true })
</script>

View File

@@ -23,14 +23,6 @@
<Icon name="mdi:printer-outline" size="32" class="text-white" />
</div>
</div>
<NuxtLink
v-if="hasCaseId && auth.isAdmin"
:to="addBovineRoute"
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"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div class="flex flex-wrap gap-3 mt-4">
@@ -56,7 +48,7 @@
:items="items"
:total-items="totalItems"
:loading="loading"
:row-clickable="auth.isAdmin"
row-clickable
empty-message="Aucun bovin dans cette case."
@row-click="goToBovine"
>
@@ -134,7 +126,6 @@ useHead({ title: 'Cases' })
import type { BuildingCaseData } from '~/services/dto/building-case-data'
import type { BovineData } from '~/services/dto/bovine-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { useBovineColumns } from '~/composables/useBovineColumns'
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
@@ -143,7 +134,6 @@ const route = useRoute()
const router = useRouter()
const { printPdf } = usePdfPrinter()
const api = useApi()
const auth = useAuthStore()
const caseId = computed(() => Number(route.query.id))
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
@@ -233,11 +223,6 @@ const title = computed(() => {
return `${buildingLabel} case ${caseNumber}`.trim()
})
const addBovineRoute = computed(() => ({
path: '/infrastructure/bovine',
query: { caseId: String(caseId.value) }
}))
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date)
@@ -270,11 +255,7 @@ const printCaseReport = async () => {
}
const goToBovine = (bovine: BovineData) => {
if (!auth.isAdmin) return
router.push({
path: '/infrastructure/bovine',
query: { id: String(bovine.id), caseId: String(caseId.value) }
})
router.push(`/bovine/${bovine.id}`)
}
watch(caseId, (id) => {

View File

@@ -57,6 +57,8 @@
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="(item: BovineData) => router.push(`/bovine/${item.id}`)"
>
<template #header-nationalNumber>
<UiTextInput
@@ -123,7 +125,7 @@
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.effectiveBuilding?.label ?? '—' }}
{{ item.buildingCase?.building?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}

View File

@@ -145,38 +145,14 @@
/>
</div>
<div v-if="formIsLoading">
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
<UiTabs
v-model="activeTab"
:tabs="[
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError },
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
{ key: 'merchandise', label: isMerchandise ? 'Marchandise' : 'Bovins', error: hasMerchandiseTabError }
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer"
:class="[
activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasMerchandiseTabError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'merchandise'"
>
{{ isMerchandise ? "Marchandise" : "Bovins" }}
</h1>
</div>
/>
<div class="mb-12 ">
<update-weight
v-show="activeTab === 'weights'"

View File

@@ -146,28 +146,13 @@
</div>
<div v-if="formIsLoading">
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
<UiTabs
v-model="activeTab"
:tabs="[
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError }
]"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
</div>
/>
<div class="mb-12">
<update-weight
v-show="activeTab === 'weights'"

View File

@@ -9,34 +9,3 @@ export async function createBovine(payload: BovinePayload) {
toastSuccessKey: 'success.bovine.create'
})
}
export async function createBovines(nationalNumbers: string[]): Promise<{ created: BovineData[]; errors: string[] }> {
const created: BovineData[] = []
const errors: string[] = []
for (const nationalNumber of nationalNumbers) {
try {
const bovine = await createBovine({ nationalNumber })
if (bovine) {
created.push(bovine)
}
} catch {
errors.push(nationalNumber)
}
}
return { created, errors }
}
export async function getBovine(id: number) {
const api = useApi()
return api.get<BovineData>(`bovines/${id}`)
}
export async function updateBovine(id: number, payload: BovinePayload) {
const api = useApi()
return api.patch<BovineData>(`bovines/${id}`, payload, {
toastErrorKey: 'errors.bovine.update',
toastSuccessKey: 'success.bovine.update'
})
}

View File

@@ -16,8 +16,6 @@ export interface BovineData {
arrivalDate: string | null
exitDate: string | null
buildingCase: BovineBuildingCaseRef | null
building: BovineBuildingRef | null
effectiveBuilding: BovineBuildingRef | null
supplier: string | null
workNumber: string | null
birthDate: string | null
@@ -29,9 +27,5 @@ export interface BovineData {
export type BovinePayload = {
nationalNumber?: string
receivedWeight?: number | null
pricePerKg?: number | null
arrivalDate?: string | null
buildingCase?: string | null
supplier?: string | null
}

View File

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

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260504125011 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add mother/father national number and bovine type to bovine.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine ADD mother_national_number VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD father_national_number VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD mother_bovine_type_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD father_bovine_type_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F14E5E9FB FOREIGN KEY (mother_bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F53F12909 FOREIGN KEY (father_bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_2068337F14E5E9FB ON bovine (mother_bovine_type_id)');
$this->addSql('CREATE INDEX IDX_2068337F53F12909 ON bovine (father_bovine_type_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F14E5E9FB');
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F53F12909');
$this->addSql('DROP INDEX IDX_2068337F14E5E9FB');
$this->addSql('DROP INDEX IDX_2068337F53F12909');
$this->addSql('ALTER TABLE bovine DROP mother_national_number');
$this->addSql('ALTER TABLE bovine DROP father_national_number');
$this->addSql('ALTER TABLE bovine DROP mother_bovine_type_id');
$this->addSql('ALTER TABLE bovine DROP father_bovine_type_id');
}
}

View 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');
}
}

View File

@@ -1,215 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Bovine;
use App\Entity\Building;
use App\Entity\Supplier;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
#[AsCommand(
name: 'app:feed-bovine-prices',
description: 'Met à jour le poids, le prix au kilo et le fournisseur des bovins existants depuis un fichier XLSX.'
)]
final class FeedBovinePricesCommand extends Command
{
public function __construct(
private EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('file', InputArgument::REQUIRED, 'Chemin absolu vers le fichier XLSX')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simule sans persister en BDD')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$file = (string) $input->getArgument('file');
$dryRun = (bool) $input->getOption('dry-run');
if (!file_exists($file)) {
$io->error(sprintf('Fichier introuvable : %s', $file));
return Command::FAILURE;
}
$io->title('Feed bovins depuis '.basename($file));
if ($dryRun) {
$io->warning('Dry-run activé : aucune écriture en BDD.');
}
try {
$spreadsheet = IOFactory::load($file);
} catch (Throwable $e) {
$io->error('Impossible de lire le fichier : '.$e->getMessage());
return Command::FAILURE;
}
$sheet = $spreadsheet->getActiveSheet();
$highestRow = $sheet->getHighestRow();
// Pré-chargement des fournisseurs pour des lookups rapides (insensible casse).
$supplierByName = [];
foreach ($this->em->getRepository(Supplier::class)->findAll() as $supplier) {
$supplierByName[mb_strtoupper($supplier->getName())] = $supplier;
}
// Pré-chargement des bâtiments par code (insensible casse).
$buildingByCode = [];
foreach ($this->em->getRepository(Building::class)->findAll() as $building) {
$buildingByCode[mb_strtoupper($building->getCode())] = $building;
}
$bovineRepo = $this->em->getRepository(Bovine::class);
$stats = [
'total' => 0,
'updated' => 0,
'notFound' => 0,
'invalid' => 0,
'supplierMissing' => 0,
'buildingMissing' => 0,
];
$missingNationalNumbers = [];
$missingSuppliers = [];
$missingBuildings = [];
$io->progressStart($highestRow);
for ($row = 1; $row <= $highestRow; ++$row) {
++$stats['total'];
$rawNationalNumber = (string) ($sheet->getCell([1, $row])->getValue() ?? '');
$rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? '');
$rawWeight = $sheet->getCell([3, $row])->getValue();
$rawPrice = $sheet->getCell([4, $row])->getValue();
$rawBuilding = (string) ($sheet->getCell([5, $row])->getValue() ?? '');
$rawNationalNumber = trim($rawNationalNumber);
if ('' === $rawNationalNumber) {
++$stats['invalid'];
$io->progressAdvance();
continue;
}
// Garde : strip "FR" + espace optionnel uniquement s'il est présent.
$nationalNumber = preg_replace('/^FR\s*/i', '', $rawNationalNumber);
$bovine = $bovineRepo->findOneBy(['nationalNumber' => $nationalNumber]);
if (null === $bovine) {
++$stats['notFound'];
$missingNationalNumbers[] = $nationalNumber;
$io->progressAdvance();
continue;
}
// Lookup supplier (peut être null si introuvable ou colonne vide).
$supplier = null;
$supplierName = mb_strtoupper(trim($rawSupplier));
if ('' !== $supplierName) {
$supplier = $supplierByName[$supplierName] ?? null;
if (null === $supplier) {
++$stats['supplierMissing'];
$missingSuppliers[$supplierName] = ($missingSuppliers[$supplierName] ?? 0) + 1;
}
}
$weight = is_numeric($rawWeight) ? (int) $rawWeight : null;
$price = is_numeric($rawPrice) ? (float) $rawPrice : null;
if (null !== $weight) {
$bovine->setReceivedWeight($weight);
}
if (null !== $price) {
$bovine->setPricePerKg($price);
}
$bovine->setSupplier($supplier);
// Bâtiment direct : on n'écrase pas une affectation à une case existante.
$buildingCode = mb_strtoupper(trim($rawBuilding));
if ('' !== $buildingCode && null === $bovine->getBuildingCase()) {
$building = $buildingByCode[$buildingCode] ?? null;
if (null !== $building) {
$bovine->setBuilding($building);
} else {
++$stats['buildingMissing'];
$missingBuildings[$buildingCode] = ($missingBuildings[$buildingCode] ?? 0) + 1;
}
}
++$stats['updated'];
$io->progressAdvance();
}
$io->progressFinish();
if (!$dryRun) {
$this->em->flush();
}
$io->section('Résultats');
$io->table(
['Métrique', 'Valeur'],
[
['Lignes totales', $stats['total']],
['Bovins mis à jour', $stats['updated']],
['Bovins introuvables', $stats['notFound']],
['Lignes invalides', $stats['invalid']],
['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']],
['Bâtiments introuvables (building non set)', $stats['buildingMissing']],
]
);
if ([] !== $missingNationalNumbers) {
$preview = array_slice($missingNationalNumbers, 0, 10);
$io->warning(sprintf(
'%d bovin(s) introuvable(s). Aperçu : %s%s',
count($missingNationalNumbers),
implode(', ', $preview),
count($missingNationalNumbers) > 10 ? '…' : '',
));
}
if ([] !== $missingSuppliers) {
$list = [];
foreach ($missingSuppliers as $name => $count) {
$list[] = sprintf('%s (%d)', $name, $count);
}
$io->warning('Fournisseurs introuvables (bovins rattachés en null) : '.implode(', ', $list));
}
if ([] !== $missingBuildings) {
$list = [];
foreach ($missingBuildings as $code => $count) {
$list[] = sprintf('%s (%d)', $code, $count);
}
$io->warning('Bâtiments introuvables (champ non renseigné) : '.implode(', ', $list));
}
if ($dryRun) {
$io->success('Dry-run terminé. Relance sans --dry-run pour persister.');
} else {
$io->success('Feed terminé avec succès.');
}
return Command::SUCCESS;
}
}

View File

@@ -17,6 +17,8 @@ use ApiPlatform\Metadata\Post;
use App\Repository\BovineRepository;
use App\State\Bovin\BovineProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -94,11 +96,6 @@ class Bovine
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?Building $building = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?Supplier $supplier = null;
@@ -135,6 +132,37 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitedAt = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read'])]
private ?string $motherNationalNumber = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?BovineType $motherBovineType = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read'])]
private ?string $fatherNationalNumber = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
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
{
return $this->id;
@@ -211,28 +239,6 @@ class Bovine
return $this;
}
public function getBuilding(): ?Building
{
return $this->building;
}
public function setBuilding(?Building $building): static
{
$this->building = $building;
return $this;
}
/**
* Bâtiment effectif d'un bovin : la case affectée si elle existe (logique
* historique), sinon le bâtiment direct (fed depuis l'XLSX initial).
*/
#[Groups(['bovine:read', 'building_case:read'])]
public function getEffectiveBuilding(): ?Building
{
return $this->buildingCase?->getIdBuilding() ?? $this->building;
}
public function getSupplier(): ?Supplier
{
return $this->supplier;
@@ -329,6 +335,79 @@ class Bovine
return $this;
}
public function getMotherNationalNumber(): ?string
{
return $this->motherNationalNumber;
}
public function setMotherNationalNumber(?string $motherNationalNumber): static
{
$this->motherNationalNumber = $motherNationalNumber;
return $this;
}
public function getMotherBovineType(): ?BovineType
{
return $this->motherBovineType;
}
public function setMotherBovineType(?BovineType $motherBovineType): static
{
$this->motherBovineType = $motherBovineType;
return $this;
}
public function getFatherNationalNumber(): ?string
{
return $this->fatherNationalNumber;
}
public function setFatherNationalNumber(?string $fatherNationalNumber): static
{
$this->fatherNationalNumber = $fatherNationalNumber;
return $this;
}
public function getFatherBovineType(): ?BovineType
{
return $this->fatherBovineType;
}
public function setFatherBovineType(?BovineType $fatherBovineType): static
{
$this->fatherBovineType = $fatherBovineType;
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\PreUpdate]
public function refreshAgeMonths(): void

View File

@@ -0,0 +1,112 @@
<?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_BUREAU')",
)]
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\Column(type: 'datetime_immutable')]
#[Groups(['bovine:read', 'bovine_movement:write'])]
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 getEnteredAt(): DateTimeImmutable
{
return $this->enteredAt;
}
public function hasEnteredAt(): bool
{
return isset($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;
}
}

View 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()
;
}
}

View File

@@ -276,7 +276,7 @@ final class BovineInventoryExportProvider implements ProviderInterface
$type = $bovine->getBovineType();
$isLim = self::BREED_CODE_LIMOUSINE === $type?->getCode();
$isCharo = self::BREED_CODE_CHAROLAISE === $type?->getCode();
$building = $bovine->getBuildingCase()?->getIdBuilding() ?? $bovine->getBuilding();
$building = $bovine->getBuildingCase()?->getIdBuilding();
$code = $building?->getCode();
$sheet->setCellValue('A'.$row, $isLim ? 'X' : '');

View File

@@ -0,0 +1,43 @@
<?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);
}
$enteredAt = $data->hasEnteredAt() ? $data->getEnteredAt() : new DateTimeImmutable();
$data->setEnteredAt($enteredAt);
$data->setLeftAt(null);
$bovine = $data->getBovine();
$openMovement = $this->movementRepository->findOpenMovement($bovine);
if (null !== $openMovement) {
$openMovement->setLeftAt($enteredAt);
}
$bovine->setBuildingCase($data->getBuildingCase());
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}

View File

@@ -99,6 +99,11 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setMotherNationalNumber($identification->motherCarrier?->bovin?->nationalNumber);
$bovine->setMotherBovineType($this->resolveBovineType($identification->motherCarrier?->breedType));
$bovine->setFatherNationalNumber($identification->fatherIpg?->bovin?->nationalNumber);
$bovine->setFatherBovineType($this->resolveBovineType($identification->fatherIpg?->breedType));
}
$latestEntry = null;

View File

@@ -25,7 +25,7 @@
.sheet { width: auto; }
h1 {
margin: 8px 0 16px 0;
margin: 0 0 8px 0;
padding: 0;
line-height: 1;
text-transform: uppercase;
@@ -243,11 +243,23 @@
</tr>
</table>
{% set buildingNumber = buildingCase.idBuilding.label ?? '' %}
{% set buildingNumber = buildingNumber|replace({'Bâtiment': '', 'BÂTIMENT': '', 'Batiment': '', 'BATIMENT': ''})|trim %}
<div style="font-weight:700; text-align:left; font-size: 18px; margin-bottom: 16px;">
BÂTIMENT N°{{ buildingNumber }} - CASE N°{{ buildingCase.caseNumber ?? '' }}
</div>
<table style="width:auto; border-collapse:collapse; margin-bottom: 8px; margin-top: 8px">
<tr>
<td style="border:0; text-align:left; font-weight:700; font-size: 18px; padding-right: 8px;">BATIMENT N°</td>
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
<td style="border:0; width: 22px;"></td>
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
<td style="border:0; width: 22px;"></td>
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
<td style="border:0; width: 32px;"></td>
<td style="border:0; text-align:left; font-weight:700; font-size: 18px; padding-right: 8px;">CASE N°</td>
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
<td style="border:0; width: 22px;"></td>
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
<td style="border:0; width: 22px;"></td>
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
</tr>
</table>
<!-- =========================
TABLEAU PRINCIPAL