Compare commits

...

15 Commits

Author SHA1 Message Date
Matthieu
6db8973336 chore : trigger CI
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build Release Artefact / build (push) Successful in 1m31s
2026-04-10 14:40:53 +02:00
Matthieu
5be38b9f5a chore : trigger CI 2026-04-10 14:31:11 +02:00
Matthieu
07bcd6270d chore : trigger CI 2026-04-10 14:26:52 +02:00
Matthieu
422a46482a chore : trigger CI 2026-04-10 14:25:33 +02:00
Matthieu
f498f4dc95 chore : trigger CI 2026-04-10 14:21:19 +02:00
Matthieu
7690cd6484 chore : trigger CI 2026-04-10 14:12:11 +02:00
Matthieu
a736e233df chore : trigger CI 2026-04-10 12:03:58 +02:00
b24b5fb145 fix : CI/CD (!44)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #44
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-10 09:56:33 +00:00
Matthieu
8d25d3e589 chore : bump version to v0.0.82
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:47:59 +02:00
40f8bb40c9 fix : README.md 2026-04-10 11:41:23 +02:00
c84aa27d2c fix : README.md 2026-04-10 11:21:04 +02:00
77b9323615 feat : update CHANGELOG.md 2026-04-10 11:18:06 +02:00
6bf194b280 feat : update CHANGELOG.md 2026-04-10 11:00:36 +02:00
cdc9c33f4e feat : update CHANGELOG.md 2026-04-10 10:53:29 +02:00
b45e2d3a95 feat : écran d'ajout bovin + feed bovin + fix pesées expéditions 2026-04-10 10:29:16 +02:00
30 changed files with 854 additions and 422 deletions

View File

