diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 189cb00..d2f4458 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -820,6 +820,7 @@ "sites_site": "Site", "catalog_category": "Catégorie", "catalog_product": "Produit", + "catalog_storage": "Stockage", "commercial_client": "Client", "commercial_clientaddress": "Adresse client", "commercial_clientcontact": "Contact client", diff --git a/makefile b/makefile index 67d070e..a06fe9e 100644 --- a/makefile +++ b/makefile @@ -234,6 +234,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_product_code_active ON product (code) WHERE deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_storage_site_type_numero_active ON storage (site_id, storage_type_id, numero) WHERE deleted_at IS NULL" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load diff --git a/src/Module/Catalog/Domain/Entity/Storage.php b/src/Module/Catalog/Domain/Entity/Storage.php new file mode 100644 index 0000000..5eafb19 --- /dev/null +++ b/src/Module/Catalog/Domain/Entity/Storage.php @@ -0,0 +1,264 @@ + 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 ?? '')); + } +} diff --git a/src/Module/Catalog/Domain/Repository/StorageRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/StorageRepositoryInterface.php new file mode 100644 index 0000000..3edcf9b --- /dev/null +++ b/src/Module/Catalog/Domain/Repository/StorageRepositoryInterface.php @@ -0,0 +1,14 @@ + + */ +class DoctrineStorageRepository extends ServiceEntityRepository implements StorageRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Storage::class); + } + + public function findById(int $id): ?Storage + { + return $this->find($id); + } + + public function save(Storage $storage): void + { + $this->getEntityManager()->persist($storage); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 9cc42c5..7e6ebfc 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -610,6 +610,20 @@ final class ColumnCommentsCatalog 'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.', 'storage_type_id' => 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.', ], + + // M7 Catalog (ERP-212) — table desormais mappee par l'entite Storage : + // schema:update (test) la recree sans COMMENT -> app:apply-column-comments + // les rejoue depuis ce catalogue. Strings identiques aux COMMENT de la + // migration Version20260629120000 (ERP-211). + 'storage' => [ + '_table' => 'Emplacements de stockage (M7 Catalog) — un stockage = 1 site + 1 type (storage_type) + 1 numero, etats multi-valeur JSONB, soft-delete + Timestampable/Blamable.', + 'id' => 'Identifiant interne auto-incremente.', + 'site_id' => 'Site du stockage. FK -> site.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).', + 'storage_type_id' => 'Type de stockage (referentiel M6). FK -> storage_type.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).', + 'numero' => 'Numero du stockage (≤ 50), saisi. Unique par (site, type) parmi les actifs (RG-7.01, uq_storage_site_type_numero_active). Normalise serveur.', + 'states' => 'Etats du stockage (JSON) : tableau non vide (>= 1 element, RG-7.04, chk_storage_states_not_empty). Multi-valeur.', + 'deleted_at' => 'Horodatage du soft-delete technique — null = ligne active. Une ligne supprimee sort de l unicite metier (index partiel uq_storage_site_type_numero_active).', + ] + self::timestampableBlamableComments(), ]; }