Compare commits

..

4 Commits

Author SHA1 Message Date
gitea-actions
de39207102 chore: bump version to v0.0.103
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build Release Artefact / build (push) Successful in 1m34s
2026-05-18 07:40:20 +00:00
5da0003c4d [#FER-25] Ajout un cron pour la synchro de l'inventaire bovin (!55)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #55
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-18 07:40:10 +00:00
gitea-actions
88f19cbb59 chore: bump version to v0.0.102
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m34s
2026-05-18 07:13:26 +00:00
df3f86d33f [#FER-27] Fix export inventaire bovin (!54)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Une ligne avec données absentes ou hors plage (date < 1900, n° national vide, dates incohérentes) ne fait plus tomber l'export entier en 500. Try/catch par ligne avec log warning, helper safePhpToExcel pour les dates Excel, garde birth <= arrival pour l'âge à l'entrée.

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #54
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-18 07:12:36 +00:00
6 changed files with 237 additions and 133 deletions

View File

@@ -66,6 +66,8 @@ Ajouter dans le fichier .env du frontend
* [#FER-17] Ecran d'ajout de bovin
* [#FER-18] Mise à jour du tableau d'arrivage
* [#FER-26] Passeport du bovin
* [#FER-27] Fix export inventaire bovin
* [#FER-25] Ajout un cron pour la synchro de l'inventaire bovin
### Changed

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.101'
app.version: '0.0.103'

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\BovineInventorySyncer;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
#[AsCommand(
name: 'app:sync-bovine-inventory',
description: "Synchronise l'inventaire bovin avec EDNOTIF (équivalent du bouton Rafraîchir de l'interface)."
)]
final class SyncBovineInventoryCommand extends Command
{
public function __construct(
private readonly BovineInventorySyncer $syncer,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->syncer->sync();
} catch (Throwable $e) {
$io->error(sprintf('Échec de la synchronisation : %s', $e->getMessage()));
return Command::FAILURE;
}
$io->success(sprintf(
'Inventaire synchronisé · Créés : %d · Mis à jour : %d · Sortis : %d · Total EDNOTIF : %d',
$result->created,
$result->updated,
$result->exited,
$result->total,
));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine;
use App\Entity\BovineType;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
final class BovineInventorySyncer
{
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct(
private readonly BovinApiInterface $bovinApi,
private readonly EntityManagerInterface $em,
) {}
public function sync(): BovineSyncInventoryResult
{
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
$result = new BovineSyncInventoryResult();
$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 = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
}
$seen = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
continue;
}
$seen[$nationalNumber] = true;
if (isset($existingByNationalNumber[$nationalNumber])) {
$bovine = $existingByNationalNumber[$nationalNumber];
++$result->updated;
} else {
$bovine = new Bovine();
$bovine->setNationalNumber($nationalNumber);
$this->em->persist($bovine);
++$result->created;
}
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
}
$now = new DateTimeImmutable();
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
if (isset($seen[$nationalNumber])) {
continue;
}
if (null !== $bovine->getExitedAt()) {
continue;
}
$bovine->setExitedAt($now);
++$result->exited;
}
$this->em->flush();
return $result;
}
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
{
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setMotherNationalNumber($identification->motherCarrier?->bovin?->nationalNumber);
$bovine->setMotherBovineType($this->resolveBovineType($identification->motherCarrier?->breedType));
$bovine->setFatherNationalNumber($identification->fatherIpg?->bovin?->nationalNumber);
$bovine->setFatherBovineType($this->resolveBovineType($identification->fatherIpg?->breedType));
}
$latestEntry = null;
$latestExit = null;
foreach ($animal->presencePeriods as $period) {
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
$latestEntry = $period->entry->date;
}
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
$latestExit = $period->exit->date;
}
}
$bovine->setArrivalDate($latestEntry);
$bovine->setExitDate($latestExit);
$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

@@ -18,13 +18,15 @@ use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* @implements ProviderInterface<Response>
*/
final class BovineInventoryExportProvider implements ProviderInterface
final readonly class BovineInventoryExportProvider implements ProviderInterface
{
private const FARM_NAME = 'FERME SCEA LES NAUDS';
@@ -70,6 +72,7 @@ final class BovineInventoryExportProvider implements ProviderInterface
public function __construct(
private BovineRepository $bovineRepository,
private RequestStack $requestStack,
private LoggerInterface $logger,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -253,7 +256,16 @@ final class BovineInventoryExportProvider implements ProviderInterface
// Lignes de données
$rowNumber = 5;
foreach ($bovines as $bovine) {
$this->writeBovineRow($sheet, $rowNumber, $bovine);
try {
$this->writeBovineRow($sheet, $rowNumber, $bovine);
} catch (Throwable $e) {
$this->logger->warning('Export inventaire bovin : ligne ignorée suite à une erreur.', [
'bovineId' => $bovine->getId(),
'nationalNumber' => $bovine->getNationalNumber(),
'row' => $rowNumber,
'exception' => $e,
]);
}
++$rowNumber;
}
@@ -284,22 +296,25 @@ final class BovineInventoryExportProvider implements ProviderInterface
? (int) $bovine->getWorkNumber()
: ($bovine->getWorkNumber() ?? ''));
$sheet->setCellValue('C'.$row, $isCharo ? 'X' : '');
$sheet->setCellValue('D'.$row, 'FR '.$bovine->getNationalNumber());
$national = $bovine->getNationalNumber();
$sheet->setCellValue('D'.$row, '' === $national ? '' : 'FR '.$national);
$sheet->setCellValue('E'.$row, 'B1' === $code ? 'X' : '');
$sheet->setCellValue('F'.$row, 'B2' === $code ? 'X' : '');
$sheet->setCellValue('G'.$row, 'B3' === $code ? 'X' : '');
$sheet->setCellValue('H'.$row, $bovine->getBuildingCase()?->getCaseNumber() ?? '');
$sheet->setCellValue('I'.$row, $bovine->getSupplier()?->getName() ?? '');
$birth = $bovine->getBirthDate();
$arrival = $bovine->getArrivalDate();
if (null !== $birth) {
$sheet->setCellValue('J'.$row, ExcelDate::PHPToExcel($birth));
$birth = $bovine->getBirthDate();
$arrival = $bovine->getArrivalDate();
$birthExcel = $this->safePhpToExcel($birth);
$arrivalExcel = $this->safePhpToExcel($arrival);
if (null !== $birthExcel) {
$sheet->setCellValue('J'.$row, $birthExcel);
}
if (null !== $arrival) {
$sheet->setCellValue('K'.$row, ExcelDate::PHPToExcel($arrival));
if (null !== $arrivalExcel) {
$sheet->setCellValue('K'.$row, $arrivalExcel);
}
if (null !== $birth && null !== $arrival) {
if (null !== $birth && null !== $arrival && $birth <= $arrival) {
$diff = $birth->diff($arrival);
$sheet->setCellValue('L'.$row, ($diff->y * 12) + $diff->m);
}
@@ -343,6 +358,24 @@ final class BovineInventoryExportProvider implements ProviderInterface
}
}
/**
* Convertit une date PHP en numéro de série Excel, ou null si la date est absente / hors plage Excel (< 1900).
*/
private function safePhpToExcel(?DateTimeImmutable $date): ?float
{
if (null === $date) {
return null;
}
try {
$value = ExcelDate::PHPToExcel($date);
} catch (Throwable) {
return null;
}
return is_float($value) ? $value : null;
}
/**
* Sous-titre dynamique selon les tranches d'âge cochées.
*

View File

@@ -7,26 +7,15 @@ namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine;
use App\Entity\BovineType;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
use App\Service\BovineInventorySyncer;
/**
* @implements ProcessorInterface<mixed, BovineSyncInventoryResult>
*/
final class BovineSyncInventoryProcessor implements ProcessorInterface
final readonly class BovineSyncInventoryProcessor implements ProcessorInterface
{
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct(
private BovinApiInterface $bovinApi,
private EntityManagerInterface $em,
private BovineInventorySyncer $syncer,
) {}
public function process(
@@ -35,113 +24,6 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
array $uriVariables = [],
array $context = [],
): BovineSyncInventoryResult {
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
$result = new BovineSyncInventoryResult();
$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 = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
}
$seen = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
continue;
}
$seen[$nationalNumber] = true;
if (isset($existingByNationalNumber[$nationalNumber])) {
$bovine = $existingByNationalNumber[$nationalNumber];
++$result->updated;
} else {
$bovine = new Bovine();
$bovine->setNationalNumber($nationalNumber);
$this->em->persist($bovine);
++$result->created;
}
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
}
$now = new DateTimeImmutable();
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
if (isset($seen[$nationalNumber])) {
continue;
}
if (null !== $bovine->getExitedAt()) {
continue;
}
$bovine->setExitedAt($now);
++$result->exited;
}
$this->em->flush();
return $result;
}
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
{
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setMotherNationalNumber($identification->motherCarrier?->bovin?->nationalNumber);
$bovine->setMotherBovineType($this->resolveBovineType($identification->motherCarrier?->breedType));
$bovine->setFatherNationalNumber($identification->fatherIpg?->bovin?->nationalNumber);
$bovine->setFatherBovineType($this->resolveBovineType($identification->fatherIpg?->breedType));
}
$latestEntry = null;
$latestExit = null;
foreach ($animal->presencePeriods as $period) {
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
$latestEntry = $period->entry->date;
}
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
$latestExit = $period->exit->date;
}
}
$bovine->setArrivalDate($latestEntry);
$bovine->setExitDate($latestExit);
$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;
return $this->syncer->sync();
}
}