feat(catalog) : M7 — entité Storage + repository + contrat de sérialisation (ERP-212)

This commit is contained in:
2026-06-29 16:27:02 +02:00
parent ca9dbe583a
commit 8c4c34c1a3
6 changed files with 326 additions and 0 deletions
@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\StorageProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Emplacement de stockage (M7 Catalog) — entite racine du module Stockage, jumelle
* de Product (M6) cote pattern (#[Auditable], TimestampableBlamable, soft-delete,
* etats multi-valeur JSONB) et cote contrat de serialisation (RETEX M1 — spec § 4.0).
*
* Un stockage = 1 site + 1 type de stockage (referentiel storage_type du M6) + 1
* numero. Le couple (site, type, numero) est unique parmi les stockages ACTIFS
* (RG-7.01, index partiel uq_storage_site_type_numero_active possede par la
* migration). Les etats (RECEPTION / PRODUCTION / TRIAGE) sont multi-valeur, au
* moins un (RG-7.04, CHECK chk_storage_states_not_empty).
*
* Contrat de serialisation :
* - LISTE / DETAIL (storage:read + site:read + storage_type:read + default:read) :
* numero, states, displayName (RG-7.05), site et storageType embarques (ensembles
* bornes -> embed autorise, ne viole pas la regle n°13), createdAt/updatedAt
* (via default:read). L'ecriture passe par storage:write (site, storageType,
* numero, states).
*
* Soft-delete prepare via `deletedAt` (non expose, § 2.8) : pas de Delete dans les
* operations ; la liste exclut les stockages supprimes (Provider, ERP-213). Un
* numero redevient disponible apres soft-delete (index partiel sur les actifs).
*
* NB : `Site` appartient au module Sites, consomme en relation ORM partagee (§ 2.1)
* — on reutilise son read-group `site:read`, sans logique inter-module. `StorageType`
* est dans le meme module Catalog.
*
* @see StorageProvider Lecture (liste paginee filtree soft-delete + item) — ERP-213.
* @see StorageProcessor Ecriture (normalisation, unicite metier RG-7.01) — ERP-213.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.storages.view')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
provider: StorageProvider::class,
),
new Get(
security: "is_granted('catalog.storages.view')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
provider: StorageProvider::class,
),
new Post(
security: "is_granted('catalog.storages.manage')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['storage:write']],
// Convertit les erreurs de denormalisation (type invalide / null sur une
// relation : site, storageType) en violations 422 portant un propertyPath,
// au lieu d'un 400 qui court-circuite la validation (cf. Product — mapping
// inline useFormErrors, ERP-101).
collectDenormalizationErrors: true,
processor: StorageProcessor::class,
),
new Patch(
security: "is_granted('catalog.storages.manage')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['storage:write']],
collectDenormalizationErrors: true,
provider: StorageProvider::class,
processor: StorageProcessor::class,
),
// Pas de Delete au M7 (§ 2.8) ; soft-delete prepare non expose.
],
)]
#[ORM\Entity(repositoryClass: DoctrineStorageRepository::class)]
#[ORM\Table(name: 'storage')]
// Index nommes pour matcher la migration (cf. Product). L'index unique partiel
// `uq_storage_site_type_numero_active` ((site, type, numero) WHERE deleted_at IS
// NULL — unicite metier parmi les actifs, RG-7.01) reste possede par la seule
// migration : Doctrine ORM ne sait pas exprimer un index partiel via attribut.
#[ORM\Index(name: 'idx_storage_site', columns: ['site_id'])]
#[ORM\Index(name: 'idx_storage_storage_type', columns: ['storage_type_id'])]
#[ORM\Index(name: 'idx_storage_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_storage_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_storage_updated_by', columns: ['updated_by'])]
#[Auditable]
class Storage implements TimestampableInterface, BlamableInterface
{
// === Timestampable + Blamable ===
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
// getters/setters viennent du Trait Shared, remplies automatiquement par le
// TimestampableBlamableSubscriber au prePersist / preUpdate.
use TimestampableBlamableTrait;
/** Etats du stockage (RG-7.04) — valeurs autorisees de la colonne JSONB `states`. */
public const string STATE_RECEPTION = 'RECEPTION';
public const string STATE_PRODUCTION = 'PRODUCTION';
public const string STATE_TRIAGE = 'TRIAGE';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['storage:read'])]
private ?int $id = null;
// Site du stockage (obligatoire). FK ON DELETE RESTRICT : un site reference par
// un stockage ne peut etre supprime. Composante de l'unicite metier (RG-7.01).
#[ORM\ManyToOne(targetEntity: Site::class)]
#[ORM\JoinColumn(name: 'site_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Le site est obligatoire.')]
#[Groups(['storage:read', 'storage:write'])]
private ?Site $site = null;
// Type de stockage (obligatoire, referentiel plat M6). FK ON DELETE RESTRICT :
// un type reference par un stockage ne peut etre supprime. Composante de
// l'unicite metier (RG-7.01).
#[ORM\ManyToOne(targetEntity: StorageType::class)]
#[ORM\JoinColumn(name: 'storage_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Le type de stockage est obligatoire.')]
#[Groups(['storage:read', 'storage:write'])]
private ?StorageType $storageType = null;
// Numero du stockage, saisi. Unique par (site, type) parmi les actifs (RG-7.01).
// Normalise serveur (trim) par le StorageProcessor (ERP-213).
#[ORM\Column(length: 50)]
#[Assert\NotBlank(message: 'Le numéro du stockage est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 50, maxMessage: 'Le numéro du stockage ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['storage:read', 'storage:write'])]
private ?string $numero = null;
/**
* Etats du stockage (multi-select), sous-ensemble non vide de
* {RECEPTION, PRODUCTION, TRIAGE} (RG-7.04). Stocke en JSONB (tableau de
* chaines), non-vacuite garantie aussi par le CHECK chk_storage_states_not_empty.
*
* Validation des valeurs via Assert\Choice(multiple: true) plutot que
* Assert\All([Choice]) : equivalent fonctionnel, et seul Choice est gere par le
* garde-fou EntityConstraintsHaveFrenchMessageTest (cf. Product::states).
*
* @var list<string>
*/
// jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la
// migration (CHECK chk_storage_states_not_empty via jsonb_array_length). Sans
// `options: ['jsonb' => true]`, schema:update tente un ALTER states TYPE JSON
// qui casse le CHECK et fait echouer make test-db-setup (cf. Product::states).
#[ORM\Column(type: 'json', options: ['jsonb' => true])]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état.')]
#[Assert\Choice(
choices: [self::STATE_RECEPTION, self::STATE_PRODUCTION, self::STATE_TRIAGE],
multiple: true,
message: 'État de stockage invalide.',
multipleMessage: 'État de stockage invalide.',
)]
#[Groups(['storage:read', 'storage:write'])]
private array $states = [];
/**
* Soft-delete technique : null = actif, valeur = supprime logiquement le {date}.
* Non expose (§ 2.8, aucun groupe) : prepare pour une future suppression. La
* liste exclut par defaut les stockages supprimes (Provider, ERP-213) et le
* numero redevient disponible (index partiel sur les actifs, RG-7.01).
*/
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(?Site $site): static
{
$this->site = $site;
return $this;
}
public function getStorageType(): ?StorageType
{
return $this->storageType;
}
public function setStorageType(?StorageType $storageType): static
{
$this->storageType = $storageType;
return $this;
}
public function getNumero(): ?string
{
return $this->numero;
}
public function setNumero(string $numero): static
{
$this->numero = $numero;
return $this;
}
/**
* @return list<string>
*/
public function getStates(): array
{
return $this->states;
}
/**
* @param list<string> $states
*/
public function setStates(array $states): static
{
$this->states = $states;
return $this;
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
/**
* RG-7.05 : libelle d'affichage = libelle du type de stockage suivi du numero
* (ex. « Cellule 12 »). Getter virtuel non persiste, expose en lecture
* (storage:read). Null-safe : `storageType` et `numero` sont garantis non nuls a
* la lecture (NOT NULL en base), le `?? ''` couvre un objet en cours de
* construction sans casser la serialisation.
*/
#[Groups(['storage:read'])]
public function getDisplayName(): string
{
$label = $this->storageType?->getLabel() ?? '';
return trim($label.' '.($this->numero ?? ''));
}
}