Compare commits

..

5 Commits

Author SHA1 Message Date
fcb6f742af feat : ordre d'affichage configurable sur les bâtiments
- Colonne display_order ajoutée à Building avec migration
- Seed des valeurs par code : B3 -> 1, B2 -> 2, B1 -> 3, ZT -> 4
- ApiResource trie par displayOrder ASC puis id (NULLs en fin de liste)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:51:27 +02:00
aa401f48f9 refactor : remplacement du SQL brut par du DQL UPDATE dans les hooks PostPersist
- Reception et Shipment utilisent createQuery au lieu de executeStatement
- Bypass toujours l'unit of work, même comportement, mais via Doctrine
- Mise à jour de la règle CLAUDE.md : repository custom autorisé, SQL brut interdit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:10:37 +02:00
569d3b373f feat : refonte de l'affichage des âges et restriction des prix aux admins
- 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) <noreply@anthropic.com>
2026-04-27 17:09:13 +02:00
b3b7746bc5 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>
2026-04-27 16:16:31 +02:00
7bf6a36b73 feat : WIP prix au kilo et prix total sur les bovins
- 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) <noreply@anthropic.com>
2026-04-24 17:01:48 +02:00
16 changed files with 57 additions and 290 deletions

View File

@@ -193,14 +193,12 @@ Pas de ligne d'en-tête, 4 colonnes dans cet ordre :
| B | Fournisseur | Texte libre, casse ignorée | `TERRENA` | | B | Fournisseur | Texte libre, casse ignorée | `TERRENA` |
| C | Poids à l'arrivée (kg) | Entier | `368` | | C | Poids à l'arrivée (kg) | Entier | `368` |
| D | Prix au kilo | Décimal | `5.7` | | D | Prix au kilo | Décimal | `5.7` |
| E | Code bâtiment (optionnel) | `B1`, `B2`, `B3`, `ZT` (casse ignorée) | `B2` |
### Comportement ### 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. - **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. - **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. - **Fournisseur introuvable** en BDD → le bovin est mis à jour quand même avec `supplier = null`, log warning.
- **Bâtiment** (colonne E) : recherché par `code` (insensible casse). Set uniquement si le bovin n'a pas déjà une `buildingCase` assignée (la case prime sur le bâtiment direct côté affichage). Si code introuvable → log warning, champ non set.
- **Cellules `weight` / `price` vides ou non numériques** → champ non modifié. - **Cellules `weight` / `price` vides ou non numériques** → champ non modifié.
- La commande est **idempotente** : peut être relancée sans effet de bord. - La commande est **idempotente** : peut être relancée sans effet de bord.
@@ -218,30 +216,18 @@ docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bo
### Lancement en prod ### Lancement en prod
Le user SSH n'a généralement pas les droits d'écriture sur `/var/www/ferme/` ; on passe donc le fichier par `/tmp` et on pointe la commande dessus (le chemin du XLSX est juste un argument).
```bash ```bash
# 1. Copier le fichier sur le serveur dans /tmp (accessible en écriture) # 1. Envoyer le fichier sur le serveur
scp feed_bovin.xlsx <user>@<host>:/tmp/ scp feed_bovin.xlsx ferme-prod:/tmp/
# 2. SSH sur le serveur # 2. SSH sur le serveur et lancer la commande dans le dossier de l'app
ssh <user>@<host> ssh ferme-prod
# 3. Se placer dans le dossier de l'app (pour bin/console)
cd /var/www/ferme cd /var/www/ferme
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run # vérification
# 4. Dry-run pour vérifier sans rien écrire php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx # exécution
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run rm /tmp/feed_bovin.xlsx # nettoyage
# 5. Persistance effective
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx
# 6. Cleanup
rm /tmp/feed_bovin.xlsx
``` ```
> Si à l'étape 4 le user PHP (souvent `www-data`) n'arrive pas à lire le fichier (`Permission denied`), donne-lui les droits de lecture avant : `chmod 644 /tmp/feed_bovin.xlsx`.
### Sortie attendue ### Sortie attendue
À la fin, un tableau récapitule : À la fin, un tableau récapitule :
@@ -251,4 +237,3 @@ rm /tmp/feed_bovin.xlsx
- Bovins introuvables (avec aperçu des 10 premiers numéros) - Bovins introuvables (avec aperçu des 10 premiers numéros)
- Lignes invalides (numéro national vide) - Lignes invalides (numéro national vide)
- Fournisseurs introuvables (avec liste et compte par nom) - Fournisseurs introuvables (avec liste et compte par nom)
- Bâtiments introuvables (avec liste des codes inconnus)

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.0.91' app.version: '0.0.89'

