feat : commande de feed des prix bovins depuis un XLSX
- app:feed-bovine-prices <file> [--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) <noreply@anthropic.com>
This commit is contained in:
59
README.md
59
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)
|
||||
|
||||
184
src/Command/FeedBovinePricesCommand.php
Normal file
184
src/Command/FeedBovinePricesCommand.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Bovine;
|
||||
use App\Entity\Supplier;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:feed-bovine-prices',
|
||||
description: 'Met à jour le poids, le prix au kilo et le fournisseur des bovins existants depuis un fichier XLSX.'
|
||||
)]
|
||||
final class FeedBovinePricesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user