From 7bf6a36b73d53bc5bde0a104acd3015af678e9e5 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 24 Apr 2026 17:01:48 +0200 Subject: [PATCH 1/6] feat : WIP prix au kilo et prix total sur les bovins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Champ pricePerKg persisté sur Bovine + migration - Getter calculé finalPrice = receivedWeight * pricePerKg - Colonnes Prix/kg et Prix total sur inventory et case - Ajustements de largeurs pour rentrer dans le layout Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/pages/infrastructure/case.vue | 33 ++++++++++++++++++++------ frontend/pages/inventory.vue | 33 ++++++++++++++++++++------ frontend/services/dto/bovine-data.ts | 3 +++ migrations/Version20260424132554.php | 31 ++++++++++++++++++++++++ src/Entity/Bovine.php | 26 ++++++++++++++++++++ 5 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 migrations/Version20260424132554.php diff --git a/frontend/pages/infrastructure/case.vue b/frontend/pages/infrastructure/case.vue index 18df4b2..7479157 100644 --- a/frontend/pages/infrastructure/case.vue +++ b/frontend/pages/infrastructure/case.vue @@ -74,6 +74,12 @@ + + + + @@ -175,15 +187,17 @@ const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[st const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]') const columns = [ - { key: 'nationalNumber', label: 'N° National', width: '160px' }, - { key: 'workNumber', label: 'N° Travail', width: '85px' }, + { key: 'nationalNumber', label: 'N° National', width: '80px' }, + { key: 'workNumber', label: 'N° Travail', width: '43px' }, { key: 'sex', label: 'Sexe', width: '70px' }, - { key: 'birthDate', label: 'Né le', width: '120px' }, + { key: 'birthDate', label: 'Né le', width: '72px' }, { key: 'age', label: 'Age', width: '110px' }, - { key: 'breedCode', label: 'Race' }, - { key: 'buildingCase.building.label', label: 'Bâtiment', width: '1.5fr' }, - { key: 'buildingCase.caseNumber', label: 'Case', width: '80px' }, - { key: 'arrivalDate', label: 'Entrée le', width: '120px' } + { key: 'breedCode', label: 'Race', width: '70px' }, + { key: 'buildingCase.building.label', label: 'Bâtiment', width: '75px' }, + { key: 'buildingCase.caseNumber', label: 'Case', width: '60px' }, + { key: 'arrivalDate', label: 'Entrée le', width: '90px' }, + { key: 'pricePerKg', label: 'Prix/kg', width: '65px' }, + { key: 'finalPrice', label: 'Prix total', width: '100px' } ] const title = computed(() => { @@ -209,6 +223,11 @@ const formatDate = (date: string | null) => { }) } +const formatPrice = (price: number | null) => { + if (price === null || price === undefined) return '—' + return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €` +} + const rowClass = (item: BovineData): string => { if (item.ageMonths === null || item.ageMonths === undefined) return '' if (item.ageMonths >= 24) return 'bg-violet-300 hover:bg-violet-400' diff --git a/frontend/pages/inventory.vue b/frontend/pages/inventory.vue index 6948db7..399acde 100644 --- a/frontend/pages/inventory.vue +++ b/frontend/pages/inventory.vue @@ -102,6 +102,12 @@ + + @@ -117,6 +123,12 @@ + + @@ -256,15 +268,17 @@ const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[st const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]') const columns = [ - { key: 'nationalNumber', label: 'N° National', width: '160px' }, - { key: 'workNumber', label: 'N° Travail', width: '85px' }, + { key: 'nationalNumber', label: 'N° National', width: '80px' }, + { key: 'workNumber', label: 'N° Travail', width: '43px' }, { key: 'sex', label: 'Sexe', width: '70px' }, - { key: 'birthDate', label: 'Né le', width: '120px' }, + { key: 'birthDate', label: 'Né le', width: '72px' }, { key: 'age', label: 'Age', width: '110px' }, - { key: 'breedCode', label: 'Race' }, - { key: 'buildingCase.building.label', label: 'Bâtiment', width: '1.5fr' }, - { key: 'buildingCase.caseNumber', label: 'Case', width: '80px' }, - { key: 'arrivalDate', label: 'Entrée le', width: '120px' } + { key: 'breedCode', label: 'Race', width: '70px' }, + { key: 'buildingCase.building.label', label: 'Bâtiment', width: '75px' }, + { key: 'buildingCase.caseNumber', label: 'Case', width: '60px' }, + { key: 'arrivalDate', label: 'Entrée le', width: '90px' }, + { key: 'pricePerKg', label: 'Prix/kg', width: '65px' }, + { key: 'finalPrice', label: 'Prix total', width: '100px' } ] const formatDate = (date: string | null) => { @@ -278,6 +292,11 @@ const formatDate = (date: string | null) => { }) } +const formatPrice = (price: number | null) => { + if (price === null || price === undefined) return '—' + return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €` +} + const rowClass = (item: BovineData): string => { if (item.ageMonths === null || item.ageMonths === undefined) return '' if (item.ageMonths >= 24) return 'bg-violet-300 hover:bg-violet-400' diff --git a/frontend/services/dto/bovine-data.ts b/frontend/services/dto/bovine-data.ts index db5d857..06b250f 100644 --- a/frontend/services/dto/bovine-data.ts +++ b/frontend/services/dto/bovine-data.ts @@ -7,6 +7,8 @@ export interface BovineData { id: number nationalNumber: string receivedWeight: number | null + pricePerKg: number | null + finalPrice: number | null arrivalDate: string | null exitDate: string | null buildingCase: BovineBuildingCaseRef | null @@ -22,6 +24,7 @@ export interface BovineData { export type BovinePayload = { nationalNumber?: string receivedWeight?: number | null + pricePerKg?: number | null arrivalDate?: string | null buildingCase?: string | null supplier?: string | null diff --git a/migrations/Version20260424132554.php b/migrations/Version20260424132554.php new file mode 100644 index 0000000..b8afa70 --- /dev/null +++ b/migrations/Version20260424132554.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE bovine ADD price_per_kg DOUBLE PRECISION 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 price_per_kg'); + } +} diff --git a/src/Entity/Bovine.php b/src/Entity/Bovine.php index 25d59a4..0c7cd09 100644 --- a/src/Entity/Bovine.php +++ b/src/Entity/Bovine.php @@ -77,6 +77,10 @@ class Bovine #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])] private ?int $receivedWeight = null; + #[ORM\Column(type: 'float', nullable: true)] + #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])] + private ?float $pricePerKg = null; + #[ORM\Column(type: 'date_immutable', nullable: true)] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] @@ -151,6 +155,28 @@ class Bovine return $this; } + public function getPricePerKg(): ?float + { + return $this->pricePerKg; + } + + public function setPricePerKg(?float $pricePerKg): static + { + $this->pricePerKg = $pricePerKg; + + return $this; + } + + #[Groups(['bovine:read', 'building_case:read'])] + public function getFinalPrice(): ?float + { + if (null === $this->receivedWeight || null === $this->pricePerKg) { + return null; + } + + return $this->receivedWeight * $this->pricePerKg; + } + public function getArrivalDate(): ?DateTimeImmutable { return $this->arrivalDate; -- 2.39.5 From b3b7746bc5ba83d01c7e6371fa8a63d000c7076d Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 27 Apr 2026 16:16:31 +0200 Subject: [PATCH 2/6] feat : commande de feed des prix bovins depuis un XLSX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app:feed-bovine-prices [--dry-run] - Met à jour receivedWeight, pricePerKg et supplier sur les bovins existants (pas de création) - Strip défensif du préfixe FR - Supplier introuvable -> bovin updaté avec supplier=null + warning - Préload des suppliers pour lookup O(1) - Documentation ajoutée au README Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 59 ++++++++ src/Command/FeedBovinePricesCommand.php | 184 ++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 src/Command/FeedBovinePricesCommand.php diff --git a/README.md b/README.md index 790a0af..28da722 100644 --- a/README.md +++ b/README.md @@ -178,3 +178,62 @@ Pour suivre les logs en temps réel : - tail -f var/log/dev.log - tail -f var/log/prod.log + +## Feed des prix bovins + +Une commande Symfony permet de mettre à jour le **poids à l'arrivée**, le **prix au kilo** et le **fournisseur** des bovins existants à partir d'un fichier XLSX. La commande **ne crée jamais de nouveau bovin** : elle complète seulement ceux déjà présents en BDD. + +### Format du fichier XLSX attendu + +Pas de ligne d'en-tête, 4 colonnes dans cet ordre : + +| Colonne | Champ | Format | Exemple | +|---------|-------|--------|---------| +| A | Numéro national | Avec ou sans préfixe `FR ` (insensible casse) | `FR 7979580026` ou `7979580026` | +| B | Fournisseur | Texte libre, casse ignorée | `TERRENA` | +| C | Poids à l'arrivée (kg) | Entier | `368` | +| D | Prix au kilo | Décimal | `5.7` | + +### Comportement + +- **Numéro national** : le préfixe `FR` (avec ou sans espace) est retiré s'il est présent. Sinon la valeur est utilisée telle quelle. +- **Bovin introuvable** en BDD → ligne ignorée, log warning à la fin avec aperçu. +- **Fournisseur introuvable** en BDD → le bovin est mis à jour quand même avec `supplier = null`, log warning. +- **Cellules `weight` / `price` vides ou non numériques** → champ non modifié. +- La commande est **idempotente** : peut être relancée sans effet de bord. + +### Lancement en dev + +Copie le fichier dans la racine du projet (mappée dans le container sous `/var/www/html`), puis : + +```bash +# Simulation sans écriture en BDD +docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx --dry-run + +# Persistance effective +docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx +``` + +### Lancement en prod + +```bash +# 1. Envoyer le fichier sur le serveur +scp feed_bovin.xlsx ferme-prod:/tmp/ + +# 2. SSH sur le serveur et lancer la commande dans le dossier de l'app +ssh ferme-prod +cd /var/www/ferme +php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run # vérification +php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx # exécution +rm /tmp/feed_bovin.xlsx # nettoyage +``` + +### Sortie attendue + +À la fin, un tableau récapitule : + +- Lignes totales lues +- Bovins mis à jour +- Bovins introuvables (avec aperçu des 10 premiers numéros) +- Lignes invalides (numéro national vide) +- Fournisseurs introuvables (avec liste et compte par nom) diff --git a/src/Command/FeedBovinePricesCommand.php b/src/Command/FeedBovinePricesCommand.php new file mode 100644 index 0000000..696b30b --- /dev/null +++ b/src/Command/FeedBovinePricesCommand.php @@ -0,0 +1,184 @@ +addArgument('file', InputArgument::REQUIRED, 'Chemin absolu vers le fichier XLSX') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simule sans persister en BDD') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $file = (string) $input->getArgument('file'); + $dryRun = (bool) $input->getOption('dry-run'); + + if (!file_exists($file)) { + $io->error(sprintf('Fichier introuvable : %s', $file)); + + return Command::FAILURE; + } + + $io->title('Feed bovins depuis '.basename($file)); + if ($dryRun) { + $io->warning('Dry-run activé : aucune écriture en BDD.'); + } + + try { + $spreadsheet = IOFactory::load($file); + } catch (Throwable $e) { + $io->error('Impossible de lire le fichier : '.$e->getMessage()); + + return Command::FAILURE; + } + + $sheet = $spreadsheet->getActiveSheet(); + $highestRow = $sheet->getHighestRow(); + + // Pré-chargement des fournisseurs pour des lookups rapides (insensible casse). + $supplierByName = []; + foreach ($this->em->getRepository(Supplier::class)->findAll() as $supplier) { + $supplierByName[mb_strtoupper($supplier->getName())] = $supplier; + } + + $bovineRepo = $this->em->getRepository(Bovine::class); + + $stats = [ + 'total' => 0, + 'updated' => 0, + 'notFound' => 0, + 'invalid' => 0, + 'supplierMissing' => 0, + ]; + $missingNationalNumbers = []; + $missingSuppliers = []; + + $io->progressStart($highestRow); + for ($row = 1; $row <= $highestRow; ++$row) { + ++$stats['total']; + + $rawNationalNumber = (string) ($sheet->getCell([1, $row])->getValue() ?? ''); + $rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? ''); + $rawWeight = $sheet->getCell([3, $row])->getValue(); + $rawPrice = $sheet->getCell([4, $row])->getValue(); + + $rawNationalNumber = trim($rawNationalNumber); + if ('' === $rawNationalNumber) { + ++$stats['invalid']; + $io->progressAdvance(); + + continue; + } + + // Garde : strip "FR" + espace optionnel uniquement s'il est présent. + $nationalNumber = preg_replace('/^FR\s*/i', '', $rawNationalNumber); + + $bovine = $bovineRepo->findOneBy(['nationalNumber' => $nationalNumber]); + if (null === $bovine) { + ++$stats['notFound']; + $missingNationalNumbers[] = $nationalNumber; + $io->progressAdvance(); + + continue; + } + + // Lookup supplier (peut être null si introuvable ou colonne vide). + $supplier = null; + $supplierName = mb_strtoupper(trim($rawSupplier)); + if ('' !== $supplierName) { + $supplier = $supplierByName[$supplierName] ?? null; + if (null === $supplier) { + ++$stats['supplierMissing']; + $missingSuppliers[$supplierName] = ($missingSuppliers[$supplierName] ?? 0) + 1; + } + } + + $weight = is_numeric($rawWeight) ? (int) $rawWeight : null; + $price = is_numeric($rawPrice) ? (float) $rawPrice : null; + + if (null !== $weight) { + $bovine->setReceivedWeight($weight); + } + if (null !== $price) { + $bovine->setPricePerKg($price); + } + $bovine->setSupplier($supplier); + + ++$stats['updated']; + $io->progressAdvance(); + } + $io->progressFinish(); + + if (!$dryRun) { + $this->em->flush(); + } + + $io->section('Résultats'); + $io->table( + ['Métrique', 'Valeur'], + [ + ['Lignes totales', $stats['total']], + ['Bovins mis à jour', $stats['updated']], + ['Bovins introuvables', $stats['notFound']], + ['Lignes invalides', $stats['invalid']], + ['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']], + ] + ); + + if ([] !== $missingNationalNumbers) { + $preview = array_slice($missingNationalNumbers, 0, 10); + $io->warning(sprintf( + '%d bovin(s) introuvable(s). Aperçu : %s%s', + count($missingNationalNumbers), + implode(', ', $preview), + count($missingNationalNumbers) > 10 ? '…' : '', + )); + } + + if ([] !== $missingSuppliers) { + $list = []; + foreach ($missingSuppliers as $name => $count) { + $list[] = sprintf('%s (%d)', $name, $count); + } + $io->warning('Fournisseurs introuvables (bovins rattachés en null) : '.implode(', ', $list)); + } + + if ($dryRun) { + $io->success('Dry-run terminé. Relance sans --dry-run pour persister.'); + } else { + $io->success('Feed terminé avec succès.'); + } + + return Command::SUCCESS; + } +} -- 2.39.5 From 569d3b373f395c098183caae691851cf3558676f Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 27 Apr 2026 17:09:13 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat=20:=20refonte=20de=20l'affichage=20des?= =?UTF-8?q?=20=C3=A2ges=20et=20restriction=20des=20prix=20aux=20admins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repository BovineRepository avec getInventoryStats en DQL - Sécurité ApiProperty ROLE_ADMIN sur pricePerKg et finalPrice - Endpoint inventory-export passe en ROLE_ADMIN - Composable useBovineColumns mutualisé entre inventory et case (admin/user séparés) - Stats par tranche d'âge filtrables par buildingCaseId - Légende avec cartes colorées pleines + texte blanc - Coloration de la cellule Age (badge) au lieu de toute la ligne - Décalage couleurs : red ≥ 24, orange 22-24, yellow 20-22 Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/composables/useBovineColumns.ts | 47 +++++++++++ frontend/pages/infrastructure/case.vue | 78 +++++++++++++------ frontend/pages/inventory.vue | 51 +++++------- frontend/utils/bovine-age.ts | 8 ++ src/ApiResource/BovineInventoryExport.php | 2 +- src/Entity/Bovine.php | 5 +- src/Repository/BovineRepository.php | 53 +++++++++++++ .../Bovin/BovineInventoryExportProvider.php | 12 +-- .../Bovin/BovineInventoryStatsProvider.php | 27 +++---- 9 files changed, 204 insertions(+), 79 deletions(-) create mode 100644 frontend/composables/useBovineColumns.ts create mode 100644 src/Repository/BovineRepository.php diff --git a/frontend/composables/useBovineColumns.ts b/frontend/composables/useBovineColumns.ts new file mode 100644 index 0000000..bdb228b --- /dev/null +++ b/frontend/composables/useBovineColumns.ts @@ -0,0 +1,47 @@ +import { computed } from 'vue' +import { useAuthStore } from '~/stores/auth' + +export interface BovineColumn { + key: string + label: string + width?: string +} + +/** + * Définition partagée des colonnes des tableaux bovins (inventory + case). + * Deux définitions distinctes admin/user pour pouvoir ajuster les largeurs + * indépendamment selon le contexte. + */ +export const useBovineColumns = () => { + const auth = useAuthStore() + + const adminColumns: BovineColumn[] = [ + { key: 'nationalNumber', label: 'N° National', width: '80px' }, + { key: 'workNumber', label: 'N° Travail', width: '60px' }, + { key: 'sex', label: 'Sexe', width: '70px' }, + { key: 'birthDate', label: 'Né le', width: '72px' }, + { key: 'age', label: 'Age', width: '110px' }, + { key: 'breedCode', label: 'Race', width: '70px' }, + { key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' }, + { key: 'buildingCase.caseNumber', label: 'Case', width: '42px' }, + { key: 'arrivalDate', label: 'Entrée le', width: '90px' }, + { key: 'pricePerKg', label: 'Prix/kg', width: '65px' }, + { key: 'finalPrice', label: 'Prix total', width: '100px' } + ] + + const userColumns: BovineColumn[] = [ + { key: 'nationalNumber', label: 'N° National', width: '80px' }, + { key: 'workNumber', label: 'N° Travail', width: '60px' }, + { key: 'sex', label: 'Sexe', width: '70px' }, + { key: 'birthDate', label: 'Né le', width: '72px' }, + { key: 'age', label: 'Age', width: '110px' }, + { key: 'breedCode', label: 'Race', width: '70px' }, + { key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' }, + { key: 'buildingCase.caseNumber', label: 'Case', width: '42px' }, + { key: 'arrivalDate', label: 'Entrée le', width: '90px' } + ] + + const columns = computed(() => auth.isAdmin ? adminColumns : userColumns) + + return { columns } +} diff --git a/frontend/pages/infrastructure/case.vue b/frontend/pages/infrastructure/case.vue index 7479157..9306352 100644 --- a/frontend/pages/infrastructure/case.vue +++ b/frontend/pages/infrastructure/case.vue @@ -33,7 +33,22 @@ -
+
+
+ {{ stats.over24 }} + ≥ 24 mois +
+
+ {{ stats.between22And24 }} + 22 – 24 mois +
+
+ {{ stats.between20And22 }} + 20 – 22 mois +
+
+ +