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 */ // 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 */ public function getStates(): array { return $this->states; } /** * @param list $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 ?? '')); } }