site (sur quels sites un type * de stockage est disponible — alimente le filtrage du multi-select par site). * - product : table principale (code unique global parmi les actifs, etats * multi-valeur JSONB, champs conditionnels SALE, categorie de type PRODUIT, * soft-delete prepare + Timestampable/Blamable). * - product_site : jonction M2M product <-> site (sites de disponibilite, RG-6.04). * - product_storage_type : jonction M2M product <-> storage_type (RG-6.06). * * Seed : ajout du `category_type` PRODUIT (miroir CategoryTypeFixtures, comme * CLIENT/FOURNISSEUR/PRESTATAIRE/ADRESSE — § 2.5). Les `Category` de type PRODUIT et * le seed Figma du referentiel storage_type suivent au ticket ERP-201. * * Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire : * la table product porte des FK cross-module (user, site, category). Le tri par * timestamp au sein du namespace racine garantit l'ordre apres la creation de ces * tables sur base vide ; un namespace modulaire casserait `make db-reset` (cf. * Version20260617150000 pour le M5). * * Convention IDs (spec § 2.2) : `INT GENERATED BY DEFAULT AS IDENTITY`, * horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe * `datetime_immutable`). Chaque colonne porte son `COMMENT ON COLUMN` (regle n°12). * * NB schema:update (test-db-setup) : product / storage_type et leurs jonctions seront * mappes en ORM au ticket suivant (entites Product + StorageType, ERP-199). D'ici la, * `schema:update --force` les drope sur la base de TEST uniquement (sans impact : * aucun test ne les reference encore, et dev/prod ne lancent jamais schema:update). * Leurs descriptions seront ajoutees a ColumnCommentsCatalog au ticket entites (comme * weighing_ticket : migration ERP-182, catalogue ERP-183). */ final class Version20260625110000 extends AbstractMigration { public function getDescription(): string { return 'ERP-198 (M6) : storage_type (+ jonction site) + product (+ jonctions site/stockage) + seed category_type PRODUIT.'; } public function up(Schema $schema): void { $this->createStorageType(); $this->createStorageTypeSite(); $this->createProduct(); $this->createProductSite(); $this->createProductStorageType(); $this->seedCategoryTypeProduit(); } public function down(Schema $schema): void { // Ordre inverse des dependances FK. $this->addSql('DROP TABLE IF EXISTS product_storage_type'); $this->addSql('DROP TABLE IF EXISTS product_site'); $this->addSql('DROP TABLE IF EXISTS product'); $this->addSql('DROP TABLE IF EXISTS storage_type_site'); $this->addSql('DROP TABLE IF EXISTS storage_type'); // Retrait du type seede (best-effort : echoue si des categories le referencent // encore — attendu, le down sert au dev sur base saine). $this->addSql("DELETE FROM category_type WHERE code = 'PRODUIT'"); } // ================================================================= // Referentiel des types de stockage (PROVISOIRE) — § 2.4 / RG-6.06 // ================================================================= private function createStorageType(): void { $this->addSql(<<<'SQL' CREATE TABLE storage_type ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, code VARCHAR(40) NOT NULL, label VARCHAR(120) NOT NULL, PRIMARY KEY (id) ) SQL); $this->addSql('CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code)'); $this->comment('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.'); $this->comment('storage_type', 'id', 'Identifiant interne auto-incremente.'); $this->comment('storage_type', 'code', 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).'); $this->comment('storage_type', 'label', 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).'); } private function createStorageTypeSite(): void { $this->addSql(<<<'SQL' CREATE TABLE storage_type_site ( storage_type_id INT NOT NULL, site_id INT NOT NULL, PRIMARY KEY (storage_type_id, site_id), CONSTRAINT fk_storage_type_site_type FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE CASCADE, CONSTRAINT fk_storage_type_site_site FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE ) SQL); $this->addSql('CREATE INDEX idx_storage_type_site_site ON storage_type_site (site_id)'); $this->comment('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).'); $this->comment('storage_type_site', 'storage_type_id', 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.'); $this->comment('storage_type_site', 'site_id', 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.'); } // ================================================================= // Table principale `product` // ================================================================= private function createProduct(): void { $this->addSql(<<<'SQL' CREATE TABLE product ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(255) NOT NULL, states JSONB DEFAULT '[]'::jsonb NOT NULL, manufactured BOOLEAN DEFAULT FALSE NOT NULL, contains_molasses BOOLEAN DEFAULT FALSE NOT NULL, category_id INT NOT NULL, deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, created_by INT DEFAULT NULL, updated_by INT DEFAULT NULL, PRIMARY KEY (id), CONSTRAINT chk_product_states_not_empty CHECK (jsonb_array_length(states) >= 1), CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT, CONSTRAINT fk_product_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_product_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); // Unicite GLOBALE du code parmi les actifs (soft-delete tolere) — index partiel. $this->addSql('CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL'); $this->addSql('CREATE INDEX idx_product_category ON product (category_id)'); $this->addSql('CREATE INDEX idx_product_deleted_at ON product (deleted_at)'); $this->addSql('CREATE INDEX idx_product_created_by ON product (created_by)'); $this->addSql('CREATE INDEX idx_product_updated_by ON product (updated_by)'); $this->comment('product', '_table', 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.'); $this->comment('product', 'id', 'Identifiant interne auto-incremente.'); $this->comment('product', '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).'); $this->comment('product', 'name', 'Nom du produit (≤ 255). Normalise serveur (trim).'); $this->comment('product', '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.'); $this->comment('product', 'manufactured', '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).'); $this->comment('product', 'contains_molasses', '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).'); $this->comment('product', 'category_id', 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).'); $this->comment('product', 'deleted_at', 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.'); $this->addTimestampableBlamableComments('product'); } private function createProductSite(): void { $this->addSql(<<<'SQL' CREATE TABLE product_site ( product_id INT NOT NULL, site_id INT NOT NULL, PRIMARY KEY (product_id, site_id), CONSTRAINT fk_product_site_product FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE, CONSTRAINT fk_product_site_site FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT ) SQL); $this->addSql('CREATE INDEX idx_product_site_site ON product_site (site_id)'); $this->comment('product_site', '_table', 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).'); $this->comment('product_site', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.'); $this->comment('product_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.'); } private function createProductStorageType(): void { $this->addSql(<<<'SQL' CREATE TABLE product_storage_type ( product_id INT NOT NULL, storage_type_id INT NOT NULL, PRIMARY KEY (product_id, storage_type_id), CONSTRAINT fk_product_storage_type_product FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE, CONSTRAINT fk_product_storage_type_type FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT ) SQL); $this->addSql('CREATE INDEX idx_product_storage_type_type ON product_storage_type (storage_type_id)'); $this->comment('product_storage_type', '_table', 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).'); $this->comment('product_storage_type', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.'); $this->comment('product_storage_type', 'storage_type_id', 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.'); } // ================================================================= // Seed du type de categorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures // ================================================================= private function seedCategoryTypeProduit(): void { // Idempotent via l'index unique uq_category_type_code (comme CLIENT/FOURNISSEUR/ // PRESTATAIRE/ADRESSE). Les Category de type PRODUIT suivent en ERP-201. $this->addSql(<<<'SQL' INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit') ON CONFLICT (code) DO NOTHING SQL); } // ================================================================= // Helpers (identiques au M5 Version20260617150000) // ================================================================= /** * Pose les 4 commentaires standardises Timestampable/Blamable sur une table, * en reutilisant le catalogue partage (source unique, ERP-67). */ private function addTimestampableBlamableComments(string $table): void { foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) { $this->comment($table, $column, $description); } } /** * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN` * en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe. */ private function comment(string $table, string $column, string $description): void { $quotedTable = '"'.str_replace('"', '""', $table).'"'; if ('_table' === $column) { $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); return; } $this->addSql(sprintf( 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', $quotedTable, '"'.str_replace('"', '""', $column).'"', $description, )); } }