View File

@@ -7,77 +7,41 @@ export interface BovineColumn {
width?: string width?: string
} }
export interface UseBovineColumnsOptions {
/**
* 'inventory' (par défaut) : colonnes complètes incluant Bâtiment + Case.
* 'case' : pas de Bâtiment ni Case (déjà dans le titre de la page),
* largeurs élargies pour combler l'espace.
*/
variant?: 'inventory' | 'case'
}
/** /**
* Définition partagée des colonnes des tableaux bovins (inventory + case). * Définition partagée des colonnes des tableaux bovins (inventory + case).
* Variants distincts pour chaque écran et chaque rôle (admin/user) afin de * Deux définitions distinctes admin/user pour pouvoir ajuster les largeurs
* pouvoir ajuster les largeurs indépendamment. * indépendamment selon le contexte.
*/ */
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => { export const useBovineColumns = () => {
const auth = useAuthStore() const auth = useAuthStore()
const adminColumnsInventory: BovineColumn[] = [ const adminColumns: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' }, { key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' }, { key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' }, { key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '72px' }, { key: 'birthDate', label: 'Né le', width: '72px' },
{ key: 'age', label: 'Age', width: '110px' }, { key: 'age', label: 'Age', width: '110px' },
{ key: 'bovineType.label', label: 'Race', width: '90px' }, { key: 'breedCode', label: 'Race', width: '70px' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' }, { key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' }, { key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }, { key: 'arrivalDate', label: 'Entrée le', width: '90px' },
{ key: 'pricePerKg', label: 'Prix/kg', width: '65px' }, { key: 'pricePerKg', label: 'Prix/kg', width: '65px' },
{ key: 'finalPrice', label: 'Prix total', width: '80px' } { key: 'finalPrice', label: 'Prix total', width: '100px' }
] ]
const userColumnsInventory: BovineColumn[] = [ const userColumns: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' }, { key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' }, { key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' }, { key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '72px' }, { key: 'birthDate', label: 'Né le', width: '72px' },
{ key: 'age', label: 'Age', width: '110px' }, { key: 'age', label: 'Age', width: '110px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' }, { key: 'breedCode', label: 'Race', width: '70px' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '120px' }, { key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' }, { key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' } { key: 'arrivalDate', label: 'Entrée le', width: '90px' }
] ]
const adminColumnsCase: BovineColumn[] = [ const columns = computed<BovineColumn[]>(() => auth.isAdmin ? adminColumns : userColumns)
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '90px' },
{ key: 'birthDate', label: 'Né le', width: '100px' },
{ key: 'age', label: 'Age', width: '90px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
{ key: 'arrivalDate', label: 'Entrée le', width: '110px' },
{ key: 'pricePerKg', label: 'Prix/kg', width: '85px' },
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
]
const userColumnsCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
{ key: 'sex', label: 'Sexe', width: '110px' },
{ key: 'birthDate', label: 'Né le', width: '140px' },
{ key: 'age', label: 'Age', width: '130px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
{ key: 'arrivalDate', label: 'Entrée le', width: '170px' }
]
const columns = computed<BovineColumn[]>(() => {
if (options.variant === 'case') {
return auth.isAdmin ? adminColumnsCase : userColumnsCase
}
return auth.isAdmin ? adminColumnsInventory : userColumnsInventory
})
return { columns } return { columns }
} }

View File