@@ -63,6 +63,7 @@ Ajouter dans le fichier .env du frontend
* [#FER-12] Ajouter un blocage des utilisateurs * [#FER-12] Ajouter un blocage des utilisateurs
* [#FER-13] Faire des recherches sur le scanner des bêtes * [#FER-13] Faire des recherches sur le scanner des bêtes
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente * [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
* [#FER-17] Ecran d'ajout de bovin
### Changed ### Changed

View File

@@ -1,68 +1,87 @@
# Projet Ferme # Projet Ferme t
## Installation du projet ## Installation du projet
### Windows ### Windows
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm. Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows) Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
### Linux ### Linux
Pour linux, il faut installer docker et nvm. Pour linux, il faut installer docker et nvm.
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux) Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
### Installation du projet ### Installation du projet
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
```bash ```bash
make start make start
make install make install
``` ```
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible. Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
### Configuration global ### Configuration global
Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue. Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue.
Vérifier que dans le .env.local, vous avez : Vérifier que dans le .env.local, vous avez :
* APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
* DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" - APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
* PONT_BASCULE_BYPASS (doit être à true en dev) - DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
* PONT_BASCULE_URL - PONT_BASCULE_BYPASS (doit être à true en dev)
* JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair) - PONT_BASCULE_URL
* JWT_PUBLIC_KEY - JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
* JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));") - JWT_PUBLIC_KEY
* COOKIE_SECURE=0 (en dev 0 et en prod 1. Si c'est du http, laisser en 0) - JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
- COOKIE_SECURE=0 (en dev 0 et en prod 1. Si c'est du http, laisser en 0)
Vérifier que dans le .env du dossier frontend, vous avez : Vérifier que dans le .env du dossier frontend, vous avez :
* NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
- NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
### Configuration xdebug ### Configuration xdebug
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br> Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
Pour cela, il faut aller dans **Settings > PHP > Servers** <br> Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
* Name : ferme-docker
* Host : localhost - Name : ferme-docker
* Port : 8080 - Host : localhost
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html - Port : 8080
- Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
## Utilisation du projet ## Utilisation du projet
### Backend ### Backend
L'api est disponible sur http://localhost:8080/api L'api est disponible sur http://localhost:8080/api
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local** Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
Vous pouvez modifier le port si nécessaire. Vous pouvez modifier le port si nécessaire.
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter. La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
C'est un bdd local dans le docker. C'est un bdd local dans le docker.
### Frontend ### Frontend
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
```bash ```bash
make dev-nuxt make dev-nuxt
``` ```
Le front sera accessible sur http://localhost:3000 Le front sera accessible sur http://localhost:3000
### Authentification ### Authentification
Ce projet utilise l'authentification JWT avec un cookie httpOnly (LexikJWTAuthenticationBundle). Ce projet utilise l'authentification JWT avec un cookie httpOnly (LexikJWTAuthenticationBundle).
Le frontend ne lit jamais directement le token, le navigateur envoie automatiquement le cookie. Le frontend ne lit jamais directement le token, le navigateur envoie automatiquement le cookie.
### Login flow ### Login flow
- Frontend envoie les identifiants à: - Frontend envoie les identifiants à:
- `POST /api/login_check` - `POST /api/login_check`
- Backend returns: - Backend returns:
@@ -72,63 +91,90 @@ Le frontend ne lit jamais directement le token, le navigateur envoie automatique
- La déconnexion utilise `POST /api/logout` et redirige vers `/login`. - La déconnexion utilise `POST /api/logout` et redirige vers `/login`.
### Fixtures ### Fixtures
Pour lancer les fixtures (Attention sa purge la bdd complètement) Pour lancer les fixtures (Attention sa purge la bdd complètement)
```bash ```bash
php bin/console doctrine:fixtures:load php bin/console doctrine:fixtures:load
``` ```
Attention cette commande est dangereuse, à utiliser que pour les débuts de la prod ou en recette. Attention cette commande est dangereuse, à utiliser que pour les débuts de la prod ou en recette.
Dans un premier temps pour remplir les listes, vous pouvez lancer la commande symfony Dans un premier temps pour remplir les listes, vous pouvez lancer la commande symfony
```bash ```bash
php bin/console app:seed php bin/console app:seed
``` ```
La commande va faire une update ou une création en fonction des data existante. La commande va faire une update ou une création en fonction des data existante.
## Livraison en recette ## Livraison en recette
### Préparatifs ### Préparatifs
Avant de déployer, il faut penser à ajouter les variables d'env s'il y a des changements/modifications. Avant de déployer, il faut penser à ajouter les variables d'env s'il y a des changements/modifications.
Le .env se trouve /var/www/ferme/.env Le .env se trouve /var/www/ferme/.env
Le script de livraison est version dans le repo dans script/deploy-release.sh <br> Le script de livraison est version dans le repo dans script/deploy-release.sh <br>
Sur la machine, il est disponible dans /usr/local/bin/deploy-ferme <br> Sur la machine, il est disponible dans /usr/local/bin/deploy-ferme <br>
Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme
### Livraison ### Livraison
Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer
```bash ```bash
/usr/local/bin/deploy-ferme vX.Y.Z /usr/local/bin/deploy-ferme vX.Y.Z
``` ```
## Commandes utiles ## Commandes utiles
Pour restart le container Pour restart le container
```bash ```bash
make restart make restart
``` ```
Pour lancer les TU Pour lancer les TU
```bash ```bash
make test make test
``` ```
Pour accéder au container et lance des commandes Pour accéder au container et lance des commandes
```bash ```bash
make shell make shell
``` ```
Pour clear le cache Symfony Pour clear le cache Symfony
```bash ```bash
make cache-clear make cache-clear
``` ```
Faire une migration Faire une migration
```bash ```bash
make migration-migrate make migration-migrate
``` ```
Pour générer un password pour un user Pour générer un password pour un user
```bash ```bash
make shell make shell
php bin/console security:hash-password php bin/console security:hash-password
``` ```
Sélectionner entity User, taper sont mdp, le copier et l'ajouter dans l'insert de bdd suivant : Sélectionner entity User, taper sont mdp, le copier et l'ajouter dans l'insert de bdd suivant :
```sql ```sql
INSERT INTO "user" (username, roles, password) INSERT INTO "user" (username, roles, password)
VALUES ('Mon user', '["ROLE_USER"]', 'Mon mdp hashé'); VALUES ('Mon user', '["ROLE_USER"]', 'Mon mdp hashé');
``` ```
## Gestion des logs ## Gestion des logs
Pour suivre les logs en temps réel : Pour suivre les logs en temps réel :
* tail -f var/log/dev.log
* tail -f var/log/prod.log - tail -f var/log/dev.log
- tail -f var/log/prod.log

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.0.81' app.version: '0.0.82'

View File

@@ -37,6 +37,7 @@ export const useWeighingStep = (options: UseWeighingStepOptions) => {
entityName: options.entityName, entityName: options.entityName,
apiResource: options.apiResource, apiResource: options.apiResource,
titleLabel: options.titleLabel, titleLabel: options.titleLabel,
isFinal: options.isFinal,
getWeightFromScale: options.getWeightFromScale, getWeightFromScale: options.getWeightFromScale,
updateEntity: options.updateEntity, updateEntity: options.updateEntity,
loadEntity: options.loadEntity loadEntity: options.loadEntity

View File

@@ -12,6 +12,7 @@ export interface UseWeighingOptions {
entityName: 'reception' | 'shipment' entityName: 'reception' | 'shipment'
apiResource: string apiResource: string
titleLabel: string titleLabel: string
isFinal?: boolean
getWeightFromScale: () => Promise<WeightData> getWeightFromScale: () => Promise<WeightData>
updateEntity: (id: number, payload: any) => Promise<any> updateEntity: (id: number, payload: any) => Promise<any>
loadEntity?: (id: number) => Promise<any> loadEntity?: (id: number) => Promise<any>
@@ -23,6 +24,7 @@ export const useWeighing = ({
entityName, entityName,
apiResource, apiResource,
titleLabel, titleLabel,
isFinal = false,
getWeightFromScale, getWeightFromScale,
updateEntity, updateEntity,
loadEntity loadEntity
@@ -77,7 +79,7 @@ export const useWeighing = ({
}) })
} }
const nextStep = mode === 'tare' const nextStep = isFinal
? entity.value.currentStep ? entity.value.currentStep
: entity.value.currentStep + 1 : entity.value.currentStep + 1
await updateEntity(entity.value.id, { await updateEntity(entity.value.id, {
@@ -152,7 +154,7 @@ export const useWeighingShipment = ({
entity: shipment, entity: shipment,
entityName: 'shipment', entityName: 'shipment',
apiResource: 'shipments', apiResource: 'shipments',
titleLabel: modeShipment === 'gross' ? 'Pesée à vide' : 'Pesée à plein', titleLabel: modeShipment === 'gross' ? 'Pesée à plein' : 'Pesée à vide',
getWeightFromScale: async () => { getWeightFromScale: async () => {
const { getWeightShipment } = await import('~/services/shipment') const { getWeightShipment } = await import('~/services/shipment')
return getWeightShipment() return getWeightShipment()

View File

@@ -5,13 +5,13 @@ export const shipmentConfig: WorkflowConfig = {
apiResource: 'shipments', apiResource: 'shipments',
steps: [ steps: [
{ label: 'Expédition' }, { label: 'Expédition' },
{ label: 'Pesée à vide', weighingMode: 'gross' }, { label: 'Pesée à vide', weighingMode: 'tare' },
{ label: 'Chargement' }, { label: 'Chargement' },
{ label: 'Pesée à plein', weighingMode: 'tare', isFinal: true } { label: 'Pesée à plein', weighingMode: 'gross', isFinal: true }
], ],
weighingLabels: { weighingLabels: {
gross: 'Pesée à vide', gross: 'Pesée à plein',
tare: 'Pesée à plein' tare: 'Pesée à vide'
}, },
buildReceiptFilename: (entity: WorkflowEntity) => { buildReceiptFilename: (entity: WorkflowEntity) => {
const ship = entity as any const ship = entity as any

View File

@@ -0,0 +1,180 @@
<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">
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

@@ -36,7 +36,7 @@
v-for="cell in entry.cells" v-for="cell in entry.cells"
:key="cell.key" :key="cell.key"
class="relative text-white flex h-[50px] items-center justify-center border-y-[3px] border-y-black bg-white hover:opacity-85 focus-visible:outline-none" class="relative text-white flex h-[50px] items-center justify-center border-y-[3px] border-y-black bg-white hover:opacity-85 focus-visible:outline-none"
:class="[cell.sideBorderClass, activeLegendStatutId !== null && cell.caseStatusId !== activeLegendStatutId ? 'opacity-35 hover:opacity-70' : '']" :class="[cell.sideBorderClass, activeLegendLabel !== null && cell.caseStatusLabel !== activeLegendLabel ? 'opacity-35 hover:opacity-70' : '']"
:style="[cell.spanStyle, cell.sideBorderStyle]" :style="[cell.spanStyle, cell.sideBorderStyle]"
:to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'" :to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'"
:title="cell.caseStatusLabel ?? undefined" :title="cell.caseStatusLabel ?? undefined"
@@ -58,25 +58,19 @@
<!-- Légende : survol d'un statut => atténue les autres cases --> <!-- Légende : survol d'un statut => atténue les autres cases -->
<div class="py-4"> <div class="py-4">
<!-- 3 zones fixes pour forcer gauche / centre / droite sur toute la largeur --> <div class="flex gap-6">
<div class="grid w-full grid-cols-3 gap-3">
<div <div
v-for="(statut, index) in statutLegend" v-for="statut in statutLegend"
:key="statut.id" :key="statut.label"
class="flex min-w-0 cursor-pointer items-center gap-2 py-1" class="flex cursor-pointer items-center gap-2 py-1"
:class="[ @mouseenter="activeLegendLabel = statut.label"
index === 0 ? 'justify-self-start' : '', @mouseleave="activeLegendLabel = null"
index === statutLegend.length - 1 ? 'justify-self-end' : '',
index > 0 && index < statutLegend.length - 1 ? 'justify-self-center' : ''
]"
@mouseenter="activeLegendStatutId = statut.id"
@mouseleave="activeLegendStatutId = null"
> >
<span <span
class="h-5 w-5 border border-slate-300" class="h-5 w-5 border border-slate-300"
:style="statut.couleur ? { backgroundColor: statut.couleur } : {}" :style="statut.couleur ? { backgroundColor: statut.couleur } : {}"
></span> ></span>
<span class="truncate text-sm uppercase text-slate-700"> <span class="text-sm uppercase text-slate-700">
{{ statut.label }} {{ statut.label }}
</span> </span>
</div> </div>
@@ -90,24 +84,27 @@
import type {BuildingData} from "~/services/dto/building-data" import type {BuildingData} from "~/services/dto/building-data"
import type {BuildingLayoutData} from "~/services/dto/building-layout-data" import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data" import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"
import type {BuildingCaseStatusData} from "~/services/dto/building-case-status-data"
import {getBuildingList} from "~/services/building" import {getBuildingList} from "~/services/building"
import {getStatutList} from "~/services/statut"
definePageMeta({layout: "default"}) definePageMeta({layout: "default"})
const router = useRouter() const router = useRouter()
// Données brutes chargées depuis l'API // Données brutes chargées depuis l'API
const buildingList = ref<BuildingData[]>([]) const buildingList = ref<BuildingData[]>([])
const statutLegend = ref<BuildingCaseStatusData[]>([]) const statutLegend = [
{ label: 'Libre', couleur: '#A3B18A' },
{ label: 'Occupé', couleur: '#3A506B' },
{ label: 'Malade', couleur: '#E07A5F' },
]
// Statut actuellement survolé dans la légende (pour filtrage visuel) // Statut actuellement survolé dans la légende (pour filtrage visuel)
const activeLegendStatutId = ref<number | null>(null) const activeLegendLabel = ref<string | null>(null)
// Modèle de vue prêt pour le template (layout + cellules + styles de grille) // Modèle de vue prêt pour le template (layout + cellules + styles de grille)
const buildingLayouts = computed(() => const buildingLayouts = computed(() =>
buildingList.value.map((building) => { buildingList.value
// On affiche uniquement le premier layout du bâtiment .filter((building) => building.layouts && building.layouts.length > 0)
const layout = building.layouts?.[0] ?? null .map((building) => {
const view = layout ? buildLayoutView(layout) : null const layout = building.layouts![0]
const view = buildLayoutView(layout)
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}} return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
}) })
) )
@@ -116,7 +113,6 @@ type GridCell = {
key: string key: string
caseId: number | null caseId: number | null
display: string display: string
caseStatusId: number | null
caseStatusLabel: string | null caseStatusLabel: string | null
// Couleur de fond de la case (dépend du statut) // Couleur de fond de la case (dépend du statut)
caseStyle?: Record<string, string> caseStyle?: Record<string, string>
@@ -132,6 +128,7 @@ type GridCell = {
// Type intermédiaire : garde des infos utiles au calcul des bordures, retirées ensuite // Type intermédiaire : garde des infos utiles au calcul des bordures, retirées ensuite
type GridCellDraft = Omit<GridCell, "sideBorderClass" | "sideBorderStyle" | "contentInsetClass"> & { x: number; columnSpan: number} type GridCellDraft = Omit<GridCell, "sideBorderClass" | "sideBorderStyle" | "contentInsetClass"> & { x: number; columnSpan: number}
// Nettoie la couleur de statut pour éviter les chaînes vides / espaces // Nettoie la couleur de statut pour éviter les chaînes vides / espaces
const normalizeCaseStatusColor = (value: string | null | undefined): string | null => { const normalizeCaseStatusColor = (value: string | null | undefined): string | null => {
const color = (value ?? "").trim() const color = (value ?? "").trim()
@@ -181,7 +178,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
// Métadonnées utiles au rendu / navigation / légende // Métadonnées utiles au rendu / navigation / légende
const caseId = (position.buildingCase?.id ?? null) as number | null const caseId = (position.buildingCase?.id ?? null) as number | null
const caseNumber = (position.buildingCase?.caseNumber ?? null) as number | null const caseNumber = (position.buildingCase?.caseNumber ?? null) as number | null
const caseStatusId = position.buildingCase?.statut?.id ?? null
const caseStatusLabel = position.buildingCase?.statut?.label ?? null const caseStatusLabel = position.buildingCase?.statut?.label ?? null
const statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur) const statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur)
@@ -191,7 +187,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
columnSpan, columnSpan,
caseId, caseId,
display: caseNumber !== null ? String(caseNumber) : "Case", display: caseNumber !== null ? String(caseNumber) : "Case",
caseStatusId,
caseStatusLabel, caseStatusLabel,
caseStyle: statusColor ? {backgroundColor: statusColor} : undefined, caseStyle: statusColor ? {backgroundColor: statusColor} : undefined,
// Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne // Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne
@@ -230,13 +225,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
} }
onMounted(async () => { onMounted(async () => {
// Chargement initial des bâtiments et de la légende des statuts buildingList.value = await getBuildingList()
const buildings = await getBuildingList()
const statuts = await getStatutList()
buildingList.value = buildings
// Tri alphabétique FR pour une légende stable
statutLegend.value = [...statuts].sort((a, b) =>
(a.label ?? "").localeCompare(b.label ?? "", "fr", {sensitivity: "base"})
)
}) })
</script> </script>

View File

@@ -1,21 +1,118 @@
<template> <template>
<div class="flex justify-center items-center"> <div class="px-[86px]">
<UiButton <div class="flex items-center justify-between relative">
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]" <div class="flex flex-row absolute -left-[60px]">
:disabled="!hasCaseId" <Icon
@click="router.push('/infrastructure/building')"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500"
/>
</div>
<div class="flex items-center gap-4">
<h1 class="font-bold text-4xl text-primary-500 uppercase">
{{ title }}
</h1>
<div
v-if="hasCaseId"
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer"
title="Imprimer"
@click="printCaseReport" @click="printCaseReport"
> >
Imprimer <Icon name="mdi:printer-outline" size="32" class="text-white" />
</UiButton> </div>
</div>
<NuxtLink
v-if="hasCaseId"
: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"
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60 pointer-events-none'"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div class="mt-8 border border-slate-200 mb-16">
<div
class="grid grid-cols-3 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
>
<div>Numéro national</div>
<div>Poids à l'arrivée (kg)</div>
<div>Date d'arrivée</div>
</div>
<template v-if="bovines.length > 0">
<div
v-for="bovine in bovines"
:key="bovine.id"
class="grid grid-cols-3 gap-4 px-4 py-3 text-sm border-t border-slate-200"
:class="auth.isAdmin ? 'cursor-pointer hover:bg-slate-50' : ''"
:role="auth.isAdmin ? 'button' : undefined"
:tabindex="auth.isAdmin ? 0 : undefined"
@click="goToBovine(bovine.id)"
@keydown.enter="goToBovine(bovine.id)"
>
<div>{{ bovine.nationalNumber }}</div>
<div>{{ bovine.receivedWeight ?? '—' }}</div>
<div>{{ formatDate(bovine.arrivalDate) }}</div>
</div>
</template>
<div
v-else
class="px-4 py-3 text-sm border-t border-slate-200 text-slate-500"
>
Aucun bovin dans cette case.
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { BuildingCaseData } from '~/services/dto/building-case-data'
import { useAuthStore } from '~/stores/auth'
const route = useRoute() const route = useRoute()
const router = useRouter()
const { printPdf } = usePdfPrinter() const { printPdf } = usePdfPrinter()
const api = useApi()
const auth = useAuthStore()
const caseId = computed(() => Number(route.query.id)) const caseId = computed(() => Number(route.query.id))
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0) const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
const buildingCase = ref<BuildingCaseData | null>(null)
const bovines = computed(() => buildingCase.value?.bovines ?? [])
const title = computed(() => {
if (!buildingCase.value) return ''
const buildingLabel = buildingCase.value.building?.label ?? ''
const caseNumber = buildingCase.value.caseNumber ?? ''
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)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const loadCase = async () => {
if (!hasCaseId.value) {
buildingCase.value = null
return
}
buildingCase.value = await api.get<BuildingCaseData>(`/building_cases/${caseId.value}`)
}
const printCaseReport = async () => { const printCaseReport = async () => {
if (!hasCaseId.value) { if (!hasCaseId.value) {
return return
@@ -24,4 +121,14 @@ const printCaseReport = async () => {
const filename = `tableau_poids_case_${caseId.value}.pdf` const filename = `tableau_poids_case_${caseId.value}.pdf`
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename) await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
} }
const goToBovine = (id: number) => {
if (!auth.isAdmin) return
router.push({
path: '/infrastructure/bovine',
query: { id: String(id), caseId: String(caseId.value) }
})
}
watch(caseId, loadCase, { immediate: true })
</script> </script>

View File

@@ -18,11 +18,11 @@
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/> <ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<WorkflowWeight <WorkflowWeight
v-if="storeShipment?.currentStep === 1" v-if="storeShipment?.currentStep === 1"
ref="grossWeightRef" ref="tareWeightRef"
mode="gross" mode="tare"
entity-name="shipment" entity-name="shipment"
api-resource="shipments" api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.gross" :title-label="shipmentConfig.weighingLabels.tare"
:is-final="false" :is-final="false"
:entity="storeShipment" :entity="storeShipment"
:get-weight-from-scale="getWeightShipment" :get-weight-from-scale="getWeightShipment"
@@ -34,11 +34,11 @@
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/> <ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
<WorkflowWeight <WorkflowWeight
v-if="storeShipment?.currentStep === 3" v-if="storeShipment?.currentStep === 3"
ref="tareWeightRef" ref="grossWeightRef"
mode="tare" mode="gross"
entity-name="shipment" entity-name="shipment"
api-resource="shipments" api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.tare" :title-label="shipmentConfig.weighingLabels.gross"
:is-final="true" :is-final="true"
:entity="storeShipment" :entity="storeShipment"
:get-weight-from-scale="getWeightShipment" :get-weight-from-scale="getWeightShipment"

View File

@@ -149,16 +149,6 @@
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60"> <div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1 <h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer" 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' : ''
]"
@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="[ :class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50', activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : '' hasTareWeightError ? '!text-red-500 !border-red-500' : ''
@@ -167,6 +157,16 @@
> >
pesée à vide pesée à vide
</h1> </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>
<div class="mb-12"> <div class="mb-12">
<update-weight <update-weight
@@ -248,7 +248,7 @@ const hasTareWeightError = computed(() =>
submitted.value && (tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null) submitted.value && (tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null)
) )
const activeTab = ref<'weightsEmpty' | 'weights'>('weights') const activeTab = ref<'weightsEmpty' | 'weights'>('weightsEmpty')
const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross')) const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross'))
const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare')) const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
const formIsLoading = ref(false) const formIsLoading = ref(false)

View File

@@ -27,3 +27,16 @@ export async function createBovines(nationalNumbers: string[]): Promise<{ create
return { created, errors } 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

@@ -4,6 +4,7 @@ export interface BovineData {
receivedWeight: number | null receivedWeight: number | null
arrivalDate: string | null arrivalDate: string | null
buildingCase: string | null buildingCase: string | null
supplier: string | null
} }
export type BovinePayload = { export type BovinePayload = {
@@ -11,4 +12,5 @@ export type BovinePayload = {
receivedWeight?: number | null receivedWeight?: number | null
arrivalDate?: string | null arrivalDate?: string | null
buildingCase?: string | null buildingCase?: string | null
supplier?: string | null
} }

View File

@@ -1,9 +1,17 @@
import type { BuildingCaseStatusData } from '~/services/dto/building-case-status-data' import type { BovineData } from '~/services/dto/bovine-data'
export interface BuildingSummary {
id: number
label: string
code: string
}
export interface BuildingCaseData { export interface BuildingCaseData {
id: number id: number
caseNumber: number | null caseNumber: number | null
code: string | null code: string | null
capacity: number | null capacity: number | null
statut?: BuildingCaseStatusData | null statut?: { label: string; couleur: string } | null
building?: BuildingSummary | null
bovines: BovineData[]
} }

View File

@@ -1,6 +0,0 @@
export interface BuildingCaseStatusData {
id: number
label: string | null
code: string | null
couleur: string | null
}

View File

@@ -1,23 +0,0 @@
import { useApi } from '~/composables/useApi'
import type { BuildingCaseStatusData } from '~/services/dto/building-case-status-data'
export type StatutListResponse =
| BuildingCaseStatusData[]
| { 'hydra:member'?: BuildingCaseStatusData[] }
export async function getStatutList(): Promise<BuildingCaseStatusData[]> {
const api = useApi()
const response = await api.get<StatutListResponse>('statuts', {}, {
toastErrorKey: 'errors.http.get'
})
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260410081839 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE building_case DROP CONSTRAINT fk_de2cee50f6203804');
$this->addSql('DROP INDEX idx_de2cee50f6203804');
$this->addSql('ALTER TABLE building_case DROP statut_id');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE building_case ADD statut_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE building_case ADD CONSTRAINT fk_de2cee50f6203804 FOREIGN KEY (statut_id) REFERENCES statut (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_de2cee50f6203804 ON building_case (statut_id)');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260410082723 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE statut');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE statut (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(255) NOT NULL, code VARCHAR(255) NOT NULL, color VARCHAR(255) NOT NULL, PRIMARY KEY (id))');
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Bovine;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
use function count;
#[AsCommand(
name: 'app:enrich-bovines',
description: 'Enrichit les bovins existants avec les données EdNotif (n° travail, date naissance, race).'
)]
class EnrichBovinesCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly BovinApiInterface $bovinApi,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$bovines = $this->entityManager->getRepository(Bovine::class)->findBy(['workNumber' => null]);
if (0 === count($bovines)) {
$io->success('Tous les bovins sont déjà enrichis.');
return Command::SUCCESS;
}
$io->info(sprintf('%d bovin(s) à enrichir.', count($bovines)));
$enriched = 0;
$failed = 0;
foreach ($bovines as $bovine) {
try {
$animalFile = $this->bovinApi->getAnimalFile(
nationalNumber: $bovine->getNationalNumber(),
countryCode: 'FR',
);
$identification = $animalFile->identification;
if (null === $identification) {
$io->warning(sprintf(' %s — pas d\'identification retournée.', $bovine->getNationalNumber()));
++$failed;
continue;
}
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setBreedCode($this->normalizeBreedCode($identification->breedType));
++$enriched;
$io->text(sprintf(' ✓ %s → n° travail %s', $bovine->getNationalNumber(), $identification->workNumber ?? '—'));
} catch (Throwable $e) {
++$failed;
$io->warning(sprintf(' %s — erreur : %s', $bovine->getNationalNumber(), $e->getMessage()));
}
}
$this->entityManager->flush();
$io->success(sprintf('%d enrichi(s), %d échoué(s).', $enriched, $failed));
return Command::SUCCESS;
}
private function normalizeBreedCode(mixed $breedType): ?string
{
if (null === $breedType) {
return null;
}
if (is_numeric($breedType)) {
return (string) $breedType;
}
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
return $matches[0];
}
return null;
}
}

View File

@@ -18,7 +18,6 @@ use App\Entity\MerchandiseType;
use App\Entity\PelletType; use App\Entity\PelletType;
use App\Entity\ReceptionType; use App\Entity\ReceptionType;
use App\Entity\ShipmentType; use App\Entity\ShipmentType;
use App\Entity\Statut;
use App\Entity\Supplier; use App\Entity\Supplier;
use App\Entity\Truck; use App\Entity\Truck;
use App\Entity\Vehicle; use App\Entity\Vehicle;
@@ -230,24 +229,6 @@ class SeedCommand extends Command
private function seedBuildingInfrastructure(): void private function seedBuildingInfrastructure(): void
{ {
$statusByCode = [];
$statusRows = [
['label' => 'Libre', 'code' => 'LB', 'color' => '#A3B18A'],
['label' => 'Occupé', 'code' => 'OC', 'color' => '#3A506B'],
['label' => 'Malade', 'code' => 'ML', 'color' => '#E07A5F'],
];
foreach ($statusRows as $statusRow) {
/** @var Statut $status */
$status = $this->upsertByCode(Statut::class, $statusRow['code'], static function (Statut $entity) use ($statusRow) {
$entity
->setLabel($statusRow['label'])
->setCode($statusRow['code'])
->setColor($statusRow['color'])
;
});
$statusByCode[$statusRow['code']] = $status;
}
$buildingRepo = $this->entityManager->getRepository(Building::class); $buildingRepo = $this->entityManager->getRepository(Building::class);
$layoutByBuildingCode = []; $layoutByBuildingCode = [];
$layoutRows = [ $layoutRows = [
@@ -274,25 +255,15 @@ class SeedCommand extends Command
} }
$caseRows = [ $caseRows = [
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'status' => 'LB'], ['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'status' => 'OC'], ['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 25, 'to' => 32, 'status' => 'ML'], ['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 33, 'to' => 44, 'status' => 'LB'],
['buildingCode' => 'B2', 'from' => 1, 'to' => 10, 'status' => 'OC'],
['buildingCode' => 'B2', 'from' => 11, 'to' => 22, 'status' => 'LB'],
['buildingCode' => 'B2', 'from' => 23, 'to' => 30, 'status' => 'ML'],
['buildingCode' => 'B2', 'from' => 31, 'to' => 44, 'status' => 'OC'],
['buildingCode' => 'B3', 'from' => 1, 'to' => 8, 'status' => 'ML'],
['buildingCode' => 'B3', 'from' => 9, 'to' => 20, 'status' => 'LB'],
['buildingCode' => 'B3', 'from' => 21, 'to' => 34, 'status' => 'OC'],
['buildingCode' => 'B3', 'from' => 35, 'to' => 44, 'status' => 'ML'],
]; ];
$caseByCode = []; $caseByCode = [];
foreach ($caseRows as $caseRow) { foreach ($caseRows as $caseRow) {
$building = $buildingRepo->findOneBy(['code' => $caseRow['buildingCode']]); $building = $buildingRepo->findOneBy(['code' => $caseRow['buildingCode']]);
$status = $statusByCode[$caseRow['status']] ?? null; if (!$building instanceof Building) {
if (!$building instanceof Building || !$status instanceof Statut) {
continue; continue;
} }
@@ -300,13 +271,12 @@ class SeedCommand extends Command
$code = sprintf('%s-C%d', $caseRow['buildingCode'], $caseNumber); $code = sprintf('%s-C%d', $caseRow['buildingCode'], $caseNumber);
/** @var BuildingCase $buildingCase */ /** @var BuildingCase $buildingCase */
$buildingCase = $this->upsertByCode(BuildingCase::class, $code, static function (BuildingCase $entity) use ($code, $caseNumber, $building, $status) { $buildingCase = $this->upsertByCode(BuildingCase::class, $code, static function (BuildingCase $entity) use ($code, $caseNumber, $building) {
$entity $entity
->setCode($code) ->setCode($code)
->setCaseNumber($caseNumber) ->setCaseNumber($caseNumber)
->setCapacity(15) ->setCapacity(15)
->setIdBuilding($building) ->setIdBuilding($building)
->setStatut($status)
; ;
}); });
$caseByCode[$code] = $buildingCase; $caseByCode[$code] = $buildingCase;

View File

@@ -8,7 +8,6 @@ use App\Entity\Building;
use App\Entity\BuildingCase; use App\Entity\BuildingCase;
use App\Entity\BuildingCasePosition; use App\Entity\BuildingCasePosition;
use App\Entity\BuildingLayout; use App\Entity\BuildingLayout;
use App\Entity\Statut;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
@@ -18,10 +17,9 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
{ {
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
$statuts = $this->loadStatuts($manager);
$buildings = $this->getBuildingsByCode($manager, ['B1', 'B2', 'B3']); $buildings = $this->getBuildingsByCode($manager, ['B1', 'B2', 'B3']);
$layouts = $this->loadLayouts($manager, $buildings); $layouts = $this->loadLayouts($manager, $buildings);
$cases = $this->loadBuildingCases($manager, $buildings, $statuts); $cases = $this->loadBuildingCases($manager, $buildings);
$this->loadCasePositions($manager, $layouts, $cases); $this->loadCasePositions($manager, $layouts, $cases);
$manager->flush(); $manager->flush();
@@ -34,38 +32,6 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
]; ];
} }
/**
* @return array<string, Statut>
*/
private function loadStatuts(ObjectManager $manager): array
{
$repo = $manager->getRepository(Statut::class);
$data = [
['label' => 'Libre', 'code' => 'LB', 'color' => '#A3B18A'],
['label' => 'Occupé', 'code' => 'OC', 'color' => '#3A506B'],
['label' => 'Malade', 'code' => 'ML', 'color' => '#E07A5F'],
];
$result = [];
foreach ($data as $row) {
/** @var null|Statut $statut */
$statut = $repo->findOneBy(['code' => $row['code']]);
if (!$statut instanceof Statut) {
$statut = new Statut()
->setLabel($row['label'])
->setCode($row['code'])
->setColor($row['color'])
;
$manager->persist($statut);
}
$result[$row['code']] = $statut;
}
return $result;
}
/** /**
* @param list<string> $codes * @param list<string> $codes
* *
@@ -126,34 +92,21 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
/** /**
* @param array<string, Building> $buildings * @param array<string, Building> $buildings
* @param array<string, Statut> $statuts
* *
* @return array<string, BuildingCase> * @return array<string, BuildingCase>
*/ */
private function loadBuildingCases(ObjectManager $manager, array $buildings, array $statuts): array private function loadBuildingCases(ObjectManager $manager, array $buildings): array
{ {
$repo = $manager->getRepository(BuildingCase::class); $repo = $manager->getRepository(BuildingCase::class);
$statusRanges = [ $caseRanges = [
// B1 ['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'statut' => 'LB'], ['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'statut' => 'OC'], ['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 25, 'to' => 32, 'statut' => 'ML'],
['buildingCode' => 'B1', 'from' => 33, 'to' => 44, 'statut' => 'LB'],
// B2
['buildingCode' => 'B2', 'from' => 1, 'to' => 10, 'statut' => 'OC'],
['buildingCode' => 'B2', 'from' => 11, 'to' => 22, 'statut' => 'LB'],
['buildingCode' => 'B2', 'from' => 23, 'to' => 30, 'statut' => 'ML'],
['buildingCode' => 'B2', 'from' => 31, 'to' => 44, 'statut' => 'OC'],
// B3
['buildingCode' => 'B3', 'from' => 1, 'to' => 8, 'statut' => 'ML'],
['buildingCode' => 'B3', 'from' => 9, 'to' => 20, 'statut' => 'LB'],
['buildingCode' => 'B3', 'from' => 21, 'to' => 34, 'statut' => 'OC'],
['buildingCode' => 'B3', 'from' => 35, 'to' => 44, 'statut' => 'ML'],
]; ];
$result = []; $result = [];
foreach ($statusRanges as $range) { foreach ($caseRanges as $range) {
for ($caseNumber = $range['from']; $caseNumber <= $range['to']; ++$caseNumber) { for ($caseNumber = $range['from']; $caseNumber <= $range['to']; ++$caseNumber) {
$code = sprintf('%s-C%d', $range['buildingCode'], $caseNumber); $code = sprintf('%s-C%d', $range['buildingCode'], $caseNumber);
@@ -169,7 +122,6 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
->setCode($code) ->setCode($code)
->setCapacity(15) ->setCapacity(15)
->setIdBuilding($buildings[$range['buildingCode']]) ->setIdBuilding($buildings[$range['buildingCode']])
->setStatut($statuts[$range['statut']])
; ;
$manager->persist($buildingCase); $manager->persist($buildingCase);
} }

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\State\BovineProcessor;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Context;
@@ -31,12 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
normalizationContext: ['groups' => ['bovine:read']], normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']], denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
processor: BovineProcessor::class,
), ),
new Patch( new Patch(
requirements: ['id' => '\d+'], requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['bovine:read']], normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']], denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
processor: BovineProcessor::class,
), ),
], ],
security: "is_granted('ROLE_USER')", security: "is_granted('ROLE_USER')",
@@ -46,19 +49,19 @@ class Bovine
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['bovine:read'])] #[Groups(['bovine:read', 'building_case:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 50)] #[ORM\Column(length: 50)]
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private string $nationalNumber = ''; private string $nationalNumber = '';
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?int $receivedWeight = null; private ?int $receivedWeight = null;
#[ORM\Column(type: 'date_immutable', nullable: true)] #[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $arrivalDate = null; private ?DateTimeImmutable $arrivalDate = null;
@@ -66,6 +69,23 @@ class Bovine
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write'])]
private ?BuildingCase $buildingCase = null; private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?Supplier $supplier = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $workNumber = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $birthDate = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $breedCode = null;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -118,4 +138,52 @@ class Bovine
return $this; return $this;
} }
public function getSupplier(): ?Supplier
{
return $this->supplier;
}
public function setSupplier(?Supplier $supplier): static
{
$this->supplier = $supplier;
return $this;
}
public function getWorkNumber(): ?string
{
return $this->workNumber;
}
public function setWorkNumber(?string $workNumber): static
{
$this->workNumber = $workNumber;
return $this;
}
public function getBirthDate(): ?DateTimeImmutable
{
return $this->birthDate;
}
public function setBirthDate(?DateTimeImmutable $birthDate): static
{
$this->birthDate = $birthDate;
return $this;
}
public function getBreedCode(): ?string
{
return $this->breedCode;
}
public function setBreedCode(?string $breedCode): static
{
$this->breedCode = $breedCode;
return $this;
}
} }

