Compare commits

...

8 Commits

Author SHA1 Message Date
gitea-actions
c2074df562 chore: bump version to v0.0.84
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m37s
2026-04-13 11:46:33 +00:00
29bfeeb4ee [#FER-18] Mise à jour du tableau d'arrivage (!45)
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
| 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: #45
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-13 11:46:26 +00:00
Matthieu
5ac03e359f chore : bump version to v0.0.83
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m33s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:45:34 +02:00
Matthieu
340aa2a3c0 feat : écran bovins, refacto cases, enrichissement bovins, migrations
- Ajout page infrastructure/bovine avec CRUD
- Refacto BuildingCase (suppression Statut, simplification)
- Commande EnrichBovinesCommand pour enrichir les données bovins
- 4 migrations Doctrine
- Mise à jour composables shipment/weighing
- Mise à jour README et CHANGELOG

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:44:53 +02:00
gitea-actions
6eb2ee2578 chore: bump version to v0.0.81
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m32s
2026-03-30 13:47:53 +00:00
34c1d162d8 [#FER-15] Fix droit de suppression réception/expédition utilisateur (!43)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #43
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-30 13:47:46 +00:00
gitea-actions
bbd05cea3e chore: bump version to v0.0.80
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m19s
2026-03-26 16:51:33 +00:00
7f78454553 [#FER-13] Faire des recherches sur le scanner des bêtes (!42)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #42
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-26 16:51:27 +00:00
39 changed files with 1349 additions and 485 deletions

113
.idea/workspace.xml generated
View File

@@ -4,19 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : système de blocage utilisateur">
<change afterPath="$PROJECT_DIR$/migrations/Version20260325142815.php" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/Security/UserChecker.php" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/State/ActiveUsersProvider.php" afterDir="false" />
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/packages/security.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/config/packages/security.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/admin/user/[[id]].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/admin/user/[[id]].vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/admin/user/list.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/admin/user/list.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/services/dto/user-data.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/services/dto/user-data.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/stores/auth.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/stores/auth.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/User.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/User.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -48,7 +41,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="fix/FER-11-corriger-le-probleme-de-bearer-token" />
<entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -239,7 +232,7 @@
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "feature/FER-12-ajouter-un-blocage-des-utilisateurs",
"git-widget-placeholder": "fix/FER-15-fix-droit-de-suppression-reception-expedition-util",
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
@@ -333,47 +326,7 @@
<workItem from="1773766075191" duration="6202000" />
<workItem from="1773824491213" duration="24805000" />
<workItem from="1774275549972" duration="51000" />
<workItem from="1774276665015" duration="16178000" />
</task>
<task id="LOCAL-00032" summary="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster">
<option name="closed" value="true" />
<created>1769100048933</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1769100048933</updated>
</task>
<task id="LOCAL-00033" summary="feat : ajout de la debug bar en mod dev">
<option name="closed" value="true" />
<created>1769177611987</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1769177611987</updated>
</task>
<task id="LOCAL-00034" summary="feat : ajout du bundle Malio ednotif pour l'utilisation des WS">
<option name="closed" value="true" />
<created>1769184861047</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1769184861047</updated>
</task>
<task id="LOCAL-00035" summary="fix : modification de la conf du bundle ednotif">
<option name="closed" value="true" />
<created>1769434793487</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1769434793487</updated>
</task>
<task id="LOCAL-00036" summary="feat : update du CHANGELOG.md">
<option name="closed" value="true" />
<created>1769435038236</created>
<option name="number" value="00036" />
<option name="presentableId" value="LOCAL-00036" />
<option name="project" value="LOCAL" />
<updated>1769435038236</updated>
<workItem from="1774276665015" duration="33750000" />
</task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" />
@@ -727,7 +680,47 @@
<option name="project" value="LOCAL" />
<updated>1774448105945</updated>
</task>
<option name="localTasksCounter" value="81" />
<task id="LOCAL-00081" summary="feat : système de blocage utilisateur">
<option name="closed" value="true" />
<created>1774450388149</created>
<option name="number" value="00081" />
<option name="presentableId" value="LOCAL-00081" />
<option name="project" value="LOCAL" />
<updated>1774450388149</updated>
</task>
<task id="LOCAL-00082" summary="feat : ajout d'un système de scanner bovin">
<option name="closed" value="true" />
<created>1774543296474</created>
<option name="number" value="00082" />
<option name="presentableId" value="LOCAL-00082" />
<option name="project" value="LOCAL" />
<updated>1774543296474</updated>
</task>
<task id="LOCAL-00083" summary="feat : mise à jour du CLAUDE.md">
<option name="closed" value="true" />
<created>1774543626516</created>
<option name="number" value="00083" />
<option name="presentableId" value="LOCAL-00083" />
<option name="project" value="LOCAL" />
<updated>1774543626516</updated>
</task>
<task id="LOCAL-00084" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1774543766582</created>
<option name="number" value="00084" />
<option name="presentableId" value="LOCAL-00084" />
<option name="project" value="LOCAL" />
<updated>1774543766582</updated>
</task>
<task id="LOCAL-00085" summary="feat : la page de scanner est accessible que pour les admins">
<option name="closed" value="true" />
<created>1774543840891</created>
<option name="number" value="00085" />
<option name="presentableId" value="LOCAL-00085" />
<option name="project" value="LOCAL" />
<updated>1774543840891</updated>
</task>
<option name="localTasksCounter" value="86" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -777,11 +770,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="fix: corrections diverses" />
<MESSAGE value="fix : corrections diverses" />
<MESSAGE value="fix : corrections frontend" />
<MESSAGE value="feat : affichage et modification expédition et modification bouton valider" />
<MESSAGE value="fix : erreur customer adress et bouton valider oublie" />
<MESSAGE value="feat : changelog update" />
<MESSAGE value="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" />
@@ -802,7 +790,12 @@
<MESSAGE value="fix : bouton de mise en attente" />
<MESSAGE value="fix : problème de bearer token" />
<MESSAGE value="feat : système de blocage utilisateur" />
<option name="LAST_COMMIT_MESSAGE" value="feat : système de blocage utilisateur" />
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>

View File

@@ -61,6 +61,10 @@ Ajouter dans le fichier .env du frontend
* [#353] modification front admin utilisateur
* [#FER-11] Corriger le problème de bearer token
* [#FER-12] Ajouter un blocage des utilisateurs
* [#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-17] Ecran d'ajout de bovin
* [#FER-18] Mise à jour du tableau d'arrivage
### Changed

View File

@@ -142,6 +142,15 @@ frontend/
- `BuildingCase` a `bovines` (OneToMany).
- Rapport PDF cases : `GET /building_cases/{id}/weights-report` → template Twig, projection depuis `arrivalDate`, gain journalier fixe `1.3 kg/jour`.
### Scanner boucles auriculaires
- Page dédiée `/scan` : scan de codes-barres Code 39/128 (boucles auriculaires bovines) depuis un téléphone Android via Chrome.
- Utilise l'API native `BarcodeDetector` (Shape Detection API, Chrome Android 83+) — pas de lib JS, décodage hardware quasi-instantané.
- **Non supporté sur iOS** (tous les navigateurs iOS utilisent WebKit, qui n'implémente pas `BarcodeDetector`).
- Les 4 premiers caractères du code-barres sont retirés avant enregistrement (`rawValue.slice(4)`).
- Composable `useBarcodeScanner` : caméra arrière, anti-doublon 2s, vibration au scan.
- Le bovin est créé via `POST /bovines` avec `Content-Type: application/ld+json` (nécessaire pour la résolution d'IRI de `buildingCase`).
- Sélection bâtiment → case (filtrées dynamiquement) avant de scanner.
### Données de référence
- `ReceptionType`, `MerchandiseType`, `PelletType`, `Building`, `Supplier` (avec `Address` via join table, `createdBy` → User), `Customer` (avec `Address` via join table, `createdBy` → User), `Truck`, `Carrier`, `Driver`, `Vehicle`.
- `Address` : champ `label` nullable (déprécié, retiré du front et du `address:write`), expose `fullAddress` via getter. `countryCode` par défaut `FR` côté front.

View File

@@ -1,68 +1,87 @@
# Projet Ferme
# Projet Ferme t
## Installation du projet
### Windows
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
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)
### Installation du projet
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
```bash
make start
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.
### 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.
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"
* PONT_BASCULE_BYPASS (doit être à true en dev)
* PONT_BASCULE_URL
* JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
* JWT_PUBLIC_KEY
* 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)
- 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"
- PONT_BASCULE_BYPASS (doit être à true en dev)
- PONT_BASCULE_URL
- JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
- JWT_PUBLIC_KEY
- 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 :
* NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
- NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
### Configuration xdebug
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
* Name : ferme-docker
* Host : localhost
* Port : 8080
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
- Name : ferme-docker
- Host : localhost
- 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
## Utilisation du projet
### Backend
L'api est disponible sur http://localhost:8080/api
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
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.
C'est un bdd local dans le docker.
### Frontend
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
```bash
make dev-nuxt
```
Le front sera accessible sur http://localhost:3000
### Authentification
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.
### Login flow
- Frontend envoie les identifiants à:
- `POST /api/login_check`
- 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`.
### Fixtures
Pour lancer les fixtures (Attention sa purge la bdd complètement)
```bash
php bin/console doctrine:fixtures:load
```
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
```bash
php bin/console app:seed
```
La commande va faire une update ou une création en fonction des data existante.
## Livraison en recette
### Préparatifs
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 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>
Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme
### Livraison
Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer
```bash
```bash
/usr/local/bin/deploy-ferme vX.Y.Z
```
## Commandes utiles
Pour restart le container
```bash
make restart
```
Pour lancer les TU
```bash
make test
```
Pour accéder au container et lance des commandes
```bash
make shell
```
Pour clear le cache Symfony
```bash
make cache-clear
```
Faire une migration
```bash
make migration-migrate
```
Pour générer un password pour un user
```bash
make shell
php bin/console security:hash-password
```
Sélectionner entity User, taper sont mdp, le copier et l'ajouter dans l'insert de bdd suivant :
```sql
INSERT INTO "user" (username, roles, password)
VALUES ('Mon user', '["ROLE_USER"]', 'Mon mdp hashé');
```
## Gestion des logs
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:
app.version: '0.0.79'
app.version: '0.0.84'

View File

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

View File

@@ -0,0 +1,113 @@
import { ref, onUnmounted } from 'vue'
declare global {
interface Window {
BarcodeDetector: new (options?: { formats: string[] }) => {
detect(source: HTMLVideoElement | ImageBitmapSource): Promise<{ rawValue: string }[]>
}
}
const BarcodeDetector: Window['BarcodeDetector'] | undefined
}
export function useBarcodeScanner(onDetected: (code: string) => void) {
const isSupported = ref('BarcodeDetector' in globalThis)
const isScanning = ref(false)
const error = ref<string | null>(null)
let detector: InstanceType<Window['BarcodeDetector']> | null = null
let stream: MediaStream | null = null
let animationFrameId: number | null = null
let lastDetectedCode = ''
let lastDetectedTime = 0
const COOLDOWN_MS = 2000
async function start(videoElement: HTMLVideoElement) {
if (!isSupported.value) {
error.value = 'BarcodeDetector non supporté. Utilisez Chrome sur Android.'
return
}
try {
detector = new BarcodeDetector({ formats: ['code_39', 'code_128'] })
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
})
videoElement.srcObject = stream
await videoElement.play()
isScanning.value = true
error.value = null
scanLoop(videoElement)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur lors du démarrage de la caméra'
isScanning.value = false
}
}
function scanLoop(videoElement: HTMLVideoElement) {
if (!isScanning.value || !detector) return
animationFrameId = requestAnimationFrame(async () => {
try {
if (videoElement.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
const barcodes = await detector!.detect(videoElement)
if (barcodes.length > 0) {
const code = barcodes[0].rawValue.slice(4)
const now = Date.now()
if (code !== lastDetectedCode || now - lastDetectedTime > COOLDOWN_MS) {
lastDetectedCode = code
lastDetectedTime = now
if (navigator.vibrate) {
navigator.vibrate(100)
}
onDetected(code)
}
}
}
} catch {
// Detection error on single frame, continue
}
scanLoop(videoElement)
})
}
function stop() {
isScanning.value = false
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
if (stream) {
stream.getTracks().forEach(track => track.stop())
stream = null
}
detector = null
}
onUnmounted(() => {
stop()
})
return {
isSupported,
isScanning,
error,
start,
stop
}
}

View File

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

View File

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

View File

@@ -89,6 +89,9 @@
"create": "Impossible de créer le type bovin.",
"update": "Impossible de mettre à jour le type bovin."
},
"bovine": {
"create": "Impossible d'enregistrer le bovin."
},
"carrier": {
"list": "Impossible de récupérer la liste des transporteurs.",
"fetch": "Impossible de récupérer les données du transporteur",
@@ -146,6 +149,9 @@
"update": "Type bovin mis à jour avec succès.",
"create": "Type bovin créé avec succès."
},
"bovine": {
"create": "Bovin enregistré avec succès."
},
"weight": {
"update": "Pesée mis à jour"
}

View File

@@ -122,6 +122,23 @@
Bovins
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/scan"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/scan')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Scanner
</a>
</NuxtLink>
</nav>
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
@@ -218,6 +235,9 @@
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu">
Bovins
</NuxtLink>
<NuxtLink to="/scan" @click="closeMenu">
Scanner
</NuxtLink>
</nav>
<button
@@ -231,7 +251,7 @@
</aside>
</transition>
</header>
<main class="mx-auto w-full max-w-[1280px] mt-16">
<main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16">
<slot/>
</main>
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">

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"
: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="[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]"
:to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'"
:title="cell.caseStatusLabel ?? undefined"
@@ -58,25 +58,19 @@
<!-- Légende : survol d'un statut => atténue les autres cases -->
<div class="py-4">
<!-- 3 zones fixes pour forcer gauche / centre / droite sur toute la largeur -->
<div class="grid w-full grid-cols-3 gap-3">
<div class="flex gap-6">
<div
v-for="(statut, index) in statutLegend"
:key="statut.id"
class="flex min-w-0 cursor-pointer items-center gap-2 py-1"
:class="[
index === 0 ? 'justify-self-start' : '',
index === statutLegend.length - 1 ? 'justify-self-end' : '',
index > 0 && index < statutLegend.length - 1 ? 'justify-self-center' : ''
]"
@mouseenter="activeLegendStatutId = statut.id"
@mouseleave="activeLegendStatutId = null"
v-for="statut in statutLegend"
:key="statut.label"
class="flex cursor-pointer items-center gap-2 py-1"
@mouseenter="activeLegendLabel = statut.label"
@mouseleave="activeLegendLabel = null"
>
<span
class="h-5 w-5 border border-slate-300"
:style="statut.couleur ? { backgroundColor: statut.couleur } : {}"
></span>
<span class="truncate text-sm uppercase text-slate-700">
<span class="text-sm uppercase text-slate-700">
{{ statut.label }}
</span>
</div>
@@ -90,33 +84,35 @@
import type {BuildingData} from "~/services/dto/building-data"
import type {BuildingLayoutData} from "~/services/dto/building-layout-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 {getStatutList} from "~/services/statut"
definePageMeta({layout: "default"})
const router = useRouter()
// Données brutes chargées depuis l'API
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)
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)
const buildingLayouts = computed(() =>
buildingList.value.map((building) => {
// On affiche uniquement le premier layout du bâtiment
const layout = building.layouts?.[0] ?? null
const view = layout ? buildLayoutView(layout) : null
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
})
buildingList.value
.filter((building) => building.layouts && building.layouts.length > 0)
.map((building) => {
const layout = building.layouts![0]
const view = buildLayoutView(layout)
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
})
)
type GridCell = {
key: string
caseId: number | null
display: string
caseStatusId: number | null
caseStatusLabel: string | null
// Couleur de fond de la case (dépend du statut)
caseStyle?: Record<string, string>
@@ -130,7 +126,8 @@ type GridCell = {
contentInsetClass: string
}
// 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
const normalizeCaseStatusColor = (value: string | null | undefined): string | null => {
@@ -181,7 +178,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
// Métadonnées utiles au rendu / navigation / légende
const caseId = (position.buildingCase?.id ?? 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 statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur)
@@ -191,7 +187,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
columnSpan,
caseId,
display: caseNumber !== null ? String(caseNumber) : "Case",
caseStatusId,
caseStatusLabel,
caseStyle: statusColor ? {backgroundColor: statusColor} : undefined,
// Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne
@@ -230,13 +225,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
}
onMounted(async () => {
// Chargement initial des bâtiments et de la légende des statuts
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"})
)
buildingList.value = await getBuildingList()
})
</script>

View File

@@ -1,21 +1,118 @@
<template>
<div class="flex justify-center items-center">
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!hasCaseId"
@click="printCaseReport"
>
Imprimer
</UiButton>
<div class="px-[86px]">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<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"
>
<Icon name="mdi:printer-outline" size="32" class="text-white" />
</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>
</template>
<script setup lang="ts">
import type { BuildingCaseData } from '~/services/dto/building-case-data'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const router = useRouter()
const { printPdf } = usePdfPrinter()
const api = useApi()
const auth = useAuthStore()
const caseId = computed(() => Number(route.query.id))
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
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 () => {
if (!hasCaseId.value) {
return
@@ -24,4 +121,14 @@ const printCaseReport = async () => {
const filename = `tableau_poids_case_${caseId.value}.pdf`
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>

View File

@@ -4,7 +4,7 @@
:columns="columns"
:items="receptionList ?? []"
route-prefix="/reception"
show-actions
:show-actions="auth.isAdmin"
>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
@@ -23,6 +23,9 @@
<script setup lang="ts">
import type { ReceptionData } from '~/services/dto/reception-data'
import { getReceptionList, deleteReception } from '~/services/reception'
import { useAuthStore } from '~/stores/auth'
const auth = useAuthStore()
const columns = [
{ key: 'receptionDate', label: 'Date et heure' },

229
frontend/pages/scan.vue Normal file
View File

@@ -0,0 +1,229 @@
<template>
<div class="flex flex-col gap-4 px-4">
<h1 class="text-2xl text-primary-500 font-bold uppercase">Scanner des bovins</h1>
<!-- Message si non supporté -->
<div v-if="!scanner.isSupported.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700 flex flex-col w-full">
<p class="font-bold">Scanner non disponible</p>
<p class="text-sm mt-1">BarcodeDetector n'est pas supportée par ce navigateur. Utilisez Chrome sur Android.</p>
</div>
<!-- Erreur caméra -->
<div v-if="scanner.error.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700">
<p>{{ scanner.error.value }}</p>
</div>
<div class="flex flex-wrap gap-4">
<UiSelect
id="scan-building"
v-model="selectedBuildingId"
label="Bâtiment"
:options="buildingOptions"
wrapper-class="w-full max-w-[280px]"
required
/>
<UiSelect
id="scan-case"
v-model="selectedCaseId"
label="Case"
:options="caseOptions"
:disabled="!selectedBuildingId"
wrapper-class="w-full max-w-[280px]"
required
/>
</div>
<!-- Zone caméra pleine hauteur -->
<div v-if="showScanner" class="fixed inset-0 z-50 flex flex-col bg-black overflow-hidden">
<!-- Header scanner -->
<div class="flex items-center justify-between px-4 py-3 bg-black/90">
<div class="text-white text-sm font-semibold">
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
</div>
<UiButton
type="button"
class="text-md font-bold uppercase bg-red-500 text-white h-[40px] px-4"
@click="stopScanning"
>
Arrêter
</UiButton>
</div>
<div class="flex-1 relative">
<video
ref="videoRef"
class="w-full h-full object-cover"
playsinline
muted
/>
<!-- Overlay zone de scan -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="w-[90%] h-48 border-2 border-white/70 rounded-lg" />
</div>
<!-- Dernier scan -->
<div v-if="lastScanned" class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-green-600/80 text-white px-4 py-2 rounded-full text-sm font-semibold">
{{ lastScanned }}
</div>
</div>
</div>
<!-- Bouton démarrer -->
<div v-if="!showScanner" class="flex gap-3">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full max-w-[272px]"
:disabled="!scanner.isSupported.value"
@click="startScanning"
>
Démarrer le scanner
</UiButton>
</div>
<!-- Liste des numéros scannés -->
<div class="flex flex-col gap-3">
<p class="text-sm text-slate-500 font-semibold">
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
</p>
<div
v-for="(entry, index) in entries"
:key="index"
class="flex items-center gap-2"
>
<UiTextInput
:id="`scan-entry-${index}`"
:ref="(el: any) => setInputRef(el?.$el?.querySelector('input') ?? el, index)"
:model-value="entries[index]"
placeholder="Numéro national"
wrapper-class="flex-1 max-w-md"
@update:model-value="(val: string) => entries[index] = val ?? ''"
/>
<button
v-if="entries.length > 1"
type="button"
class="text-red-400 hover:text-red-600"
@click="removeEntry(index)"
>
<Icon name="mdi:close-circle" size="24" />
</button>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-4">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full"
:disabled="scannedCount === 0 || isSubmitting || !selectedCaseId"
:loading="isSubmitting"
@click="submit"
>
Valider ({{ scannedCount }})
</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
import { createBovine } from '~/services/bovine'
import { getBuildingList } from '~/services/building'
import type { BuildingData } from '~/services/dto/building-data'
const videoRef = ref<HTMLVideoElement>()
const entries = ref<string[]>([''])
const inputRefs = ref<(HTMLInputElement | null)[]>([])
const isSubmitting = ref(false)
const lastScanned = ref('')
const showScanner = ref(false)
const buildings = ref<BuildingData[]>([])
const selectedBuildingId = ref<string | number | null>(null)
const selectedCaseId = ref<string | number | null>(null)
const buildingOptions = computed(() =>
buildings.value.map(b => ({ value: b.id, label: b.label }))
)
const caseOptions = computed(() => {
const building = buildings.value.find(b => b.id === Number(selectedBuildingId.value))
if (!building?.buildingCases) return []
return [...building.buildingCases]
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
.map(c => ({
value: c.id,
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
}))
})
watch(selectedBuildingId, () => {
selectedCaseId.value = null
})
onMounted(async () => {
buildings.value = await getBuildingList()
})
const scannedCount = computed(() => entries.value.filter(e => e.trim() !== '').length)
function setInputRef(el: HTMLInputElement | null, index: number) {
inputRefs.value[index] = el
}
const scanner = useBarcodeScanner((code: string) => {
if (entries.value.some(e => e.trim() === code)) return
const emptyIndex = entries.value.findIndex(e => e.trim() === '')
if (emptyIndex !== -1) {
entries.value[emptyIndex] = code
} else {
entries.value.push(code)
}
lastScanned.value = code
entries.value.push('')
})
function startScanning() {
showScanner.value = true
nextTick(() => {
if (videoRef.value) {
scanner.start(videoRef.value)
}
})
}
function stopScanning() {
scanner.stop()
showScanner.value = false
lastScanned.value = ''
}
function removeEntry(index: number) {
entries.value.splice(index, 1)
inputRefs.value.splice(index, 1)
}
async function submit() {
const numbers = entries.value.filter(e => e.trim() !== '').map(e => e.trim())
if (numbers.length === 0 || !selectedCaseId.value) return
const caseIri = `/api/building_cases/${selectedCaseId.value}`
isSubmitting.value = true
try {
let successCount = 0
for (const nationalNumber of numbers) {
const result = await createBovine({ nationalNumber, buildingCase: caseIri })
if (result) successCount++
}
if (successCount > 0) {
clearAll()
}
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -18,11 +18,11 @@
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<WorkflowWeight
v-if="storeShipment?.currentStep === 1"
ref="grossWeightRef"
mode="gross"
ref="tareWeightRef"
mode="tare"
entity-name="shipment"
api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.gross"
:title-label="shipmentConfig.weighingLabels.tare"
:is-final="false"
:entity="storeShipment"
:get-weight-from-scale="getWeightShipment"
@@ -34,11 +34,11 @@
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
<WorkflowWeight
v-if="storeShipment?.currentStep === 3"
ref="tareWeightRef"
mode="tare"
ref="grossWeightRef"
mode="gross"
entity-name="shipment"
api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.tare"
:title-label="shipmentConfig.weighingLabels.gross"
:is-final="true"
:entity="storeShipment"
: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">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
@@ -167,6 +157,16 @@
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
</div>
<div class="mb-12">
<update-weight
@@ -248,7 +248,7 @@ const hasTareWeightError = computed(() =>
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 tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
const formIsLoading = ref(false)

View File

@@ -4,7 +4,7 @@
:columns="columns"
:items="shipmentList ?? []"
route-prefix="/shipment"
show-actions
:show-actions="auth.isAdmin"
>
<template #cell-shipmentDate="{ item }">
{{ formatDate(item.shipmentDate) }}
@@ -35,6 +35,9 @@
<script setup lang="ts">
import type { ShipmentData } from '~/services/dto/shipment-data'
import { getShipmentList, deleteShipment } from '~/services/shipment'
import { useAuthStore } from '~/stores/auth'
const auth = useAuthStore()
const columns = [
{ key: 'shipmentDate', label: 'Date et heure' },

View File

@@ -0,0 +1,42 @@
import { useApi } from '~/composables/useApi'
import type { BovineData, BovinePayload } from '~/services/dto/bovine-data'
export async function createBovine(payload: BovinePayload) {
const api = useApi()
return api.post<BovineData>('bovines', payload, {
headers: { 'Content-Type': 'application/ld+json' },
toastErrorKey: 'errors.bovine.create',
toastSuccessKey: 'success.bovine.create'
})
}
export async function createBovines(nationalNumbers: string[]): Promise<{ created: BovineData[]; errors: string[] }> {
const created: BovineData[] = []
const errors: string[] = []
for (const nationalNumber of nationalNumbers) {
try {
const bovine = await createBovine({ nationalNumber })
if (bovine) {
created.push(bovine)
}
} catch {
errors.push(nationalNumber)
}
}
return { created, errors }
}
export async function getBovine(id: number) {
const api = useApi()
return api.get<BovineData>(`bovines/${id}`)
}
export async function updateBovine(id: number, payload: BovinePayload) {
const api = useApi()
return api.patch<BovineData>(`bovines/${id}`, payload, {
toastErrorKey: 'errors.bovine.update',
toastSuccessKey: 'success.bovine.update'
})
}

View File

@@ -0,0 +1,16 @@
export interface BovineData {
id: number
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
buildingCase: string | null
supplier: string | null
}
export type BovinePayload = {
nationalNumber?: string
receivedWeight?: number | null
arrivalDate?: 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 {
id: number
caseNumber: number | null
code: string | 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,8 +1,10 @@
import type { BuildingLayoutData } from '~/services/dto/building-layout-data'
import type { BuildingCaseData } from '~/services/dto/building-case-data'
export interface BuildingData {
id: number
label: string
code: string
layouts?: BuildingLayoutData[] | null
buildingCases?: BuildingCaseData[] | 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\ReceptionType;
use App\Entity\ShipmentType;
use App\Entity\Statut;
use App\Entity\Supplier;
use App\Entity\Truck;
use App\Entity\Vehicle;
@@ -230,24 +229,6 @@ class SeedCommand extends Command
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);
$layoutByBuildingCode = [];
$layoutRows = [
@@ -274,25 +255,15 @@ class SeedCommand extends Command
}
$caseRows = [
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'status' => 'LB'],
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'status' => 'OC'],
['buildingCode' => 'B1', 'from' => 25, 'to' => 32, 'status' => 'ML'],
['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'],
['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
];
$caseByCode = [];
foreach ($caseRows as $caseRow) {
$building = $buildingRepo->findOneBy(['code' => $caseRow['buildingCode']]);
$status = $statusByCode[$caseRow['status']] ?? null;
if (!$building instanceof Building || !$status instanceof Statut) {
if (!$building instanceof Building) {
continue;
}
@@ -300,13 +271,12 @@ class SeedCommand extends Command
$code = sprintf('%s-C%d', $caseRow['buildingCode'], $caseNumber);
/** @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
->setCode($code)
->setCaseNumber($caseNumber)
->setCapacity(15)
->setIdBuilding($building)
->setStatut($status)
;
});
$caseByCode[$code] = $buildingCase;

View File

@@ -8,7 +8,6 @@ use App\Entity\Building;
use App\Entity\BuildingCase;
use App\Entity\BuildingCasePosition;
use App\Entity\BuildingLayout;
use App\Entity\Statut;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
@@ -18,10 +17,9 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
{
public function load(ObjectManager $manager): void
{
$statuts = $this->loadStatuts($manager);
$buildings = $this->getBuildingsByCode($manager, ['B1', 'B2', 'B3']);
$layouts = $this->loadLayouts($manager, $buildings);
$cases = $this->loadBuildingCases($manager, $buildings, $statuts);
$cases = $this->loadBuildingCases($manager, $buildings);
$this->loadCasePositions($manager, $layouts, $cases);
$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
*
@@ -126,34 +92,21 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
/**
* @param array<string, Building> $buildings
* @param array<string, Statut> $statuts
*
* @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);
$statusRanges = [
// B1
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'statut' => 'LB'],
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'statut' => 'OC'],
['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'],
$caseRanges = [
['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
];
$result = [];
foreach ($statusRanges as $range) {
foreach ($caseRanges as $range) {
for ($caseNumber = $range['from']; $caseNumber <= $range['to']; ++$caseNumber) {
$code = sprintf('%s-C%d', $range['buildingCode'], $caseNumber);
@@ -169,7 +122,6 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
->setCode($code)
->setCapacity(15)
->setIdBuilding($buildings[$range['buildingCode']])
->setStatut($statuts[$range['statut']])
;
$manager->persist($buildingCase);
}

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\State\BovineProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
@@ -31,12 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']],
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_ADMIN')",
processor: BovineProcessor::class,
),
],
security: "is_granted('ROLE_USER')",
@@ -46,19 +49,19 @@ class Bovine
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bovine:read'])]
#[Groups(['bovine:read', 'building_case:read'])]
private ?int $id = null;
#[ORM\Column(length: 50)]
#[Groups(['bovine:read', 'bovine:write'])]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private string $nationalNumber = '';
#[ORM\Column(nullable: true)]
#[Groups(['bovine:read', 'bovine:write'])]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?int $receivedWeight = null;
#[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'])]
private ?DateTimeImmutable $arrivalDate = null;
@@ -66,6 +69,23 @@ class Bovine
#[Groups(['bovine:read', 'bovine:write'])]
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
{
return $this->id;
@@ -118,4 +138,52 @@ class Bovine
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\GeneratedValue]
#[ORM\Column]
#[Groups(['building:read', 'reception:read'])]
#[Groups(['building:read', 'building:summary', 'reception:read'])]
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Groups(['building:read', 'reception:read'])]
#[Groups(['building:read', 'building:summary', 'reception:read'])]
private string $label = '';
#[ORM\Column(length: 50)]
#[Groups(['building:read', 'reception:read'])]
#[Groups(['building:read', 'building:summary', 'reception:read'])]
private string $code = '';
/**
@@ -53,6 +53,8 @@ class Building
* @var Collection<int, BuildingCase>
*/
#[ORM\OneToMany(targetEntity: BuildingCase::class, mappedBy: 'id_building')]
#[Groups(['building:read'])]
#[SerializedName('buildingCases')]
private Collection $buildingCases;
/**

View File

@@ -17,6 +17,10 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['building_case:read', 'building:summary']],
),
new Get(
uriTemplate: '/building_cases/{id}/weights-report',
requirements: ['id' => '\d+'],
@@ -35,20 +39,20 @@ class BuildingCase
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['building:read'])]
#[Groups(['building:read', 'building_case:read'])]
private ?int $id = null;
#[ORM\Column]
#[Groups(['building:read'])]
#[Groups(['building:read', 'building_case:read'])]
#[SerializedName('caseNumber')]
private ?int $case_number = null;
#[ORM\Column(length: 255)]
#[Groups(['building:read'])]
#[Groups(['building:read', 'building_case:read'])]
private ?string $code = null;
#[ORM\Column]
#[Groups(['building:read'])]
#[Groups(['building:read', 'building_case:read'])]
private ?int $capacity = null;
/**
@@ -58,16 +62,15 @@ class BuildingCase
private Collection $id_case_position;
#[ORM\ManyToOne(inversedBy: 'buildingCases')]
#[Groups(['building_case:read'])]
#[SerializedName('building')]
private ?Building $id_building = null;
#[ORM\ManyToOne(inversedBy: 'id_case')]
#[Groups(['building:read'])]
private ?Statut $statut = null;
/**
* @var Collection<int, Bovine>
*/
#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'buildingCase')]
#[Groups(['building_case:read'])]
private Collection $bovines;
public function __construct()
@@ -166,16 +169,17 @@ class BuildingCase
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
{
$this->statut = $statut;
return $this;
return ['label' => 'Libre', 'couleur' => '#A3B18A'];
}
/**

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 Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
@@ -40,7 +38,6 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
public function __construct(
private Environment $twig,
private EntityManagerInterface $entityManager,
private BovinApiInterface $bovinApi,
) {}
/**
@@ -68,24 +65,9 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
continue;
}
$workNumber = null;
$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) {
$headerBreedCode = $breedCode;
}
} catch (Throwable) {
// Keep row data even if external identification service is unavailable.
$breedCode = $bovine->getBreedCode();
if (null === $headerBreedCode && null !== $breedCode) {
$headerBreedCode = $breedCode;
}
$arrivalDate = $bovine->getArrivalDate();
@@ -101,8 +83,8 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
$rows[] = [
'nationalNumber' => $bovine->getNationalNumber(),
'workNumber' => $workNumber,
'birthDate' => $birthDate,
'workNumber' => $bovine->getWorkNumber(),
'birthDate' => $bovine->getBirthDate()?->format('d/m/y'),
'receivedWeight' => $bovine->getReceivedWeight(),
'arrivalDate' => $bovine->getArrivalDate()?->format('d/m/Y'),
'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
{
return 1.3;

View File

@@ -265,23 +265,15 @@
TABLEAU PRINCIPAL
========================= -->
<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>
<tr>
<th rowspan="4" class="head-big">N° de<br>travail</th>
<th rowspan="4" class="head-big head-big-weight">Poids<br>(kg)</th>
<th rowspan="4" class="head-big">Date de<br>naissance</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>
<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([]) %}
<th class="month">{{ month.name }}</th>
<th class="month" style="width:6.58%">{{ month.name }}</th>
{% endfor %}
</tr>
@@ -292,7 +284,9 @@
</tr>
<tr>
<th colspan="{{ monthHeaders|length }}" class="sub-title">POIDS PAR MOIS</th>
<th class="days">Foin</th>
<th class="days">Foin</th>
<th colspan="{{ monthHeaders|length -2 }}" class="sub-title">POIDS PAR MOIS</th>
</tr>
<tr>
@@ -315,6 +309,7 @@
{% 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>
<td class="row-birth">