diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index b3a8e70..6425406 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -817,6 +817,7 @@ "core_permission": "Permission", "sites_site": "Site", "catalog_category": "Catégorie", + "catalog_product": "Produit", "commercial_client": "Client", "commercial_clientaddress": "Adresse client", "commercial_clientcontact": "Contact client", diff --git a/makefile b/makefile index e6e43f1..67d070e 100644 --- a/makefile +++ b/makefile @@ -233,6 +233,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (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_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" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load diff --git a/src/Module/Catalog/Domain/Entity/Product.php b/src/Module/Catalog/Domain/Entity/Product.php new file mode 100644 index 0000000..41bfca7 --- /dev/null +++ b/src/Module/Catalog/Domain/Entity/Product.php @@ -0,0 +1,361 @@ + embed autorise, ne viole pas la regle n°13). Le groupe + * product:item:read est reserve pour d'eventuels champs detail-only ulterieurs. + * + * Regles de gestion (renvoyees au Processor/Provider, ERP-200) : + * - RG-6.01 : `code` unique global parmi les actifs, normalise serveur (trim/UPPER), + * 409 sur doublon (index partiel uq_product_code_active). + * - RG-6.02 : `states` = sous-ensemble non vide de {PURCHASE, SALE, OTHER}. + * - RG-6.03 : `manufactured` / `containsMolasses` saisis uniquement si states + * contient SALE, sinon forces false serveur. + * - RG-6.04 : `sites` >= 1. + * - RG-6.05 : `category` de type PRODUIT (validee applicativement, Callback ERP-200). + * - RG-6.06 : `storageTypes` >= 1, filtres par les sites selectionnes. + * + * Soft-delete prepare via `deletedAt` (non expose au M6, § 2.7) : pas de Delete + * dans les operations, la liste exclut les produits supprimes (Provider, ERP-200). + * + * Les RG inter-champs (RG-6.03/6.05/6.06) et l'unicite du code passent par le + * Processor + une contrainte d'entite Assert\Callback en ERP-200 (chaque 422 + * porte un propertyPath exploitable par useFormErrors — mapping inline, ERP-101). + * + * NB : `Site` appartient au module Sites, consomme en relation ORM partagee + * (§ 2.1) — on reutilise son read-group `site:read`, sans logique inter-module. + * `Category` et `StorageType` sont dans le meme module Catalog. + * + * @see ProductProvider Lecture (liste paginee filtree soft-delete + item) — ERP-200. + * @see ProductProcessor Ecriture (normalisation, unicite code, RG-6.03/05/06) — ERP-200. + */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('catalog.products.view')", + normalizationContext: ['groups' => ['product:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']], + provider: ProductProvider::class, + ), + new Get( + security: "is_granted('catalog.products.view')", + normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']], + provider: ProductProvider::class, + ), + new Post( + security: "is_granted('catalog.products.manage')", + normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']], + denormalizationContext: ['groups' => ['product:write']], + processor: ProductProcessor::class, + ), + new Patch( + security: "is_granted('catalog.products.manage')", + normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']], + denormalizationContext: ['groups' => ['product:write']], + provider: ProductProvider::class, + processor: ProductProcessor::class, + ), + // Pas de Delete au M6 (docx) ; soft-delete prepare non expose (§ 2.7). + ], +)] +#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)] +#[ORM\Table(name: 'product')] +// Index nommes pour matcher la migration (cf. Category). L'index unique partiel +// `uq_product_code_active` (code WHERE deleted_at IS NULL — unicite GLOBALE du +// code parmi les actifs, RG-6.01) reste possede par la seule migration : +// Doctrine ORM ne sait pas exprimer un index partiel via attribut. +#[ORM\Index(name: 'idx_product_category', columns: ['category_id'])] +#[ORM\Index(name: 'idx_product_deleted_at', columns: ['deleted_at'])] +#[ORM\Index(name: 'idx_product_created_by', columns: ['created_by'])] +#[ORM\Index(name: 'idx_product_updated_by', columns: ['updated_by'])] +#[Auditable] +class Product 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; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['product:read'])] + private ?int $id = null; + + // Code produit (= « Numero » de la liste), saisi, unique global parmi les + // actifs (RG-6.01). Normalise serveur (trim/UPPER) par le ProductProcessor. + #[ORM\Column(length: 50)] + #[Assert\NotBlank(message: 'Le code produit est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['product:read', 'product:write'])] + private ?string $code = null; + + #[ORM\Column(length: 255)] + #[Assert\NotBlank(message: 'Le nom du produit est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['product:read', 'product:write'])] + private ?string $name = null; + + /** + * Etats du produit (multi-select), sous-ensemble non vide de + * {PURCHASE, SALE, OTHER} (RG-6.02). Stocke en JSONB (tableau de chaines), + * non-vacuite garantie aussi par le CHECK chk_product_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. + * + * @var list + */ + #[ORM\Column(type: 'json')] + #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')] + #[Assert\Choice( + choices: ['PURCHASE', 'SALE', 'OTHER'], + multiple: true, + message: 'État de produit invalide.', + multipleMessage: 'État de produit invalide.', + )] + #[Groups(['product:read', 'product:write'])] + private array $states = []; + + // « Fabrique » : saisi uniquement si states contient SALE, sinon force false + // serveur (RG-6.03). + #[ORM\Column(options: ['default' => false])] + #[Groups(['product:read', 'product:write'])] + private bool $manufactured = false; + + // « Contient de la melasse » : saisi uniquement si states contient SALE, + // sinon force false serveur (RG-6.03). + #[ORM\Column(name: 'contains_molasses', options: ['default' => false])] + #[Groups(['product:read', 'product:write'])] + private bool $containsMolasses = false; + + // Categorie produit (obligatoire). Limitee aux categories de type PRODUIT, + // validee applicativement (RG-6.05, Callback ERP-200). FK ON DELETE RESTRICT : + // une categorie referencee par un produit ne peut etre supprimee. + #[ORM\ManyToOne(targetEntity: Category::class)] + #[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] + #[Assert\NotNull(message: 'La catégorie produit est obligatoire.')] + #[Groups(['product:read', 'product:write'])] + private ?Category $category = null; + + /** + * Sites de disponibilite du produit (>= 1, RG-6.04). Relation ORM partagee + * vers Site (module Sites, § 2.1). Cote inverse en ON DELETE RESTRICT : un + * site reference par un produit ne peut etre supprime. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Site::class)] + #[ORM\JoinTable(name: 'product_site')] + #[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')] + #[Groups(['product:read', 'product:write'])] + private Collection $sites; + + /** + * Types de stockage du produit (>= 1, RG-6.06), filtres par les sites + * selectionnes (provider StorageType, ERP-201). Cote inverse en ON DELETE + * RESTRICT : un type de stockage reference par un produit ne peut etre supprime. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: StorageType::class)] + #[ORM\JoinTable(name: 'product_storage_type')] + #[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')] + #[Groups(['product:read', 'product:write'])] + private Collection $storageTypes; + + /** + * Soft-delete technique : null = actif, valeur = supprime logiquement le {date}. + * Non expose au M6 (§ 2.7, aucun groupe) : prepare pour une future suppression + * (HP-M6-04). La liste exclut par defaut les produits supprimes (Provider). + */ + #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)] + private ?DateTimeImmutable $deletedAt = null; + + public function __construct() + { + $this->sites = new ArrayCollection(); + $this->storageTypes = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + 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 isManufactured(): bool + { + return $this->manufactured; + } + + public function setManufactured(bool $manufactured): static + { + $this->manufactured = $manufactured; + + return $this; + } + + public function containsMolasses(): bool + { + return $this->containsMolasses; + } + + public function setContainsMolasses(bool $containsMolasses): static + { + $this->containsMolasses = $containsMolasses; + + return $this; + } + + public function getCategory(): ?Category + { + return $this->category; + } + + public function setCategory(?Category $category): static + { + $this->category = $category; + + return $this; + } + + /** + * @return Collection + */ + public function getSites(): Collection + { + return $this->sites; + } + + public function addSite(Site $site): static + { + if (!$this->sites->contains($site)) { + $this->sites->add($site); + } + + return $this; + } + + public function removeSite(Site $site): static + { + $this->sites->removeElement($site); + + return $this; + } + + /** + * @return Collection + */ + public function getStorageTypes(): Collection + { + return $this->storageTypes; + } + + public function addStorageType(StorageType $storageType): static + { + if (!$this->storageTypes->contains($storageType)) { + $this->storageTypes->add($storageType); + } + + return $this; + } + + public function removeStorageType(StorageType $storageType): static + { + $this->storageTypes->removeElement($storageType); + + return $this; + } + + public function getDeletedAt(): ?DateTimeImmutable + { + return $this->deletedAt; + } + + public function setDeletedAt(?DateTimeImmutable $deletedAt): static + { + $this->deletedAt = $deletedAt; + + return $this; + } +} diff --git a/src/Module/Catalog/Domain/Entity/StorageType.php b/src/Module/Catalog/Domain/Entity/StorageType.php new file mode 100644 index 0000000..5652af6 --- /dev/null +++ b/src/Module/Catalog/Domain/Entity/StorageType.php @@ -0,0 +1,144 @@ + Site) : sites sur lesquels ce type de stockage + * est disponible. Sert au filtrage du multi-select « Type de stockage » par les + * sites selectionnes dans le formulaire produit (RG-6.06). Non serialisee au M6 + * (le filtrage est applique cote provider en ERP-201). + * + * Lecture seule au M6 : seules les operations GetCollection et Get sont exposees + * (CRUD admin = hors perimetre HP-M6-03), sous la permission `catalog.products.view` + * (referentiel servant le formulaire produit — § 4.2). + * + * Referentiel statique : pas de Timestampable/Blamable ni `#[Auditable]` + * (whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED, miroir + * CategoryType — cree par migration/seed, pas pilote utilisateur). Le groupe + * `storage_type:read` est porte par chaque propriete affichee pour que le type + * soit embarque dans la reponse d'un Product (cf. .claude/rules/backend.md + * § Serialization). + */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('catalog.products.view')", + normalizationContext: ['groups' => ['storage_type:read']], + // Tri alphabetique stable pour alimenter le multi-select du formulaire + // produit (§ 4.2). Le filtre ?siteId[]= est branche en ERP-201. + order: ['label' => 'ASC'], + ), + new Get( + security: "is_granted('catalog.products.view')", + normalizationContext: ['groups' => ['storage_type:read']], + ), + ], +)] +#[ORM\Entity(repositoryClass: DoctrineStorageTypeRepository::class)] +#[ORM\Table(name: 'storage_type')] +// Contrainte d'unicite nommee pour matcher la migration (cf. CategoryType). +#[ORM\UniqueConstraint(name: 'uq_storage_type_code', columns: ['code'])] +class StorageType +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['storage_type:read'])] + private ?int $id = null; + + #[ORM\Column(length: 40)] + #[Groups(['storage_type:read'])] + private ?string $code = null; + + #[ORM\Column(length: 120)] + #[Groups(['storage_type:read'])] + private ?string $label = null; + + /** + * Sites sur lesquels ce type de stockage est disponible (RG-6.06). Non + * exposee en serialisation au M6 : sert uniquement au filtrage `?siteId[]=` + * du referentiel (branche en ERP-201). + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Site::class)] + #[ORM\JoinTable(name: 'storage_type_site')] + #[ORM\JoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + private Collection $sites; + + public function __construct() + { + $this->sites = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + /** + * @return Collection + */ + public function getSites(): Collection + { + return $this->sites; + } + + public function addSite(Site $site): static + { + if (!$this->sites->contains($site)) { + $this->sites->add($site); + } + + return $this; + } + + public function removeSite(Site $site): static + { + $this->sites->removeElement($site); + + return $this; + } +} diff --git a/src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php new file mode 100644 index 0000000..424e176 --- /dev/null +++ b/src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php @@ -0,0 +1,23 @@ + + */ + public function findAllOrderedByLabel(): array; +} diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php new file mode 100644 index 0000000..8851aa4 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php @@ -0,0 +1,49 @@ + + */ +class DoctrineProductRepository extends ServiceEntityRepository implements ProductRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Product::class); + } + + public function findById(int $id): ?Product + { + return $this->find($id); + } + + public function save(Product $product): void + { + $this->getEntityManager()->persist($product); + $this->getEntityManager()->flush(); + } + + public function existsActiveByCode(string $code, ?int $excludeId = null): bool + { + $qb = $this->createQueryBuilder('p') + ->select('1') + ->andWhere('p.code = :code') + ->andWhere('p.deletedAt IS NULL') + ->setParameter('code', $code) + ->setMaxResults(1) + ; + + if (null !== $excludeId) { + $qb->andWhere('p.id != :excludeId')->setParameter('excludeId', $excludeId); + } + + return [] !== $qb->getQuery()->getResult(); + } +} diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php new file mode 100644 index 0000000..46962ef --- /dev/null +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php @@ -0,0 +1,34 @@ + + */ +class DoctrineStorageTypeRepository extends ServiceEntityRepository implements StorageTypeRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, StorageType::class); + } + + public function findById(int $id): ?StorageType + { + return $this->find($id); + } + + /** + * @return list + */ + public function findAllOrderedByLabel(): array + { + return $this->findBy([], ['label' => 'ASC']); + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 5a8415b..5898617 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -575,6 +575,47 @@ final class ColumnCommentsCatalog 'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.', 'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.', ] + self::timestampableBlamableComments(), + + // M6 Catalog (ERP-199) — tables desormais mappees par les entites + // Product / StorageType : schema:update (test) les recree sans COMMENT + // -> app:apply-column-comments les rejoue depuis ce catalogue. Strings + // identiques aux COMMENT de la migration Version20260625110000 (ERP-198). + 'storage_type' => [ + '_table' => 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).', + 'label' => 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).', + ], + + 'storage_type_site' => [ + '_table' => 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).', + 'storage_type_id' => 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.', + 'site_id' => 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.', + ], + + 'product' => [ + '_table' => 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).', + 'name' => 'Nom du produit (≤ 255). Normalise serveur (trim).', + 'states' => 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.', + 'manufactured' => '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).', + 'contains_molasses' => '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).', + 'category_id' => 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).', + 'deleted_at' => 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.', + ] + self::timestampableBlamableComments(), + + 'product_site' => [ + '_table' => 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).', + 'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.', + 'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.', + ], + + 'product_storage_type' => [ + '_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).', + '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.', + ], ]; } diff --git a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php index 3f27d2c..008738b 100644 --- a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php +++ b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Architecture; use App\Module\Catalog\Domain\Entity\CategoryType; +use App\Module\Catalog\Domain\Entity\StorageType; use App\Module\Commercial\Domain\Entity\Bank; use App\Module\Commercial\Domain\Entity\Country; use App\Module\Commercial\Domain\Entity\PaymentDelay; @@ -55,6 +56,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase * - CategoryType : referentiel statique (codes de typage des categories), * pas de besoin de tracabilite user-driven (cree par migration/seed, * pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17. + * - StorageType (M6, ERP-199) : referentiel PROVISOIRE des types de stockage + * (en attente liste Aurore — HP-M6-02), cree par migration + seede (ERP-201), + * lecture seule au M6. Pas de tracabilite user-driven, meme justification que + * CategoryType. Cf. spec-back M6 § 2.4 + § 2.6. * - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels * comptables statiques (id/code/label/position), seedes par migration + * CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de @@ -75,6 +80,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase Permission::class, Site::class, CategoryType::class, + StorageType::class, TvaMode::class, PaymentDelay::class, PaymentType::class,