View File

@@ -32,15 +32,15 @@ class Building
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['building:read', 'reception:read'])] #[Groups(['building:read', 'building:summary', 'reception:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Groups(['building:read', 'reception:read'])] #[Groups(['building:read', 'building:summary', 'reception:read'])]
private string $label = ''; private string $label = '';
#[ORM\Column(length: 50)] #[ORM\Column(length: 50)]
#[Groups(['building:read', 'reception:read'])] #[Groups(['building:read', 'building:summary', 'reception:read'])]
private string $code = ''; private string $code = '';
/** /**

View File

@@ -19,7 +19,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
operations: [ operations: [
new Get( new Get(
requirements: ['id' => '\d+'], requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['building:read']], normalizationContext: ['groups' => ['building_case:read', 'building:summary']],
), ),
new Get( new Get(
uriTemplate: '/building_cases/{id}/weights-report', uriTemplate: '/building_cases/{id}/weights-report',
@@ -39,20 +39,20 @@ class BuildingCase
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['building:read'])] #[Groups(['building:read', 'building_case:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column] #[ORM\Column]
#[Groups(['building:read'])] #[Groups(['building:read', 'building_case:read'])]
#[SerializedName('caseNumber')] #[SerializedName('caseNumber')]
private ?int $case_number = null; private ?int $case_number = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Groups(['building:read'])] #[Groups(['building:read', 'building_case:read'])]
private ?string $code = null; private ?string $code = null;
#[ORM\Column] #[ORM\Column]
#[Groups(['building:read'])] #[Groups(['building:read', 'building_case:read'])]
private ?int $capacity = null; private ?int $capacity = null;
/** /**
@@ -62,16 +62,15 @@ class BuildingCase
private Collection $id_case_position; private Collection $id_case_position;
#[ORM\ManyToOne(inversedBy: 'buildingCases')] #[ORM\ManyToOne(inversedBy: 'buildingCases')]
#[Groups(['building_case:read'])]
#[SerializedName('building')]
private ?Building $id_building = null; private ?Building $id_building = null;
#[ORM\ManyToOne(inversedBy: 'id_case')]
#[Groups(['building:read'])]
private ?Statut $statut = null;
/** /**
* @var Collection<int, Bovine> * @var Collection<int, Bovine>
*/ */
#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'buildingCase')] #[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'buildingCase')]
#[Groups(['building_case:read'])]
private Collection $bovines; private Collection $bovines;
public function __construct() public function __construct()
@@ -170,16 +169,17 @@ class BuildingCase
return $this; return $this;
} }
public function getStatut(): ?Statut /**
* @return array{label: string, couleur: string}
*/
#[Groups(['building:read', 'building_case:read'])]
public function getStatut(): array
{ {
return $this->statut; if ($this->bovines->count() > 0) {
return ['label' => 'Occupé', 'couleur' => '#3A506B'];
} }
public function setStatut(?Statut $statut): static return ['label' => 'Libre', 'couleur' => '#A3B18A'];
{
$this->statut = $statut;
return $this;
} }
/** /**

View File

@@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['building:read']],
),
new GetCollection(
normalizationContext: ['groups' => ['building:read']],
),
],
security: "is_granted('ROLE_USER')",
)]
class Statut
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['building:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['building:read'])]
private ?string $label = null;
#[ORM\Column(length: 255)]
#[Groups(['building:read'])]
private ?string $code = null;
#[ORM\Column(length: 255)]
#[Groups(['building:read'])]
#[SerializedName('couleur')]
private ?string $color = null;
/**
* @var Collection<int, BuildingCase>
*/
#[ORM\OneToMany(targetEntity: BuildingCase::class, mappedBy: 'statut')]
private Collection $id_case;
public function __construct()
{
$this->id_case = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function setId(int $id): static
{
$this->id = $id;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
/**
* @return Collection<int, BuildingCase>
*/
public function getIdCase(): Collection
{
return $this->id_case;
}
public function addIdCase(BuildingCase $idCase): static
{
if (!$this->id_case->contains($idCase)) {
$this->id_case->add($idCase);
$idCase->setStatut($this);
}
return $this;
}
public function removeIdCase(BuildingCase $idCase): static
{
if ($this->id_case->removeElement($idCase)) {
// set the owning side to null (unless already changed)
if ($idCase->getStatut() === $this) {
$idCase->setStatut(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Bovine;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Throwable;
final class BovineProcessor implements ProcessorInterface
{
public function __construct(
private readonly BovinApiInterface $bovinApi,
#[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 Bovine && '' !== $data->getNationalNumber()) {
$this->enrichFromEdnotif($data);
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
private function enrichFromEdnotif(Bovine $bovine): void
{
try {
$animalFile = $this->bovinApi->getAnimalFile(
nationalNumber: $bovine->getNationalNumber(),
countryCode: 'FR',
);
$identification = $animalFile->identification;
if (null === $identification) {
return;
}
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setBreedCode($this->normalizeBreedCode($identification->breedType));
} catch (Throwable) {
// External service unavailable — persist bovine without enrichment.
}
}
private function normalizeBreedCode(mixed $breedType): ?string
{
if (null === $breedType) {
return null;
}
if (is_numeric($breedType)) {
return (string) $breedType;
}
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
return $matches[0];
}
return null;
}
}

View File

@@ -11,10 +11,8 @@ use App\Entity\BuildingCase;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf; use Dompdf\Dompdf;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
use Twig\Environment; use Twig\Environment;
use Twig\Error\LoaderError; use Twig\Error\LoaderError;
use Twig\Error\RuntimeError; use Twig\Error\RuntimeError;
@@ -40,7 +38,6 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
public function __construct( public function __construct(
private Environment $twig, private Environment $twig,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private BovinApiInterface $bovinApi,
) {} ) {}
/** /**
@@ -68,25 +65,10 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
continue; continue;
} }
$workNumber = null; $breedCode = $bovine->getBreedCode();
$birthDate = null;
$breedCode = null;
try {
$animalFileDto = $this->bovinApi->getAnimalFile(
nationalNumber: $bovine->getNationalNumber(),
countryCode: 'FR',
);
$workNumber = $animalFileDto->identification?->workNumber;
$birthDate = $animalFileDto->identification?->birthDate?->date?->format('d/m/y');
$breedCode = $this->normalizeBreedCode($animalFileDto->identification?->breedType);
if (null === $headerBreedCode && null !== $breedCode) { if (null === $headerBreedCode && null !== $breedCode) {
$headerBreedCode = $breedCode; $headerBreedCode = $breedCode;
} }
} catch (Throwable) {
// Keep row data even if external identification service is unavailable.
}
$arrivalDate = $bovine->getArrivalDate(); $arrivalDate = $bovine->getArrivalDate();
if ($arrivalDate instanceof DateTimeImmutable && null === $firstArrivalDate) { if ($arrivalDate instanceof DateTimeImmutable && null === $firstArrivalDate) {
@@ -101,8 +83,8 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
$rows[] = [ $rows[] = [
'nationalNumber' => $bovine->getNationalNumber(), 'nationalNumber' => $bovine->getNationalNumber(),
'workNumber' => $workNumber, 'workNumber' => $bovine->getWorkNumber(),
'birthDate' => $birthDate, 'birthDate' => $bovine->getBirthDate()?->format('d/m/y'),
'receivedWeight' => $bovine->getReceivedWeight(), 'receivedWeight' => $bovine->getReceivedWeight(),
'arrivalDate' => $bovine->getArrivalDate()?->format('d/m/Y'), 'arrivalDate' => $bovine->getArrivalDate()?->format('d/m/Y'),
'projectedWeights' => $projectedWeights, 'projectedWeights' => $projectedWeights,
@@ -131,23 +113,6 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
]); ]);
} }
private function normalizeBreedCode(mixed $breedType): ?string
{
if (null === $breedType) {
return null;
}
if (is_numeric($breedType)) {
return (string) $breedType;
}
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
return $matches[0];
}
return null;
}
private function resolveDailyGainKg(?string $breedCode): float private function resolveDailyGainKg(?string $breedCode): float
{ {
return 1.3; return 1.3;

View File

@@ -265,23 +265,15 @@
TABLEAU PRINCIPAL TABLEAU PRINCIPAL
========================= --> ========================= -->
<table class="main"> <table class="main">
<colgroup>
<col style="width:8%">
<col style="width:4%">
<col style="width:7%">
{% for month in monthHeaders %}
<col style="width:6.75%">
{% endfor %}
</colgroup>
<thead> <thead>
<tr> <tr>
<th rowspan="4" class="head-big">N° de<br>travail</th> <th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
<th rowspan="4" class="head-big head-big-weight">Poids<br>(kg)</th> <th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
<th rowspan="4" class="head-big">Date de<br>naissance</th> <th rowspan="4" class="head-big head-big-weight" style="width:4%">Poids<br>(kg)</th>
<th rowspan="4" class="head-big" style="width:7%">Date de<br>naissance</th>
{% for month in monthHeaders|default([]) %} {% for month in monthHeaders|default([]) %}
<th class="month">{{ month.name }}</th> <th class="month" style="width:6.58%">{{ month.name }}</th>
{% endfor %} {% endfor %}
</tr> </tr>
@@ -315,6 +307,7 @@
{% set baseWeight = row ? (row.receivedWeight ?? null) : null %} {% set baseWeight = row ? (row.receivedWeight ?? null) : null %}
<tr class="data-row"> <tr class="data-row">
<td class="row-work"></td>
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td> <td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
<td class="row-weight">{{ baseWeight ?? '' }}</td> <td class="row-weight">{{ baseWeight ?? '' }}</td>
<td class="row-birth"> <td class="row-birth">