@@ -94,13 +94,19 @@
<template #header-finalPrice> <template #header-finalPrice>
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled /> <UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
</template> </template>
<template #header-bovineType.label> <template #header-breedCode>
<UiTextInput <UiTextInput
v-model="filters['bovineType.label']" v-model="filters.breedCode"
placeholder="Race" placeholder="Race"
size="compact" size="compact"
/> />
</template> </template>
<template #header-buildingCase.building.label>
<UiTextInput :model-value="''" placeholder="Bâtiment" size="compact" disabled />
</template>
<template #header-buildingCase.caseNumber>
<UiTextInput :model-value="''" placeholder="Case" size="compact" disabled />
</template>
<template #header-arrivalDate> <template #header-arrivalDate>
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" /> <UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
</template> </template>
@@ -118,6 +124,12 @@
<template #cell-arrivalDate="{ item }"> <template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }} {{ formatDate(item.arrivalDate) }}
</template> </template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.buildingCase?.building?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
</template>
<template #cell-pricePerKg="{ item }"> <template #cell-pricePerKg="{ item }">
{{ formatPrice(item.pricePerKg) }} {{ formatPrice(item.pricePerKg) }}
</template> </template>
@@ -184,7 +196,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
buildingCase: '', buildingCase: '',
nationalNumber: '', nationalNumber: '',
workNumber: '', workNumber: '',
'bovineType.label': '', breedCode: '',
sex: '', sex: '',
'arrivalDate[after]': '', 'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '', 'arrivalDate[strictly_before]': '',
@@ -222,7 +234,7 @@ const singleDateFilter = (afterKey: string, beforeKey: string) =>
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]') const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]') const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
const { columns } = useBovineColumns({ variant: 'case' }) const { columns } = useBovineColumns()
const title = computed(() => { const title = computed(() => {
if (!buildingCase.value) return '' if (!buildingCase.value) return ''

View File

@@ -83,9 +83,9 @@
<template #header-birthDate> <template #header-birthDate>
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" /> <UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
</template> </template>
<template #header-bovineType.label> <template #header-breedCode>
<UiTextInput <UiTextInput
v-model="filters['bovineType.label']" v-model="filters.breedCode"
placeholder="Race" placeholder="Race"
size="compact" size="compact"
/> />
@@ -123,7 +123,7 @@
{{ formatDate(item.arrivalDate) }} {{ formatDate(item.arrivalDate) }}
</template> </template>
<template #cell-buildingCase.building.label="{ item }"> <template #cell-buildingCase.building.label="{ item }">
{{ item.effectiveBuilding?.label ?? '—' }} {{ item.buildingCase?.building?.label ?? '—' }}
</template> </template>
<template #cell-buildingCase.caseNumber="{ item }"> <template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }} {{ item.buildingCase?.caseNumber ?? '—' }}
@@ -236,7 +236,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
'exists[exitedAt]': 'false', 'exists[exitedAt]': 'false',
nationalNumber: '', nationalNumber: '',
workNumber: '', workNumber: '',
'bovineType.label': '', breedCode: '',
sex: '', sex: '',
'arrivalDate[after]': '', 'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '', 'arrivalDate[strictly_before]': '',

View File

@@ -1,10 +1,6 @@
export interface BovineBuildingRef {
label: string
}
export interface BovineBuildingCaseRef { export interface BovineBuildingCaseRef {
caseNumber: number | null caseNumber: number | null
building: BovineBuildingRef | null building: { label: string } | null
} }
export interface BovineData { export interface BovineData {
@@ -16,12 +12,10 @@ export interface BovineData {
arrivalDate: string | null arrivalDate: string | null
exitDate: string | null exitDate: string | null
buildingCase: BovineBuildingCaseRef | null buildingCase: BovineBuildingCaseRef | null
building: BovineBuildingRef | null
effectiveBuilding: BovineBuildingRef | null
supplier: string | null supplier: string | null
workNumber: string | null workNumber: string | null
birthDate: string | null birthDate: string | null
bovineType: { id: number; label: string; code: string } | null breedCode: string | null
sex: string | null sex: string | null
ageMonths: number | null ageMonths: number | null
exitedAt: string | null exitedAt: string | null

View File

@@ -1,35 +0,0 @@
<?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 Version20260428061801 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 building_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F4D2A7E12 FOREIGN KEY (building_id) REFERENCES building (id)');
$this->addSql('CREATE INDEX IDX_2068337F4D2A7E12 ON bovine (building_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_2068337F4D2A7E12');
$this->addSql('DROP INDEX IDX_2068337F4D2A7E12');
$this->addSql('ALTER TABLE bovine DROP building_id');
}
}

View File

@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Bascule de Bovine.breed_code (string) vers une relation Bovine.bovine_type_id (FK).
* Ajoute au passage les BovineType manquants (Aubrac=14, Croisé=39, Blonde d'aquitaine=79).
*/
final class Version20260428065800 extends AbstractMigration
{
public function getDescription(): string
{
return 'Migration breedCode -> relation BovineType + ajout des races manquantes.';
}
public function up(Schema $schema): void
{
// 1. Insertion des BovineType manquants (idempotent via NOT EXISTS).
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Aubrac', '14' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '14')");
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Croisé', '39' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '39')");
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Blonde d''aquitaine', '79' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '79')");
// 2. Ajout de la colonne FK + index.
$this->addSql('ALTER TABLE bovine ADD bovine_type_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_2068337F7899F32E ON bovine (bovine_type_id)');
// 3. Backfill : associe chaque bovin à son BovineType via le code.
$this->addSql('UPDATE bovine SET bovine_type_id = (SELECT id FROM bovine_type WHERE bovine_type.code = bovine.breed_code) WHERE breed_code IS NOT NULL');
// 4. Contrainte de clé étrangère (après backfill pour éviter une violation transitoire).
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F7899F32E FOREIGN KEY (bovine_type_id) REFERENCES bovine_type (id)');
// 5. Drop de l'ancienne colonne string.
$this->addSql('ALTER TABLE bovine DROP breed_code');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine ADD breed_code VARCHAR(20) DEFAULT NULL');
$this->addSql('UPDATE bovine SET breed_code = (SELECT code FROM bovine_type WHERE bovine_type.id = bovine.bovine_type_id) WHERE bovine_type_id IS NOT NULL');
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F7899F32E');
$this->addSql('DROP INDEX IDX_2068337F7899F32E');
$this->addSql('ALTER TABLE bovine DROP bovine_type_id');
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\Entity\Bovine; use App\Entity\Bovine;
use App\Entity\Building;
use App\Entity\Supplier; use App\Entity\Supplier;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
@@ -72,12 +71,6 @@ final class FeedBovinePricesCommand extends Command
$supplierByName[mb_strtoupper($supplier->getName())] = $supplier; $supplierByName[mb_strtoupper($supplier->getName())] = $supplier;
} }
// Pré-chargement des bâtiments par code (insensible casse).
$buildingByCode = [];
foreach ($this->em->getRepository(Building::class)->findAll() as $building) {
$buildingByCode[mb_strtoupper($building->getCode())] = $building;
}
$bovineRepo = $this->em->getRepository(Bovine::class); $bovineRepo = $this->em->getRepository(Bovine::class);
$stats = [ $stats = [
@@ -86,11 +79,9 @@ final class FeedBovinePricesCommand extends Command
'notFound' => 0, 'notFound' => 0,
'invalid' => 0, 'invalid' => 0,
'supplierMissing' => 0, 'supplierMissing' => 0,
'buildingMissing' => 0,
]; ];
$missingNationalNumbers = []; $missingNationalNumbers = [];
$missingSuppliers = []; $missingSuppliers = [];
$missingBuildings = [];
$io->progressStart($highestRow); $io->progressStart($highestRow);
for ($row = 1; $row <= $highestRow; ++$row) { for ($row = 1; $row <= $highestRow; ++$row) {
@@ -100,7 +91,6 @@ final class FeedBovinePricesCommand extends Command
$rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? ''); $rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? '');
$rawWeight = $sheet->getCell([3, $row])->getValue(); $rawWeight = $sheet->getCell([3, $row])->getValue();
$rawPrice = $sheet->getCell([4, $row])->getValue(); $rawPrice = $sheet->getCell([4, $row])->getValue();
$rawBuilding = (string) ($sheet->getCell([5, $row])->getValue() ?? '');
$rawNationalNumber = trim($rawNationalNumber); $rawNationalNumber = trim($rawNationalNumber);
if ('' === $rawNationalNumber) { if ('' === $rawNationalNumber) {
@@ -144,18 +134,6 @@ final class FeedBovinePricesCommand extends Command
} }
$bovine->setSupplier($supplier); $bovine->setSupplier($supplier);
// Bâtiment direct : on n'écrase pas une affectation à une case existante.
$buildingCode = mb_strtoupper(trim($rawBuilding));
if ('' !== $buildingCode && null === $bovine->getBuildingCase()) {
$building = $buildingByCode[$buildingCode] ?? null;
if (null !== $building) {
$bovine->setBuilding($building);
} else {
++$stats['buildingMissing'];
$missingBuildings[$buildingCode] = ($missingBuildings[$buildingCode] ?? 0) + 1;
}
}
++$stats['updated']; ++$stats['updated'];
$io->progressAdvance(); $io->progressAdvance();
} }
@@ -174,7 +152,6 @@ final class FeedBovinePricesCommand extends Command
['Bovins introuvables', $stats['notFound']], ['Bovins introuvables', $stats['notFound']],
['Lignes invalides', $stats['invalid']], ['Lignes invalides', $stats['invalid']],
['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']], ['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']],
['Bâtiments introuvables (building non set)', $stats['buildingMissing']],
] ]
); );
@@ -196,14 +173,6 @@ final class FeedBovinePricesCommand extends Command
$io->warning('Fournisseurs introuvables (bovins rattachés en null) : '.implode(', ', $list)); $io->warning('Fournisseurs introuvables (bovins rattachés en null) : '.implode(', ', $list));
} }
if ([] !== $missingBuildings) {
$list = [];
foreach ($missingBuildings as $code => $count) {
$list[] = sprintf('%s (%d)', $code, $count);
}
$io->warning('Bâtiments introuvables (champ non renseigné) : '.implode(', ', $list));
}
if ($dryRun) { if ($dryRun) {
$io->success('Dry-run terminé. Relance sans --dry-run pour persister.'); $io->success('Dry-run terminé. Relance sans --dry-run pour persister.');
} else { } else {

View File

@@ -476,12 +476,9 @@ class SeedCommand extends Command
private function seedBovineTypes(): void private function seedBovineTypes(): void
{ {
$bovineTypes = [ $bovineTypes = [
['label' => 'Aubrac', 'code' => '14'],
['label' => 'Limousine', 'code' => '34'], ['label' => 'Limousine', 'code' => '34'],
['label' => 'Charolaise', 'code' => '38'], ['label' => 'Charolaise', 'code' => '38'],
['label' => 'Croisé', 'code' => '39'],
['label' => 'Parthenaise', 'code' => '71'], ['label' => 'Parthenaise', 'code' => '71'],
['label' => "Blonde d'aquitaine", 'code' => '79'],
]; ];
foreach ($bovineTypes as $type) { foreach ($bovineTypes as $type) {
$this->upsertByCode(BovineType::class, $type['code'], static function (BovineType $entity) use ($type) { $this->upsertByCode(BovineType::class, $type['code'], static function (BovineType $entity) use ($type) {

View File

@@ -77,12 +77,9 @@ class ReferenceFixtures extends Fixture
} }
$bovineTypes = [ $bovineTypes = [
['label' => 'Aubrac', 'code' => '14'],
['label' => 'Limousine', 'code' => '34'], ['label' => 'Limousine', 'code' => '34'],
['label' => 'Charolaise', 'code' => '38'], ['label' => 'Charolaise', 'code' => '38'],
['label' => 'Croisé', 'code' => '39'],
['label' => 'Parthenaise', 'code' => '71'], ['label' => 'Parthenaise', 'code' => '71'],
['label' => "Blonde d'aquitaine", 'code' => '79'],
]; ];
foreach ($bovineTypes as $type) { foreach ($bovineTypes as $type) {
$bovineType = new BovineType() $bovineType = new BovineType()

View File

@@ -29,8 +29,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ApiFilter(SearchFilter::class, properties: [ #[ApiFilter(SearchFilter::class, properties: [
'nationalNumber' => 'ipartial', 'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial', 'workNumber' => 'ipartial',
'bovineType.label' => 'ipartial', 'breedCode' => 'ipartial',
'bovineType.code' => 'ipartial',
'sex' => 'exact', 'sex' => 'exact',
'buildingCase' => 'exact', 'buildingCase' => 'exact',
'receivedWeight' => 'exact', 'receivedWeight' => 'exact',
@@ -94,11 +93,6 @@ class Bovine
#[ApiProperty(readableLink: true)] #[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null; private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?Building $building = null;
#[ORM\ManyToOne] #[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?Supplier $supplier = null; private ?Supplier $supplier = null;
@@ -112,10 +106,9 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $birthDate = null; private ?DateTimeImmutable $birthDate = null;
#[ORM\ManyToOne] #[ORM\Column(length: 20, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])] #[Groups(['bovine:read', 'building_case:read'])]
#[ApiProperty(readableLink: true)] private ?string $breedCode = null;
private ?BovineType $bovineType = null;
#[ORM\Column(length: 1, nullable: true)] #[ORM\Column(length: 1, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])] #[Groups(['bovine:read', 'building_case:read'])]
@@ -211,28 +204,6 @@ class Bovine
return $this; return $this;
} }
public function getBuilding(): ?Building
{
return $this->building;
}
public function setBuilding(?Building $building): static
{
$this->building = $building;
return $this;
}
/**
* Bâtiment effectif d'un bovin : la case affectée si elle existe (logique
* historique), sinon le bâtiment direct (fed depuis l'XLSX initial).
*/
#[Groups(['bovine:read', 'building_case:read'])]
public function getEffectiveBuilding(): ?Building
{
return $this->buildingCase?->getIdBuilding() ?? $this->building;
}
public function getSupplier(): ?Supplier public function getSupplier(): ?Supplier
{ {
return $this->supplier; return $this->supplier;
@@ -269,14 +240,14 @@ class Bovine
return $this; return $this;
} }
public function getBovineType(): ?BovineType public function getBreedCode(): ?string
{ {
return $this->bovineType; return $this->breedCode;
} }
public function setBovineType(?BovineType $bovineType): static public function setBreedCode(?string $breedCode): static
{ {
$this->bovineType = $bovineType; $this->breedCode = $breedCode;
return $this; return $this;
} }

View File

@@ -51,11 +51,11 @@ class BovineType
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])] #[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
private ?string $label = null; private ?string $label = null;
#[ORM\Column(length: 50)] #[ORM\Column(length: 50)]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])] #[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
private ?string $code = null; private ?string $code = null;
public function getId(): ?int public function getId(): ?int

View File

@@ -155,7 +155,7 @@ final class BovineInventoryExportProvider implements ProviderInterface
$this->formatSex($bovine->getSex()), $this->formatSex($bovine->getSex()),
$this->formatDate($bovine->getBirthDate()), $this->formatDate($bovine->getBirthDate()),
$bovine->getAgeMonths(), $bovine->getAgeMonths(),
$bovine->getBovineType()?->getLabel(), $bovine->getBreedCode(),
$bovine->getBuildingCase()?->getIdBuilding()?->getLabel(), $bovine->getBuildingCase()?->getIdBuilding()?->getLabel(),
$bovine->getBuildingCase()?->getCaseNumber(), $bovine->getBuildingCase()?->getCaseNumber(),
$this->formatDate($bovine->getArrivalDate()), $this->formatDate($bovine->getArrivalDate()),

View File

@@ -8,7 +8,6 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BovineSyncInventoryResult; use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine; use App\Entity\Bovine;
use App\Entity\BovineType;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface; use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
@@ -19,11 +18,6 @@ use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
*/ */
final class BovineSyncInventoryProcessor implements ProcessorInterface final class BovineSyncInventoryProcessor implements ProcessorInterface
{ {
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct( public function __construct(
private BovinApiInterface $bovinApi, private BovinApiInterface $bovinApi,
private EntityManagerInterface $em, private EntityManagerInterface $em,
@@ -40,13 +34,6 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$result = new BovineSyncInventoryResult(); $result = new BovineSyncInventoryResult();
$result->total = count($inventory->animals); $result->total = count($inventory->animals);
$this->bovineTypeCache = [];
foreach ($this->em->getRepository(BovineType::class)->findAll() as $bovineType) {
if (null !== $bovineType->getCode()) {
$this->bovineTypeCache[$bovineType->getCode()] = $bovineType;
}
}
$existingByNationalNumber = []; $existingByNationalNumber = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) { foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine; $existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
@@ -96,7 +83,7 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$identification = $animal->identification; $identification = $animal->identification;
if (null !== $identification) { if (null !== $identification) {
$bovine->setSex($identification->sex); $bovine->setSex($identification->sex);
$bovine->setBovineType($this->resolveBovineType($identification->breedType)); $bovine->setBreedCode($identification->breedType);
$bovine->setWorkNumber($identification->workNumber); $bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date); $bovine->setBirthDate($identification->birthDate?->date);
} }
@@ -115,28 +102,4 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$bovine->setExitDate($latestExit); $bovine->setExitDate($latestExit);
$bovine->refreshAgeMonths(); $bovine->refreshAgeMonths();
} }
/**
* Trouve un BovineType existant par code, sinon en crée un placeholder
* que l'admin pourra renommer dans /admin/bovin/bovin-list.
*/
private function resolveBovineType(?string $code): ?BovineType
{
if (null === $code || '' === $code) {
return null;
}
if (isset($this->bovineTypeCache[$code])) {
return $this->bovineTypeCache[$code];
}
$bovineType = new BovineType();
$bovineType->setCode($code);
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
$this->em->persist($bovineType);
$this->bovineTypeCache[$code] = $bovineType;
return $bovineType;
}
} }

View File

@@ -65,7 +65,7 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
continue; continue;
} }
$breedCode = $bovine->getBovineType()?->getCode(); $breedCode = $bovine->getBreedCode();
if (null === $headerBreedCode && null !== $breedCode) { if (null === $headerBreedCode && null !== $breedCode) {
$headerBreedCode = $breedCode; $headerBreedCode = $breedCode;
} }