Compare commits
4 Commits
3e1760ca98
...
v0.0.95
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc8365405 | ||
| 4561467532 | |||
| c441420522 | |||
| ba9ea2de71 |
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.0.94'
|
||||
app.version: '0.0.95'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,199 +0,0 @@
|
||||
# Entrée / Sortie des bovins — Design
|
||||
|
||||
## Contexte
|
||||
|
||||
Aujourd'hui, l'application gère les **réceptions** (arrivée d'un camion) qui déclarent un nombre de bovins par race (ex : 5 charolais + 3 limousine + 2 autres). Une fois la réception terminée, ces déclarations sont des indicateurs imprécis et il manque l'étape de saisie individuelle des bovins (numéro national, poids, prix…).
|
||||
|
||||
L'objectif est d'introduire un **workflow d'entrée** qui transforme une réception bovins finie en saisies individuelles enrichies via EDNOTIF, et de poser les fondations pour un futur workflow de sortie symétrique.
|
||||
|
||||
Pour ce lot, **les sorties sont hors scope** mais l'écran liste prévoit déjà leur emplacement.
|
||||
|
||||
## Décisions structurantes
|
||||
|
||||
| Décision | Choix |
|
||||
| --- | --- |
|
||||
| Distinction "en attente" vs "terminée" | Flag explicite `entryCompleted: bool` sur `Reception` |
|
||||
| Lien Bovine → Reception | FK 1-N, `Bovine.reception` ManyToOne **nullable** |
|
||||
| Rendu de l'écran de saisie | UN formulaire (2 lignes) + tableau récap dessous |
|
||||
| Bâtiment + Case | Choisis **par bovin** dans le formulaire |
|
||||
| Persistance | Save individuel à chaque "Ajouter" (POST /bovines) |
|
||||
| Enrichissement EDNOTIF | Au backend via le `BovineProcessor` existant (pas de lookup live) |
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### `Reception` — modification
|
||||
|
||||
Nouveau champ :
|
||||
- `entryCompleted: bool`, default `false`, non nullable.
|
||||
- Pertinent uniquement quand `receptionType.code === 'BOVINS'`. Pour les autres types, reste `false` et ignoré côté UI.
|
||||
- Inclus dans les groupes `reception:read` et `reception:write`.
|
||||
|
||||
Migration : `ALTER TABLE reception ADD COLUMN entry_completed BOOLEAN NOT NULL DEFAULT false`.
|
||||
|
||||
Ajout d'un `BooleanFilter` sur `entryCompleted` dans `#[ApiFilter]`.
|
||||
|
||||
### `Bovine` — modification
|
||||
|
||||
Nouveau champ :
|
||||
- `reception: Reception` (ManyToOne, **nullable**).
|
||||
- Inclus dans `bovine:read` et `bovine:write`.
|
||||
|
||||
Migration : `ALTER TABLE bovine ADD COLUMN reception_id INTEGER NULL` + index + FK contrainte. Bovins existants restent à `NULL` — aucune migration de données.
|
||||
|
||||
Ajout d'un `SearchFilter` exact sur `reception` dans `#[ApiFilter]` pour permettre `GET /bovines?reception={id}`.
|
||||
|
||||
### `Reception` — relation inverse pour le compteur
|
||||
|
||||
Pour permettre l'affichage du compteur "bovins saisis" dans la liste sans N+1 :
|
||||
|
||||
- Ajouter `bovines: Collection<Bovine>` côté `Reception` (OneToMany inverse, `mappedBy: 'reception'`, fetch lazy).
|
||||
- Exposer un getter calculé `getRegisteredBovineCount(): int` dans le groupe `reception:read`.
|
||||
- L'implémentation côté provider/list peut utiliser un `addSelect('COUNT(b.id) AS bovineCount')` via un `QueryExtension` API Platform si le N+1 devient un problème (à mesurer).
|
||||
|
||||
### Aucune autre entité
|
||||
|
||||
Pas de table de jointure (un bovin entre une seule fois via une réception unique). Pas de nouvelle entité `Entry` (la `Reception` joue ce rôle). Pas d'entité `Exit` pour ce lot — la symétrie sera traitée plus tard.
|
||||
|
||||
## Endpoints API
|
||||
|
||||
Tous les endpoints réutilisent les ressources existantes ; **aucun endpoint custom n'est créé**.
|
||||
|
||||
### Liste des entrées en attente
|
||||
|
||||
`GET /api/receptions?receptionType.code=BOVINS&isValid=true&entryCompleted=false`
|
||||
|
||||
### Validation finale d'une entrée
|
||||
|
||||
`PATCH /api/receptions/{id}` avec `{ entryCompleted: true }`.
|
||||
|
||||
### Création d'un bovin lié
|
||||
|
||||
`POST /api/bovines` (Content-Type `application/ld+json`) avec :
|
||||
```json
|
||||
{
|
||||
"nationalNumber": "FR1234567890",
|
||||
"receivedWeight": 368,
|
||||
"pricePerKg": 5.7,
|
||||
"arrivalDate": "2026-04-29",
|
||||
"supplier": "/api/suppliers/12",
|
||||
"reception": "/api/receptions/45",
|
||||
"buildingCase": "/api/building_cases/8"
|
||||
}
|
||||
```
|
||||
|
||||
Le `BovineProcessor` enrichit automatiquement (workNumber, birthDate, race auto-créée via `BovineType`).
|
||||
|
||||
**Nettoyage en passant** : le `BovineProcessor` actuel appelle `setBreedCode()` qui n'existe plus (héritage avant la migration vers `BovineType` FK). À corriger pour qu'il fasse `setBovineType()` avec auto-create d'un `BovineType` si la race retournée par EDNOTIF n'existe pas en base.
|
||||
|
||||
### Suppression d'un bovin
|
||||
|
||||
`DELETE /api/bovines/{id}` — sécurité actuelle `ROLE_ADMIN` à abaisser à `ROLE_USER` pour permettre la correction immédiate depuis le tableau.
|
||||
|
||||
## Front-end
|
||||
|
||||
### Home (`pages/index.vue`)
|
||||
|
||||
- Card "CASES" → renommée "ENTRÉE / SORTIE" (multi-ligne `Entrée<br>Sortie`).
|
||||
- Lien : `/entry-exit`.
|
||||
- Icône : `mdi:swap-horizontal-bold` (à finaliser à l'implémentation).
|
||||
|
||||
### Page liste — `pages/entry-exit/index.vue`
|
||||
|
||||
Deux sections empilées :
|
||||
|
||||
**Entrées en attente**
|
||||
- Composant : `UiDataTable`.
|
||||
- Filtres serveur : `receptionType.code=BOVINS`, `isValid=true`, `entryCompleted=false`.
|
||||
- Colonnes :
|
||||
- Date réception
|
||||
- Fournisseur (`supplier.name`)
|
||||
- Total déclaré (calculé côté front : `sum(bovines_types.quantity) + parseInt(bovineDetail ?? '0')`)
|
||||
- Bovins saisis (depuis `getRegisteredBovineCount` exposé sur Reception)
|
||||
- Action (rangée cliquable)
|
||||
- Click row → `/entry-exit/entry/{receptionId}`.
|
||||
|
||||
**Sorties en attente**
|
||||
- Tableau placeholder vide avec message "À venir".
|
||||
|
||||
### Écran de saisie — `pages/entry-exit/entry/[id].vue`
|
||||
|
||||
**Header**
|
||||
- Titre : "Entrée bovins #N-BR-XXXX — Fournisseur YYY"
|
||||
- Sous-titre : "Bovins déclarés : 8 · Bovins saisis : 3"
|
||||
- Icône retour à gauche.
|
||||
|
||||
**Formulaire (2 lignes)**
|
||||
|
||||
Ligne 1 : Numéro national · Poids à l'arrivée · Date d'arrivée · Vendeur (Supplier select)
|
||||
Ligne 2 : Prix au kilo · Bâtiment (Building select) · Case (BuildingCase select dépendant du bâtiment) · Bouton **Ajouter**
|
||||
|
||||
**Pré-remplissage** (au chargement et après chaque add) :
|
||||
- Date d'arrivée = `reception.receptionDate` (date seule, modifiable)
|
||||
- Vendeur = `reception.supplier` (modifiable)
|
||||
- Bâtiment = premier de `reception.buildings` si dispo, sinon vide
|
||||
- Case = vide (à choisir explicitement)
|
||||
- Numéro national, poids, prix : vides
|
||||
|
||||
**Comportement bouton "Ajouter"**
|
||||
- Disabled si form invalide (n° national vide, poids ≤ 0, prix ≤ 0, building/case manquants).
|
||||
- Click → `POST /api/bovines` avec `application/ld+json`.
|
||||
- Succès → reload du tableau, reset form (en gardant les pré-remplissages), focus sur Numéro national.
|
||||
- Erreur 409 (doublon n° national) → toast "Ce bovin existe déjà".
|
||||
- Erreur EDNOTIF → bovin créé sans enrichissement (race/naissance vides), toast warning.
|
||||
|
||||
**Tableau récap (dessous)**
|
||||
|
||||
Colonnes : N° national · N° travail · Race · Sexe · Date naissance · Poids arrivée · Date arrivée · Prix/kg · Prix total · Bâtiment · Case · Action (icône poubelle).
|
||||
|
||||
Source : `GET /api/bovines?reception={id}` au mount + après chaque add/delete.
|
||||
|
||||
Suppression : `DELETE /api/bovines/{id}` avec `window.confirm`.
|
||||
|
||||
**Footer**
|
||||
- Bouton **Valider l'entrée** (à droite).
|
||||
- Si `bovins saisis < bovins déclarés` → `window.confirm("Vous n'avez saisi que X/Y bovins. Confirmer la fermeture ?")`.
|
||||
- Disabled si 0 bovin saisi.
|
||||
- Click → `PATCH /api/receptions/{id}` avec `{ entryCompleted: true }` → toast succès → redirection `/entry-exit`.
|
||||
|
||||
## Sécurité (rôles)
|
||||
|
||||
| Action | Rôle requis |
|
||||
| --- | --- |
|
||||
| Voir la page entrée/sortie | `ROLE_USER` |
|
||||
| Ajouter un bovin (POST /bovines) | `ROLE_USER` (actuellement `ROLE_ADMIN` — à abaisser, ce flux est métier opérationnel) |
|
||||
| Supprimer un bovin (DELETE /bovines) | `ROLE_USER` (idem, à abaisser) |
|
||||
| Valider l'entrée (PATCH receptions) | `ROLE_USER` |
|
||||
|
||||
L'abaissement à `ROLE_USER` sur `Bovine::Post`, `Bovine::Patch` et `Bovine::Delete` est **délibéré** : ce flux fait partie des opérations métier quotidiennes, pas de l'administration. À confirmer pendant l'implémentation.
|
||||
|
||||
## Cas limites
|
||||
|
||||
- **Total saisi > déclaré** : autorisé (les déclarations en réception sont des indicateurs imprécis).
|
||||
- **Doublon n° national** : la `UniqueConstraint` BDD le rejette → toast.
|
||||
- **EDNOTIF indisponible** : bovin créé sans enrich, comportement actuel du processor.
|
||||
- **Réception supprimée pendant la saisie** : impossible côté UI tant qu'on est dans l'écran. Si ça arrive (autre user), les `POST /bovines` suivants échoueront en 404 sur l'IRI reception → toast.
|
||||
- **Sortie d'un bovin** : non géré dans ce lot. Le futur workflow de sortie viendra basculer `Bovine.exitedAt`.
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] Migration `entry_completed` sur Reception passe sans erreur.
|
||||
- [ ] Migration `reception_id` sur Bovine passe sans erreur, bovins existants intacts.
|
||||
- [ ] Card "CASES" sur home remplacée par "ENTRÉE / SORTIE".
|
||||
- [ ] `/entry-exit` affiche les entrées en attente et un placeholder sorties.
|
||||
- [ ] Click sur une entrée → écran saisie avec form pré-rempli.
|
||||
- [ ] "Ajouter" → bovin créé, ligne au tableau, form reset (pré-remplissages restaurés).
|
||||
- [ ] Suppression d'une ligne fonctionne avec confirmation.
|
||||
- [ ] "Valider l'entrée" bascule `entryCompleted` et redirige.
|
||||
- [ ] Une réception fermée disparaît de la liste.
|
||||
- [ ] `BovineProcessor` corrigé pour utiliser `setBovineType()` avec auto-create.
|
||||
- [ ] `make test` passe sans régression.
|
||||
|
||||
## Mode d'implémentation
|
||||
|
||||
Sur ce projet, l'utilisateur souhaite **valider chaque étape du plan** avant exécution. À chaque étape du plan d'implémentation, l'agent doit :
|
||||
|
||||
1. Présenter ce qu'il s'apprête à faire (fichiers, changements).
|
||||
2. Attendre la validation explicite de l'utilisateur.
|
||||
3. Exécuter, puis présenter l'étape suivante.
|
||||
|
||||
Cette discipline permet des retours en direct et des ajustements fins en cours de route.
|
||||
@@ -153,7 +153,7 @@ const props = withDefaults(defineProps<{
|
||||
totalItems: undefined,
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
perPageOptions: () => [5, 10, 25, 50],
|
||||
perPageOptions: () => [10, 25, 50],
|
||||
rowClickable: false,
|
||||
showActions: false,
|
||||
emptyMessage: 'Aucune donnée',
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-start gap-6 relative" :class="{ 'mb-8': isConsultationMode }">
|
||||
<Icon
|
||||
@click="router.push('/entry-exit')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="font-bold text-3xl uppercase text-primary-500">
|
||||
Entrée bovins {{ reception?.identificationNumber ?? '' }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!isConsultationMode" class="text-sm text-slate-600 mt-1 mb-8">
|
||||
{{ reception?.supplier?.name ?? '—' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovinesTotal }}
|
||||
</p>
|
||||
|
||||
<template v-if="!isConsultationMode">
|
||||
<form
|
||||
class="grid grid-cols-4 gap-x-16 gap-y-8 mb-12 items-end"
|
||||
:class="{ submitted }"
|
||||
@submit.prevent="addBovine"
|
||||
>
|
||||
<UiTextInput
|
||||
v-model="form.nationalNumber"
|
||||
label="Numéro national"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.entryCause"
|
||||
label="Cause d'entrée"
|
||||
:options="entryCauseOptions"
|
||||
required
|
||||
/>
|
||||
<UiDateMaskedInput
|
||||
v-model="form.arrivalDate"
|
||||
label="Date d'entrée"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.supplierId"
|
||||
label="Vendeur"
|
||||
:options="supplierOptions"
|
||||
required
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class="flex justify-center mb-12">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
||||
:disabled="isAdding"
|
||||
:loading="isAdding"
|
||||
@click="addBovine"
|
||||
>
|
||||
Ajouter
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UiDataTable
|
||||
v-model:page="recapPage"
|
||||
v-model:per-page="recapPerPage"
|
||||
:columns="recapColumns"
|
||||
:items="savedBovines"
|
||||
:total-items="savedBovinesTotal"
|
||||
:loading="savedBovinesLoading"
|
||||
:show-actions="!isConsultationMode"
|
||||
>
|
||||
<template #header-nationalNumber>
|
||||
<UiTextInput v-model="recapFilters.nationalNumber" placeholder="N° National" size="compact" />
|
||||
</template>
|
||||
<template #header-workNumber>
|
||||
<UiTextInput v-model="recapFilters.workNumber" placeholder="N° Travail" size="compact" />
|
||||
</template>
|
||||
<template #header-bovineType.label>
|
||||
<UiTextInput v-model="recapFilters['bovineType.label']" placeholder="Race" size="compact" />
|
||||
</template>
|
||||
<template #header-sex>
|
||||
<UiTextInput v-model="recapFilters.sex" placeholder="Sexe" size="compact" />
|
||||
</template>
|
||||
<template #header-birthDate>
|
||||
<UiDateMaskedInput v-model="birthDateFilter" placeholder="Né le" size="compact" />
|
||||
</template>
|
||||
<template #header-arrivalDate>
|
||||
<UiDateMaskedInput v-model="arrivalDateFilter" placeholder="Entrée le" size="compact" />
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput :model-value="''" placeholder="Vendeur" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-entryCause>
|
||||
<UiTextInput :model-value="''" placeholder="Cause" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-ednotifConfirmedAt>
|
||||
<UiTextInput :model-value="''" placeholder="EDNOTIF" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<UiTextInput :model-value="''" placeholder="Action" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-birthDate="{ item }">
|
||||
{{ formatDate(item.birthDate) }}
|
||||
</template>
|
||||
<template #cell-arrivalDate="{ item }">
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-bovineType.label="{ item }">
|
||||
{{ item.bovineType?.label ?? '—' }}
|
||||
</template>
|
||||
<template #cell-supplier.name="{ item }">
|
||||
{{ supplierName(item.supplier) }}
|
||||
</template>
|
||||
<template #cell-entryCause="{ item }">
|
||||
{{ entryCauseLabel(item.entryCause) }}
|
||||
</template>
|
||||
<template #cell-ednotifConfirmedAt="{ item }">
|
||||
<span
|
||||
v-if="item.ednotifConfirmedAt"
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
||||
>
|
||||
Validé
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
|
||||
>
|
||||
En attente
|
||||
</span>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
size="24"
|
||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||
@click="confirmDeleteBovine(item)"
|
||||
/>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
|
||||
<div v-if="!isConsultationMode" class="flex justify-center mt-8">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
||||
:disabled="savedBovinesTotal === 0 || isValidating"
|
||||
:loading="isValidating"
|
||||
@click="validateEntry"
|
||||
>
|
||||
Valider l'entrée
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
import { getSupplierList } from '~/services/supplier'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
|
||||
const receptionId = computed(() => Number(route.params.id))
|
||||
|
||||
const reception = ref<ReceptionData | null>(null)
|
||||
const suppliers = ref<SupplierData[]>([])
|
||||
|
||||
const {
|
||||
items: savedBovines,
|
||||
totalItems: savedBovinesTotal,
|
||||
page: recapPage,
|
||||
perPage: recapPerPage,
|
||||
filters: recapFilters,
|
||||
loading: savedBovinesLoading,
|
||||
reload: reloadSavedBovines
|
||||
} = useDataTableServerState<BovineData>(
|
||||
'bovines',
|
||||
{
|
||||
reception: receptionId.value,
|
||||
nationalNumber: '',
|
||||
workNumber: '',
|
||||
'bovineType.label': '',
|
||||
sex: '',
|
||||
'birthDate[after]': '',
|
||||
'birthDate[strictly_before]': '',
|
||||
'arrivalDate[after]': '',
|
||||
'arrivalDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const birthDateFilter = computed<string>({
|
||||
get: () => (recapFilters.value['birthDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
recapFilters.value['birthDate[after]'] = ''
|
||||
recapFilters.value['birthDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
recapFilters.value['birthDate[after]'] = value
|
||||
recapFilters.value['birthDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const arrivalDateFilter = computed<string>({
|
||||
get: () => (recapFilters.value['arrivalDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
recapFilters.value['arrivalDate[after]'] = ''
|
||||
recapFilters.value['arrivalDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
recapFilters.value['arrivalDate[after]'] = value
|
||||
recapFilters.value['arrivalDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const isAdding = ref(false)
|
||||
const isValidating = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const isConsultationMode = computed(() => reception.value?.entryCompleted === true)
|
||||
|
||||
const recapColumns = computed(() => {
|
||||
const cols: Array<{ key: string; label: string; width: string }> = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '100px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '110px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '75px' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '75px' },
|
||||
{ key: 'supplier.name', label: 'Vendeur', width: '150px' },
|
||||
{ key: 'entryCause', label: 'Cause', width: '100px' }
|
||||
]
|
||||
if (isConsultationMode.value) {
|
||||
cols.push({ key: 'ednotifConfirmedAt', label: 'EDNOTIF', width: '110px' })
|
||||
}
|
||||
return cols
|
||||
})
|
||||
|
||||
const entryCauseLabel = (code: string | null | undefined) => {
|
||||
if (!code) return '—'
|
||||
return entryCauseOptions.find(o => o.value === code)?.label ?? code
|
||||
}
|
||||
|
||||
const supplierName = (supplier: BovineData['supplier']) => {
|
||||
if (supplier && typeof supplier === 'object') return supplier.name
|
||||
return '—'
|
||||
}
|
||||
|
||||
const formatDate = (date: string | null | undefined) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date.replace(' ', 'T'))
|
||||
if (isNaN(d.getTime())) return date
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
const confirmDeleteBovine = async (bovine: BovineData) => {
|
||||
const confirmed = window.confirm(`Supprimer le bovin ${bovine.nationalNumber} ?`)
|
||||
if (!confirmed) return
|
||||
|
||||
await api.delete(`bovines/${bovine.id}`)
|
||||
reloadSavedBovines()
|
||||
}
|
||||
|
||||
const validateEntry = async () => {
|
||||
if (savedBovinesTotal.value === 0 || isValidating.value) return
|
||||
|
||||
const message = savedBovinesTotal.value !== declaredCount.value
|
||||
? `Attention : ${savedBovinesTotal.value} bovins saisis sur ${declaredCount.value} déclarés. Êtes-vous sûr de vouloir valider l'entrée ?`
|
||||
: `Êtes-vous sûr de vouloir valider l'entrée ?`
|
||||
|
||||
if (!window.confirm(message)) return
|
||||
|
||||
isValidating.value = true
|
||||
try {
|
||||
await api.patch(`receptions/${receptionId.value}`, { entryCompleted: true })
|
||||
router.push('/entry-exit')
|
||||
} finally {
|
||||
isValidating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
type EntryCause = 'A' | 'N' | 'P'
|
||||
|
||||
interface FormState {
|
||||
nationalNumber: string
|
||||
entryCause: EntryCause
|
||||
arrivalDate: string
|
||||
supplierId: string | number | null
|
||||
}
|
||||
|
||||
const entryCauseOptions = [
|
||||
{ value: 'A', label: 'Achat' },
|
||||
{ value: 'N', label: 'Naissance' },
|
||||
{ value: 'P', label: 'Prêt ou pension' }
|
||||
]
|
||||
|
||||
const initialForm = (): FormState => ({
|
||||
nationalNumber: '',
|
||||
entryCause: 'A',
|
||||
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
|
||||
supplierId: reception.value?.supplier?.id ?? null
|
||||
})
|
||||
|
||||
const form = reactive<FormState>(initialForm())
|
||||
|
||||
const supplierOptions = computed(() =>
|
||||
suppliers.value.map(s => ({ value: s.id, label: s.name }))
|
||||
)
|
||||
|
||||
const declaredCount = computed(() => reception.value?.declaredBovineCount ?? 0)
|
||||
|
||||
const isFormValid = computed(() =>
|
||||
form.nationalNumber.trim() !== ''
|
||||
&& !!form.entryCause
|
||||
&& !!form.arrivalDate
|
||||
&& form.supplierId !== null
|
||||
)
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, initialForm())
|
||||
}
|
||||
|
||||
const loadReception = async () => {
|
||||
reception.value = await api.get<ReceptionData>(`receptions/${receptionId.value}`)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const focusFirstField = () => {
|
||||
const el = document.querySelector<HTMLInputElement>('form input[type="text"]')
|
||||
el?.focus()
|
||||
}
|
||||
|
||||
const addBovine = async () => {
|
||||
submitted.value = true
|
||||
if (!isFormValid.value || isAdding.value) return
|
||||
|
||||
isAdding.value = true
|
||||
try {
|
||||
const payload = {
|
||||
nationalNumber: form.nationalNumber.trim(),
|
||||
entryCause: form.entryCause,
|
||||
arrivalDate: form.arrivalDate,
|
||||
supplier: `/api/suppliers/${form.supplierId}`,
|
||||
reception: `/api/receptions/${receptionId.value}`
|
||||
}
|
||||
|
||||
await api.post<BovineData>('bovines', payload, {
|
||||
headers: { 'Content-Type': 'application/ld+json' }
|
||||
})
|
||||
|
||||
reloadSavedBovines()
|
||||
resetForm()
|
||||
submitted.value = false
|
||||
await nextTick()
|
||||
focusFirstField()
|
||||
} finally {
|
||||
isAdding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
suppliers.value = await getSupplierList()
|
||||
await loadReception()
|
||||
reloadSavedBovines()
|
||||
})
|
||||
</script>
|
||||
@@ -1,259 +0,0 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-start gap-10 relative">
|
||||
<Icon
|
||||
@click="router.push('/')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
||||
/>
|
||||
<h1 class="font-bold text-3xl uppercase text-primary-500">Entrée / Sortie</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid grid-cols-2 gap-8">
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées en attente</h2>
|
||||
<UiDataTable
|
||||
v-model:page="entryPage"
|
||||
v-model:per-page="entryPerPage"
|
||||
:columns="entryColumns"
|
||||
:items="entries"
|
||||
:total-items="totalEntries"
|
||||
:loading="entriesLoading"
|
||||
row-clickable
|
||||
@row-click="goToEntry"
|
||||
>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="entryFilters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput v-model="entryDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-declaredCount>
|
||||
<UiTextInput :model-value="''" placeholder="Déclarés" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-registeredBovineCount>
|
||||
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-status>
|
||||
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-identificationNumber="{ item }">
|
||||
{{ item.identificationNumber }}
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #cell-declaredCount="{ item }">
|
||||
{{ item.declaredBovineCount ?? 0 }}
|
||||
</template>
|
||||
<template #cell-registeredBovineCount="{ item }">
|
||||
{{ item.registeredBovineCount ?? 0 }}
|
||||
</template>
|
||||
<template #cell-status="{ item }">
|
||||
<span
|
||||
v-if="!item.entryCompleted"
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
|
||||
>
|
||||
Attente saisie
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
|
||||
>
|
||||
Attente EDNOTIF
|
||||
</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées validées</h2>
|
||||
<UiDataTable
|
||||
v-model:page="validatedPage"
|
||||
v-model:per-page="validatedPerPage"
|
||||
:columns="validatedColumns"
|
||||
:items="validated"
|
||||
:total-items="totalValidated"
|
||||
:loading="validatedLoading"
|
||||
>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="validatedFilters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput v-model="validatedDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-registeredBovineCount>
|
||||
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-validatedAt>
|
||||
<UiTextInput :model-value="''" placeholder="Validée le" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-status>
|
||||
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-identificationNumber="{ item }">
|
||||
{{ item.identificationNumber }}
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #cell-registeredBovineCount="{ item }">
|
||||
{{ item.registeredBovineCount ?? 0 }}
|
||||
</template>
|
||||
<template #cell-validatedAt="{ item }">
|
||||
{{ formatDate(item.validatedAt) }}
|
||||
</template>
|
||||
<template #cell-status>
|
||||
<span
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
||||
>
|
||||
Validée
|
||||
</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 mb-16 grid grid-cols-2 gap-8">
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties en attente</h2>
|
||||
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
||||
À venir
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties validées</h2>
|
||||
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
||||
À venir
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
items: entries,
|
||||
totalItems: totalEntries,
|
||||
page: entryPage,
|
||||
perPage: entryPerPage,
|
||||
filters: entryFilters,
|
||||
loading: entriesLoading,
|
||||
reload
|
||||
} = useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
'isValid': 'true',
|
||||
'exists[validatedAt]': 'false',
|
||||
'receptionType.code': 'BOVINS',
|
||||
'identificationNumber': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 5 }
|
||||
)
|
||||
|
||||
const {
|
||||
items: validated,
|
||||
totalItems: totalValidated,
|
||||
page: validatedPage,
|
||||
perPage: validatedPerPage,
|
||||
filters: validatedFilters,
|
||||
loading: validatedLoading,
|
||||
reload: reloadValidated
|
||||
} = useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
'isValid': 'true',
|
||||
'exists[validatedAt]': 'true',
|
||||
'receptionType.code': 'BOVINS',
|
||||
'identificationNumber': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 5 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const entryDateFilter = computed<string>({
|
||||
get: () => (entryFilters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
entryFilters.value['receptionDate[after]'] = ''
|
||||
entryFilters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
entryFilters.value['receptionDate[after]'] = value
|
||||
entryFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const validatedDateFilter = computed<string>({
|
||||
get: () => (validatedFilters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
validatedFilters.value['receptionDate[after]'] = ''
|
||||
validatedFilters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
validatedFilters.value['receptionDate[after]'] = value
|
||||
validatedFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const entryColumns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'receptionDate', label: 'Date', width: '75px' },
|
||||
{ key: 'declaredCount', label: 'Déclarés', width: '75px' },
|
||||
{ key: 'registeredBovineCount', label: 'Saisis', width: '70px' },
|
||||
{ key: 'status', label: 'Statut', width: '1fr' }
|
||||
]
|
||||
|
||||
const validatedColumns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'receptionDate', label: 'Date', width: '75px' },
|
||||
{ key: 'registeredBovineCount', label: 'Saisis', width: '50px' },
|
||||
{ key: 'validatedAt', label: 'Validée le', width: '75px' },
|
||||
{ key: 'status', label: 'Statut', width: '1fr' }
|
||||
]
|
||||
|
||||
const formatDate = (date: string | null | undefined) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date.replace(' ', 'T'))
|
||||
if (isNaN(d.getTime())) return date
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const goToEntry = (reception: ReceptionData) => {
|
||||
router.push(`/entry-exit/entry/${reception.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reload()
|
||||
reloadValidated()
|
||||
})
|
||||
</script>
|
||||
@@ -16,11 +16,7 @@ useHead({ title: 'Accueil' })
|
||||
EXPÉDITIONS<br>EN ATTENTE
|
||||
</template>
|
||||
</card-link>
|
||||
<card-link link="/entry-exit" iconName="mdi:swap-horizontal-bold">
|
||||
<template #label>
|
||||
Entrée<br>Sortie
|
||||
</template>
|
||||
</card-link>
|
||||
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
|
||||
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
|
||||
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
|
||||
<card-link link="/inventory" iconName="mdi:cow">
|
||||
|
||||
@@ -249,7 +249,6 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
'bovines',
|
||||
{
|
||||
'exists[exitedAt]': 'false',
|
||||
'exists[ednotifConfirmedAt]': 'true',
|
||||
nationalNumber: '',
|
||||
workNumber: '',
|
||||
'bovineType.label': '',
|
||||
|
||||
@@ -18,16 +18,13 @@ export interface BovineData {
|
||||
buildingCase: BovineBuildingCaseRef | null
|
||||
building: BovineBuildingRef | null
|
||||
effectiveBuilding: BovineBuildingRef | null
|
||||
supplier: { id: number; name: string } | string | null
|
||||
supplier: string | null
|
||||
workNumber: string | null
|
||||
birthDate: string | null
|
||||
bovineType: { id: number; label: string; code: string } | null
|
||||
sex: string | null
|
||||
ageMonths: number | null
|
||||
exitedAt: string | null
|
||||
reception?: string | null
|
||||
entryCause?: 'A' | 'N' | 'P' | null
|
||||
ednotifConfirmedAt?: string | null
|
||||
}
|
||||
|
||||
export type BovinePayload = {
|
||||
@@ -37,6 +34,4 @@ export type BovinePayload = {
|
||||
arrivalDate?: string | null
|
||||
buildingCase?: string | null
|
||||
supplier?: string | null
|
||||
reception?: string | null
|
||||
entryCause?: 'A' | 'N' | 'P' | null
|
||||
}
|
||||
|
||||
@@ -18,11 +18,6 @@ export interface ReceptionData {
|
||||
receptionDate: string
|
||||
currentStep: number
|
||||
isValid: boolean
|
||||
entryCompleted?: boolean
|
||||
validatedAt?: string | null
|
||||
registeredBovineCount?: number
|
||||
confirmedBovineCount?: number
|
||||
declaredBovineCount?: number
|
||||
receptionType?: ReceptionTypeData | null
|
||||
merchandiseType?: MerchandiseTypeData | null
|
||||
merchandiseDetail?: string | null
|
||||
@@ -68,7 +63,6 @@ export type ReceptionPayload = {
|
||||
receptionDate?: string
|
||||
currentStep?: number
|
||||
isValid?: boolean
|
||||
entryCompleted?: boolean
|
||||
receptionType?: string | null
|
||||
merchandiseType?: string | null
|
||||
merchandiseDetail?: string | null
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260429073108 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Workflow entrée/sortie : ajout entry_completed sur reception et reception_id sur bovine.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Reception : flag de fermeture d'une entrée bovins.
|
||||
$this->addSql('ALTER TABLE reception ADD entry_completed BOOLEAN NOT NULL DEFAULT FALSE');
|
||||
|
||||
// Bovine : FK nullable vers la réception qui a fait entrer le bovin.
|
||||
$this->addSql('ALTER TABLE bovine ADD reception_id INT DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX IDX_BOVINE_RECEPTION ON bovine (reception_id)');
|
||||
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_BOVINE_RECEPTION FOREIGN KEY (reception_id) REFERENCES reception (id) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_BOVINE_RECEPTION');
|
||||
$this->addSql('DROP INDEX IDX_BOVINE_RECEPTION');
|
||||
$this->addSql('ALTER TABLE bovine DROP reception_id');
|
||||
$this->addSql('ALTER TABLE reception DROP entry_completed');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260429101011 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return "Ajout de la cause d'entrée (enum CauseEntree EDNOTIF) sur bovine.";
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE bovine ADD entry_cause VARCHAR(1) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE bovine DROP entry_cause');
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260429143822 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Ajout de bovine.ednotif_confirmed_at (timestamp de confirmation EDNOTIF par le sync inventory).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE bovine ADD ednotif_confirmed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
|
||||
// Backfill : les bovins déjà en BDD ont été synchronisés depuis EDNOTIF
|
||||
// historiquement (commande sync-inventory), on les considère confirmés.
|
||||
// Les nouveaux bovins créés via le workflow entrée auront `NULL` par
|
||||
// défaut et seront confirmés au prochain sync.
|
||||
$this->addSql('UPDATE bovine SET ednotif_confirmed_at = NOW() WHERE ednotif_confirmed_at IS NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE bovine DROP ednotif_confirmed_at');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260430090000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Ajout de reception.validated_at (timestamp de validation complète : entrée terminée + tous bovins confirmés EDNOTIF).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE reception ADD validated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE reception DROP validated_at');
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,10 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Enum\CauseEntree;
|
||||
use App\Repository\BovineRepository;
|
||||
use App\State\Bovin\BovineProcessor;
|
||||
use DateTimeImmutable;
|
||||
@@ -36,10 +34,9 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
'sex' => 'exact',
|
||||
'buildingCase' => 'exact',
|
||||
'receivedWeight' => 'exact',
|
||||
'reception' => 'exact',
|
||||
])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt', 'ednotifConfirmedAt'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
|
||||
#[ApiResource(
|
||||
order: ['birthDate' => 'ASC'],
|
||||
operations: [
|
||||
@@ -53,20 +50,16 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
new Post(
|
||||
normalizationContext: ['groups' => ['bovine:read']],
|
||||
denormalizationContext: ['groups' => ['bovine:write']],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: BovineProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
requirements: ['id' => '\d+'],
|
||||
normalizationContext: ['groups' => ['bovine:read']],
|
||||
denormalizationContext: ['groups' => ['bovine:write']],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: BovineProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
requirements: ['id' => '\d+'],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
)]
|
||||
@@ -101,16 +94,6 @@ class Bovine
|
||||
#[ApiProperty(readableLink: true)]
|
||||
private ?BuildingCase $buildingCase = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'bovines')]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['bovine:read', 'bovine:write'])]
|
||||
#[ApiProperty(readableLink: false)]
|
||||
private ?Reception $reception = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 1, nullable: true, enumType: CauseEntree::class)]
|
||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||
private ?CauseEntree $entryCause = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[Groups(['bovine:read'])]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
@@ -118,7 +101,6 @@ class Bovine
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
private ?Supplier $supplier = null;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
@@ -153,11 +135,6 @@ class Bovine
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||
private ?DateTimeImmutable $exitedAt = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i:s'])]
|
||||
private ?DateTimeImmutable $ednotifConfirmedAt = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -234,30 +211,6 @@ class Bovine
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReception(): ?Reception
|
||||
{
|
||||
return $this->reception;
|
||||
}
|
||||
|
||||
public function setReception(?Reception $reception): static
|
||||
{
|
||||
$this->reception = $reception;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntryCause(): ?CauseEntree
|
||||
{
|
||||
return $this->entryCause;
|
||||
}
|
||||
|
||||
public function setEntryCause(?CauseEntree $entryCause): static
|
||||
{
|
||||
$this->entryCause = $entryCause;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBuilding(): ?Building
|
||||
{
|
||||
return $this->building;
|
||||
@@ -364,18 +317,6 @@ class Bovine
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEdnotifConfirmedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->ednotifConfirmedAt;
|
||||
}
|
||||
|
||||
public function setEdnotifConfirmedAt(?DateTimeImmutable $ednotifConfirmedAt): static
|
||||
{
|
||||
$this->ednotifConfirmedAt = $ednotifConfirmedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAgeMonths(): ?int
|
||||
{
|
||||
return $this->ageMonths;
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
@@ -32,15 +31,13 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
#[ORM\Entity]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'reception')]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isValid', 'entryCompleted'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['validatedAt'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'identificationNumber' => 'ipartial',
|
||||
'supplier.name' => 'ipartial',
|
||||
'carrier.name' => 'ipartial',
|
||||
'licensePlate' => 'ipartial',
|
||||
'receptionType.id' => 'exact',
|
||||
'receptionType.code' => 'exact',
|
||||
])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
|
||||
#[ApiResource(
|
||||
@@ -113,14 +110,6 @@ class Reception
|
||||
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
|
||||
private bool $isValid = false;
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
|
||||
private bool $entryCompleted = false;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['reception:read', 'reception-bovine:read'])]
|
||||
private ?DateTimeImmutable $validatedAt = null;
|
||||
|
||||
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
|
||||
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
|
||||
#[Context(
|
||||
@@ -215,12 +204,6 @@ class Reception
|
||||
#[Groups(['reception:read', 'reception:write'])]
|
||||
private ?string $bovineDetail = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Bovine>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'reception')]
|
||||
private Collection $bovines;
|
||||
|
||||
public function __construct(
|
||||
?DateTimeImmutable $receptionDate = null,
|
||||
) {
|
||||
@@ -229,7 +212,6 @@ class Reception
|
||||
$this->buildings = new ArrayCollection();
|
||||
$this->pelletBuildings = new ArrayCollection();
|
||||
$this->bovines_types = new ArrayCollection();
|
||||
$this->bovines = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -288,90 +270,6 @@ class Reception
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['reception:read'])]
|
||||
public function isEntryCompleted(): bool
|
||||
{
|
||||
return $this->entryCompleted;
|
||||
}
|
||||
|
||||
public function setEntryCompleted(bool $entryCompleted): self
|
||||
{
|
||||
$this->entryCompleted = $entryCompleted;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getValidatedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->validatedAt;
|
||||
}
|
||||
|
||||
public function setValidatedAt(?DateTimeImmutable $validatedAt): self
|
||||
{
|
||||
$this->validatedAt = $validatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isFullyConfirmed(): bool
|
||||
{
|
||||
if ($this->bovines->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
foreach ($this->bovines as $bovine) {
|
||||
if (null === $bovine->getEdnotifConfirmedAt()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function tryValidate(): void
|
||||
{
|
||||
if ($this->entryCompleted && null === $this->validatedAt && $this->isFullyConfirmed()) {
|
||||
$this->validatedAt = new DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function onPreUpdate(): void
|
||||
{
|
||||
$this->tryValidate();
|
||||
}
|
||||
|
||||
#[Groups(['reception:read'])]
|
||||
public function getRegisteredBovineCount(): int
|
||||
{
|
||||
return $this->bovines->count();
|
||||
}
|
||||
|
||||
#[Groups(['reception:read'])]
|
||||
public function getConfirmedBovineCount(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->bovines as $bovine) {
|
||||
if (null !== $bovine->getEdnotifConfirmedAt()) {
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
#[Groups(['reception:read'])]
|
||||
public function getDeclaredBovineCount(): int
|
||||
{
|
||||
$fromTypes = 0;
|
||||
foreach ($this->bovines_types as $rb) {
|
||||
$fromTypes += (int) ($rb->getQuantity() ?? 0);
|
||||
}
|
||||
|
||||
$fromOther = is_numeric($this->bovineDetail) ? (int) $this->bovineDetail : 0;
|
||||
|
||||
return $fromTypes + $fromOther;
|
||||
}
|
||||
|
||||
#[Groups(['reception:read'])]
|
||||
public function getReceptionDate(): ?DateTimeImmutable
|
||||
{
|
||||
|
||||
@@ -54,11 +54,11 @@ class Supplier
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['supplier:read', 'reception:read', 'bovine:read'])]
|
||||
#[Groups(['supplier:read', 'reception:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Groups(['supplier:read', 'reception:read', 'supplier:write', 'bovine:read'])]
|
||||
#[Groups(['supplier:read', 'reception:read', 'supplier:write'])]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Cause d'une entrée de bovin sur l'exploitation (opération `IpBCreateEntree`).
|
||||
*
|
||||
* Source : `resources/ednotif-ws/CauseEntree.XSD` + doc IPG Table 9.
|
||||
* Le `.value` est le code IPG transmis dans le payload SOAP.
|
||||
*
|
||||
* Note : duplique l'enum `Malio\EdnotifBundle\Bovin\Enum\CauseEntree` (pas
|
||||
* encore présente dans la release installée v0.0.6). À remplacer par l'import
|
||||
* bundle quand une version embarquant l'enum sera publiée.
|
||||
*/
|
||||
enum CauseEntree: string
|
||||
{
|
||||
/** Entrée par achat. */
|
||||
case Achat = 'A';
|
||||
|
||||
/** Entrée par naissance. */
|
||||
case Naissance = 'N';
|
||||
|
||||
/** Entrée par prêt ou pension. */
|
||||
case PretOuPension = 'P';
|
||||
}
|
||||
@@ -35,7 +35,6 @@ final class BovineRepository extends ServiceEntityRepository
|
||||
{
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->where('b.exitedAt IS NULL')
|
||||
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
|
||||
->orderBy('b.birthDate', 'ASC')
|
||||
;
|
||||
|
||||
@@ -82,7 +81,6 @@ final class BovineRepository extends ServiceEntityRepository
|
||||
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
|
||||
)
|
||||
->where('b.exitedAt IS NULL')
|
||||
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
|
||||
;
|
||||
|
||||
if (null !== $buildingCaseId) {
|
||||
|
||||
@@ -7,8 +7,6 @@ namespace App\State\Bovin;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Bovine;
|
||||
use App\Entity\BovineType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Throwable;
|
||||
@@ -17,7 +15,6 @@ final class BovineProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BovinApiInterface $bovinApi,
|
||||
private readonly EntityManagerInterface $em,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
) {}
|
||||
@@ -44,35 +41,28 @@ final class BovineProcessor implements ProcessorInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$bovine->setSex($identification->sex);
|
||||
$bovine->setWorkNumber($identification->workNumber);
|
||||
$bovine->setBirthDate($identification->birthDate?->date);
|
||||
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
|
||||
$bovine->setBreedCode($this->normalizeBreedCode($identification->breedType));
|
||||
} catch (Throwable) {
|
||||
// External service unavailable — persist bovine without enrichment.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve un BovineType par code, sinon en crée un placeholder
|
||||
* (l'admin pourra le renommer ensuite dans /admin/bovin/bovin-list).
|
||||
*/
|
||||
private function resolveBovineType(?string $code): ?BovineType
|
||||
private function normalizeBreedCode(mixed $breedType): ?string
|
||||
{
|
||||
if (null === $code || '' === $code) {
|
||||
if (null === $breedType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$existing = $this->em->getRepository(BovineType::class)->findOneBy(['code' => $code]);
|
||||
if (null !== $existing) {
|
||||
return $existing;
|
||||
if (is_numeric($breedType)) {
|
||||
return (string) $breedType;
|
||||
}
|
||||
|
||||
$bovineType = new BovineType();
|
||||
$bovineType->setCode($code);
|
||||
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
|
||||
$this->em->persist($bovineType);
|
||||
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
return $bovineType;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,7 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
||||
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
|
||||
}
|
||||
|
||||
$seen = [];
|
||||
$impactedReceptions = [];
|
||||
$seen = [];
|
||||
foreach ($inventory->animals as $animal) {
|
||||
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
|
||||
if (null === $nationalNumber || '' === $nationalNumber) {
|
||||
@@ -73,20 +72,6 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
||||
|
||||
$this->applyEdnotifData($bovine, $animal);
|
||||
$bovine->setExitedAt(null);
|
||||
|
||||
// Marque la confirmation EDNOTIF si c'est la première fois qu'on
|
||||
// voit ce bovin remonter dans l'inventaire.
|
||||
if (null === $bovine->getEdnotifConfirmedAt()) {
|
||||
$bovine->setEdnotifConfirmedAt(new DateTimeImmutable());
|
||||
$reception = $bovine->getReception();
|
||||
if (null !== $reception) {
|
||||
$impactedReceptions[$reception->getId()] = $reception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($impactedReceptions as $reception) {
|
||||
$reception->tryValidate();
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable();
|
||||
|
||||
@@ -91,6 +91,22 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
|
||||
];
|
||||
}
|
||||
|
||||
usort($rows, static function (array $a, array $b): int {
|
||||
$aw = (string) ($a['workNumber'] ?? '');
|
||||
$bw = (string) ($b['workNumber'] ?? '');
|
||||
if ('' === $aw && '' === $bw) {
|
||||
return 0;
|
||||
}
|
||||
if ('' === $aw) {
|
||||
return 1;
|
||||
}
|
||||
if ('' === $bw) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return (int) $aw <=> (int) $bw;
|
||||
});
|
||||
|
||||
$monthHeaders = $this->buildMonthHeaders($firstArrivalDate, $headerBreedCode);
|
||||
|
||||
$dompdf = new Dompdf();
|
||||
|
||||
@@ -139,10 +139,10 @@
|
||||
}
|
||||
|
||||
.main .sub-title {
|
||||
font-size: 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
padding: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.main .base {
|
||||
@@ -203,94 +203,79 @@
|
||||
<h1 style="color: red; text-align: center; width: 100%; font-size: 36px">
|
||||
Arrivage du {{ firstArrivalDate ?? '-' }}
|
||||
</h1>
|
||||
<table style="width:100%; border-collapse:collapse; table-layout:fixed; margin-bottom: 16px">
|
||||
<table style="width:100%; border-collapse:collapse; table-layout:fixed; margin-bottom: 4px">
|
||||
<colgroup>
|
||||
{# 28 colonnes ≈ 3.571% chacune #}
|
||||
{% for _ in 0..27 %}<col style="width:3.571%">{% endfor %}
|
||||
</colgroup>
|
||||
<tr>
|
||||
<td style="width:40%; vertical-align:top; padding-right:2mm; border:0;">
|
||||
<table style="width:100%; border-collapse:collapse; table-layout:fixed;">
|
||||
<tr>
|
||||
<td style="border: 0; height: 20px"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight:700; text-align: left; border: none; font-size: 24px">CASE N° {{ buildingCase.caseNumber ?? '' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="border:0; text-align:left; font-weight:700; font-size: 18px;" colspan="4">PROVENANCE</td>
|
||||
|
||||
<td style="width:60%; vertical-align:top; padding-left:2mm; border:0;">
|
||||
<table class="header-right-free" style="width:100%; border-collapse:collapse; table-layout:fixed;">
|
||||
<tr>
|
||||
<td style="border:0; text-align:center; font-weight:700; height: 20px;" colspan="5"></td>
|
||||
<td style="border:0;" colspan="2"></td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">1</td>
|
||||
<td style="border:0; height: 20px;"></td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">2</td>
|
||||
<td style="border:0; height: 20px;"></td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">3</td>
|
||||
<td style="border:0; height: 20px;"></td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">4</td>
|
||||
<td style="border:0;" colspan="2"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border:0; text-align:left; font-weight:700; font-size: 24px; width:40%; height: 20px;" colspan="5">PROVENANCE</td>
|
||||
<td style="border:0;" colspan="2"></td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
<td style="border:0;"></td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
<td style="border:0;"></td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
<td style="border:0;"></td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
<td style="border: 0; width: 20%;" colspan="2"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border: 0; height: 20px" colspan="16"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border: 0; text-align:left; font-weight:700; font-size: 24px" colspan="3">RACE</td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="3">LIMOUSIN</td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
|
||||
<td style="border: 0; text-align:center; font-weight:700;" colspan="1"></td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="3">CHAROLAIS</td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
|
||||
<td style="border: 0; text-align:center; font-weight:700;" colspan="1"></td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">Autre</td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
{# Paire 1 : chiffre + case vide #}
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">1</td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
<td style="border:0;"></td>
|
||||
{# Paire 2 #}
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">2</td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
<td style="border:0;"></td>
|
||||
{# Paire 3 #}
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">3</td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
<td style="border:0;"></td>
|
||||
{# Paire 4 #}
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">4</td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
|
||||
{# Espacement entre PROVENANCE et RACE (1 col, RACE commence plus tôt) #}
|
||||
<td style="border:0;"></td>
|
||||
|
||||
{# Bloc RACE #}
|
||||
<td style="border:0; text-align:left; font-weight:700; font-size: 18px;" colspan="2">RACE</td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">LIMOUSIN</td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
<td style="border:0;"></td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">CHAROLAIS</td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
<td style="border:0;"></td>
|
||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;">AUTRE</td>
|
||||
<td style="border:1px solid #2b2b2b;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="font-weight:700; text-align:left; font-size: 18px; margin-bottom: 16px;">
|
||||
CASE N° {{ buildingCase.caseNumber ?? '' }}
|
||||
</div>
|
||||
|
||||
<!-- =========================
|
||||
TABLEAU PRINCIPAL
|
||||
========================= -->
|
||||
<table class="main">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="4" class="head-big" style="width:5%">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" 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([])|reverse %}
|
||||
<th class="month" style="width:6.58%">{{ month.name }}</th>
|
||||
{% endfor %}
|
||||
<th rowspan="4" class="head-big" style="width:7%">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:5%">N° de<br>travail</th>
|
||||
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
{% for month in monthHeaders|default([]) %}
|
||||
{% for month in monthHeaders|default([])|reverse %}
|
||||
<th class="days">{{ month.days }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th class="days">Foin</th>
|
||||
<th class="days">Foin</th>
|
||||
<th colspan="{{ monthHeaders|length -2 }}" class="sub-title">POIDS PAR MOIS</th>
|
||||
<th class="days">Foin</th>
|
||||
<th class="days">Foin</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
{% for month in monthHeaders|default([]) %}
|
||||
{% for month in monthHeaders|default([])|reverse %}
|
||||
<th class="base">
|
||||
{% if month.baseValue is defined %}
|
||||
{{ month.baseValue|round(0, 'common') }} kg
|
||||
@@ -303,27 +288,28 @@
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{# 11 lignes comme dans ton code (0..10) #}
|
||||
{# 13 lignes comme dans ton code (0..12) #}
|
||||
{% for i in 0..12 %}
|
||||
{% set row = rows[i] ?? null %}
|
||||
{% set baseWeight = row ? (row.receivedWeight ?? null) : null %}
|
||||
|
||||
<tr class="data-row">
|
||||
<td class="row-work"></td>
|
||||
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
|
||||
<td class="row-weight">{{ baseWeight ?? '' }}</td>
|
||||
{% for idx in 0..(monthCount > 0 ? monthCount - 1 : 0) %}
|
||||
{% set reversedIdx = (monthCount - 1) - idx %}
|
||||
{% set projectedWeight = row and row.projectedWeights is defined ? (row.projectedWeights[reversedIdx] ?? null) : null %}
|
||||
<td class="row-month"{% if reversedIdx < 4 %} style="background:#e0e0e0;"{% endif %}>
|
||||
{{ projectedWeight is not null ? projectedWeight|round(0, 'common') : '' }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td class="row-birth">
|
||||
{% if row and row.birthDate %}
|
||||
{% set birthParts = row.birthDate|split('/') %}
|
||||
{{ birthParts|length == 3 ? birthParts[1] ~ '/' ~ birthParts[2] : row.birthDate }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% for idx in 0..(monthCount > 0 ? monthCount - 1 : 0) %}
|
||||
{% set projectedWeight = row and row.projectedWeights is defined ? (row.projectedWeights[idx] ?? null) : null %}
|
||||
<td class="row-month"{% if loop.index0 < 4 %} style="background:#e0e0e0;"{% endif %}>
|
||||
{{ projectedWeight is not null ? projectedWeight|round(0, 'common') : '' }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td class="row-weight">{{ baseWeight ?? '' }}</td>
|
||||
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
|
||||
<td class="row-work"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -331,41 +317,89 @@
|
||||
<!-- =========================
|
||||
FOOTER (traitements / vaccins)
|
||||
========================= -->
|
||||
<table class="footer" style="border-collapse:collapse; margin-top: 32px">
|
||||
<table style="width:100%; border:0; border-collapse:collapse; table-layout:fixed; margin-top: 12px">
|
||||
<tr>
|
||||
<td style="height: 20px; border: 0" colspan="4"></td>
|
||||
<td style="font-weight: 700" colspan="2">Grippe</td>
|
||||
<td style="font-weight: 700" colspan="2">Protivity</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px">Date</td>
|
||||
<td>Antibiotique</td>
|
||||
<td>Date</td>
|
||||
<td>Antero</td>
|
||||
<td>Date</td>
|
||||
<td>Intranasale</td>
|
||||
<td>Date</td>
|
||||
<td>Rappel 30 jours</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px"></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px"></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td style="border:0; padding:0; width:49%; vertical-align:top;">
|
||||
<table class="footer" style="border-collapse:collapse; width:100%; table-layout:fixed;">
|
||||
<tr>
|
||||
<td style="font-weight: 700; height: 20px" colspan="10">Traitements</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2">Date</td>
|
||||
<td colspan="2"></td>
|
||||
<td>Dose</td>
|
||||
<td colspan="5">Observation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2"></td>
|
||||
<td colspan="2">Grippe</td>
|
||||
<td></td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2"></td>
|
||||
<td colspan="2">Antéro</td>
|
||||
<td></td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2"></td>
|
||||
<td colspan="2">Antibiotiques</td>
|
||||
<td></td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2"></td>
|
||||
<td colspan="2">Déparasitage</td>
|
||||
<td></td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2"></td>
|
||||
<td colspan="2"></td>
|
||||
<td></td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="border:0; padding:0; width:2%;"></td>
|
||||
<td style="border:0; padding:0; width:49%; vertical-align:top;">
|
||||
<table class="footer" style="border-collapse:collapse; width:100%; table-layout:fixed;">
|
||||
<tr>
|
||||
<td style="font-weight: 700; height: 20px" colspan="10">Rappel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2">Date</td>
|
||||
<td>Dose</td>
|
||||
<td colspan="7">Observation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2"></td>
|
||||
<td></td>
|
||||
<td colspan="7"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2"></td>
|
||||
<td></td>
|
||||
<td colspan="7"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2"></td>
|
||||
<td></td>
|
||||
<td colspan="7"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2"></td>
|
||||
<td></td>
|
||||
<td colspan="7"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 20px" colspan="2"></td>
|
||||
<td></td>
|
||||
<td colspan="7"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user