feat : relation Bovine -> BovineType, support du bâtiment direct et feed étendu
- Bovine.breedCode (string) remplacé par bovineType (FK BovineType) - Migration : ajout des races manquantes (Aubrac, Croisé, Blonde d'aquitaine), backfill, drop breed_code - Sync EDNOTIF : auto-création d'un BovineType placeholder pour code inconnu - Bovine.building (FK Building, nullable) en plus de buildingCase - Getter effectiveBuilding (case prime sinon building direct) - Feed XLSX : colonne E optionnelle (code bâtiment), set uniquement si pas de buildingCase - Front : DTO + colonnes en variant inventory/case via composable, race et bâtiment ajustés - Excel export utilise bovineType.label Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -193,12 +193,14 @@ 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.
|
||||||
|
|
||||||
@@ -237,3 +239,4 @@ rm /tmp/feed_bovin.xlsx # nettoya
|
|||||||
- 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)
|
||||||
|
|||||||
@@ -7,41 +7,77 @@ 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).
|
||||||
* Deux définitions distinctes admin/user pour pouvoir ajuster les largeurs
|
* Variants distincts pour chaque écran et chaque rôle (admin/user) afin de
|
||||||
* indépendamment selon le contexte.
|
* pouvoir ajuster les largeurs indépendamment.
|
||||||
*/
|
*/
|
||||||
export const useBovineColumns = () => {
|
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const adminColumns: BovineColumn[] = [
|
const adminColumnsInventory: 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: 'breedCode', label: 'Race', width: '70px' },
|
{ key: 'bovineType.label', label: 'Race', width: '90px' },
|
||||||
{ 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: '100px' }
|
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const userColumns: BovineColumn[] = [
|
const userColumnsInventory: 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: 'breedCode', label: 'Race', width: '70px' },
|
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||||
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
|
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '120px' },
|
||||||
{ 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 columns = computed<BovineColumn[]>(() => auth.isAdmin ? adminColumns : userColumns)
|
const adminColumnsCase: BovineColumn[] = [
|
||||||
|
{ 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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,19 +94,13 @@
|
|||||||
<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-breedCode>
|
<template #header-bovineType.label>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
v-model="filters.breedCode"
|
v-model="filters['bovineType.label']"
|
||||||
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>
|
||||||
@@ -124,12 +118,6 @@
|
|||||||
<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>
|
||||||
@@ -196,7 +184,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
|
|||||||
buildingCase: '',
|
buildingCase: '',
|
||||||
nationalNumber: '',
|
nationalNumber: '',
|
||||||
workNumber: '',
|
workNumber: '',
|
||||||
breedCode: '',
|
'bovineType.label': '',
|
||||||
sex: '',
|
sex: '',
|
||||||
'arrivalDate[after]': '',
|
'arrivalDate[after]': '',
|
||||||
'arrivalDate[strictly_before]': '',
|
'arrivalDate[strictly_before]': '',
|
||||||
@@ -234,7 +222,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()
|
const { columns } = useBovineColumns({ variant: 'case' })
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (!buildingCase.value) return ''
|
if (!buildingCase.value) return ''
|
||||||
|
|||||||
@@ -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-breedCode>
|
<template #header-bovineType.label>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
v-model="filters.breedCode"
|
v-model="filters['bovineType.label']"
|
||||||
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.buildingCase?.building?.label ?? '—' }}
|
{{ item.effectiveBuilding?.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: '',
|
||||||
breedCode: '',
|
'bovineType.label': '',
|
||||||
sex: '',
|
sex: '',
|
||||||
'arrivalDate[after]': '',
|
'arrivalDate[after]': '',
|
||||||
'arrivalDate[strictly_before]': '',
|
'arrivalDate[strictly_before]': '',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
export interface BovineBuildingRef {
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BovineBuildingCaseRef {
|
export interface BovineBuildingCaseRef {
|
||||||
caseNumber: number | null
|
caseNumber: number | null
|
||||||
building: { label: string } | null
|
building: BovineBuildingRef | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BovineData {
|
export interface BovineData {
|
||||||
@@ -12,10 +16,12 @@ 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
|
||||||
breedCode: string | null
|
bovineType: { id: number; label: string; code: string } | null
|
||||||
sex: string | null
|
sex: string | null
|
||||||
ageMonths: number | null
|
ageMonths: number | null
|
||||||
exitedAt: string | null
|
exitedAt: string | null
|
||||||
|
|||||||
35
migrations/Version20260428061801.php
Normal file
35
migrations/Version20260428061801.php
Normal 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 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
50
migrations/Version20260428065800.php
Normal file
50
migrations/Version20260428065800.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ 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;
|
||||||
@@ -71,6 +72,12 @@ 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 = [
|
||||||
@@ -79,9 +86,11 @@ 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) {
|
||||||
@@ -91,6 +100,7 @@ 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) {
|
||||||
@@ -134,6 +144,18 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -152,6 +174,7 @@ 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']],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -173,6 +196,14 @@ 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 {
|
||||||
|
|||||||
@@ -476,9 +476,12 @@ 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) {
|
||||||
|
|||||||
@@ -77,9 +77,12 @@ 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()
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|||||||
#[ORM\Table(name: 'bovine')]
|
#[ORM\Table(name: 'bovine')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
|
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
|
||||||
#[ApiFilter(SearchFilter::class, properties: [
|
#[ApiFilter(SearchFilter::class, properties: [
|
||||||
'nationalNumber' => 'ipartial',
|
'nationalNumber' => 'ipartial',
|
||||||
'workNumber' => 'ipartial',
|
'workNumber' => 'ipartial',
|
||||||
'breedCode' => 'ipartial',
|
'bovineType.label' => 'ipartial',
|
||||||
'sex' => 'exact',
|
'bovineType.code' => 'ipartial',
|
||||||
'buildingCase' => 'exact',
|
'sex' => 'exact',
|
||||||
'receivedWeight' => 'exact',
|
'buildingCase' => 'exact',
|
||||||
|
'receivedWeight' => 'exact',
|
||||||
])]
|
])]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
||||||
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
|
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
|
||||||
@@ -93,6 +94,11 @@ 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;
|
||||||
@@ -106,9 +112,10 @@ 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\Column(length: 20, nullable: true)]
|
#[ORM\ManyToOne]
|
||||||
#[Groups(['bovine:read', 'building_case:read'])]
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
private ?string $breedCode = null;
|
#[ApiProperty(readableLink: true)]
|
||||||
|
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'])]
|
||||||
@@ -204,6 +211,28 @@ 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;
|
||||||
@@ -240,14 +269,14 @@ class Bovine
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getBreedCode(): ?string
|
public function getBovineType(): ?BovineType
|
||||||
{
|
{
|
||||||
return $this->breedCode;
|
return $this->bovineType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setBreedCode(?string $breedCode): static
|
public function setBovineType(?BovineType $bovineType): static
|
||||||
{
|
{
|
||||||
$this->breedCode = $breedCode;
|
$this->bovineType = $bovineType;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'])]
|
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case: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'])]
|
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
|
|||||||
@@ -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->getBreedCode(),
|
$bovine->getBovineType()?->getLabel(),
|
||||||
$bovine->getBuildingCase()?->getIdBuilding()?->getLabel(),
|
$bovine->getBuildingCase()?->getIdBuilding()?->getLabel(),
|
||||||
$bovine->getBuildingCase()?->getCaseNumber(),
|
$bovine->getBuildingCase()?->getCaseNumber(),
|
||||||
$this->formatDate($bovine->getArrivalDate()),
|
$this->formatDate($bovine->getArrivalDate()),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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;
|
||||||
@@ -18,6 +19,11 @@ 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,
|
||||||
@@ -34,6 +40,13 @@ 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;
|
||||||
@@ -83,7 +96,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->setBreedCode($identification->breedType);
|
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
|
||||||
$bovine->setWorkNumber($identification->workNumber);
|
$bovine->setWorkNumber($identification->workNumber);
|
||||||
$bovine->setBirthDate($identification->birthDate?->date);
|
$bovine->setBirthDate($identification->birthDate?->date);
|
||||||
}
|
}
|
||||||
@@ -102,4 +115,28 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$breedCode = $bovine->getBreedCode();
|
$breedCode = $bovine->getBovineType()?->getCode();
|
||||||
if (null === $headerBreedCode && null !== $breedCode) {
|
if (null === $headerBreedCode && null !== $breedCode) {
|
||||||
$headerBreedCode = $breedCode;
|
$headerBreedCode = $breedCode;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user