feat : cause d'entrée bovin + confirmation EDNOTIF asynchrone + historique entrées

- Champ entryCause sur Bovine (enum App\Enum\CauseEntree : Achat/Naissance/PretOuPension)
- Sélecteur "Cause d'entrée" sur le formulaire de saisie (default Achat, required)
- ednotif_confirmed_at sur Bovine : timestamp set par le sync EDNOTIF la première
  fois qu'un bovin remonte dans getInventory. Backfill des bovins existants au
  jour de la migration.
- Inventaire (page + export + stats) filtre les bovins encore "en attente
  EDNOTIF" : ils n'apparaissent qu'une fois confirmés par le sync.
- getter getConfirmedBovineCount sur Reception, exposé en reception:read.
- Tableau Historique full-width sur /entry-exit listant les entrées validées,
  avec filtres de colonnes (numéro, date, fournisseur), compteur Confirmés/Saisis,
  et badge de statut "Confirmée" / "EDNOTIF en attente".
- Tableau récap de l'écran de saisie passé en useDataTableServerState pour
  bénéficier du loading et de la pagination serveur.
- Validation entrée : confirm window obligatoire, message renforcé en cas
  d'écart entre saisis et déclarés.
- Pattern projet "submitted" sur le formulaire d'ajout pour le visuel required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 17:29:46 +02:00
parent c64e0c7100
commit 476502c91c
13 changed files with 376 additions and 96 deletions

View File

@@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\CauseEntree;
use App\Repository\BovineRepository;
use App\State\Bovin\BovineProcessor;
use DateTimeImmutable;
@@ -38,7 +39,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
'reception' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt', 'ednotifConfirmedAt'])]
#[ApiResource(
order: ['birthDate' => 'ASC'],
operations: [
@@ -106,6 +107,10 @@ class Bovine
#[ApiProperty(readableLink: false)]
private ?Reception $reception = null;
#[ORM\Column(type: 'string', length: 1, nullable: true, enumType: CauseEntree::class)]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?CauseEntree $entryCause = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
@@ -147,6 +152,11 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitedAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i:s'])]
private ?DateTimeImmutable $ednotifConfirmedAt = null;
public function getId(): ?int
{
return $this->id;
@@ -235,6 +245,18 @@ class Bovine
return $this;
}
public function getEntryCause(): ?CauseEntree
{
return $this->entryCause;
}
public function setEntryCause(?CauseEntree $entryCause): static
{
$this->entryCause = $entryCause;
return $this;
}
public function getBuilding(): ?Building
{
return $this->building;
@@ -341,6 +363,18 @@ class Bovine
return $this;
}
public function getEdnotifConfirmedAt(): ?DateTimeImmutable
{
return $this->ednotifConfirmedAt;
}
public function setEdnotifConfirmedAt(?DateTimeImmutable $ednotifConfirmedAt): static
{
$this->ednotifConfirmedAt = $ednotifConfirmedAt;
return $this;
}
public function getAgeMonths(): ?int
{
return $this->ageMonths;

View File

@@ -301,6 +301,32 @@ class Reception
return $this->bovines->count();
}
#[Groups(['reception:read'])]
public function getConfirmedBovineCount(): int
{
$count = 0;
foreach ($this->bovines as $bovine) {
if (null !== $bovine->getEdnotifConfirmedAt()) {
++$count;
}
}
return $count;
}
#[Groups(['reception:read'])]
public function getDeclaredBovineCount(): int
{
$fromTypes = 0;
foreach ($this->bovines_types as $rb) {
$fromTypes += (int) ($rb->getQuantity() ?? 0);
}
$fromOther = is_numeric($this->bovineDetail) ? (int) $this->bovineDetail : 0;
return $fromTypes + $fromOther;
}
#[Groups(['reception:read'])]
public function getReceptionDate(): ?DateTimeImmutable
{

27
src/Enum/CauseEntree.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* Cause d'une entrée de bovin sur l'exploitation (opération `IpBCreateEntree`).
*
* Source : `resources/ednotif-ws/CauseEntree.XSD` + doc IPG Table 9.
* Le `.value` est le code IPG transmis dans le payload SOAP.
*
* Note : duplique l'enum `Malio\EdnotifBundle\Bovin\Enum\CauseEntree` (pas
* encore présente dans la release installée v0.0.6). À remplacer par l'import
* bundle quand une version embarquant l'enum sera publiée.
*/
enum CauseEntree: string
{
/** Entrée par achat. */
case Achat = 'A';
/** Entrée par naissance. */
case Naissance = 'N';
/** Entrée par prêt ou pension. */
case PretOuPension = 'P';
}

View File

@@ -35,6 +35,7 @@ final class BovineRepository extends ServiceEntityRepository
{
$qb = $this->createQueryBuilder('b')
->where('b.exitedAt IS NULL')
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
->orderBy('b.birthDate', 'ASC')
;
@@ -81,6 +82,7 @@ final class BovineRepository extends ServiceEntityRepository
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
)
->where('b.exitedAt IS NULL')
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
;
if (null !== $buildingCaseId) {

View File

@@ -72,6 +72,12 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
// Marque la confirmation EDNOTIF si c'est la première fois qu'on
// voit ce bovin remonter dans l'inventaire.
if (null === $bovine->getEdnotifConfirmedAt()) {
$bovine->setEdnotifConfirmedAt(new DateTimeImmutable());
}
}
$now = new DateTimeImmutable();