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; + } +}