From 5409c79d1d0e2a966ff43152433e73bab125231c Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 25 Jun 2026 10:20:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(catalog)=20:=20ERP-198=20=E2=80=94=20migra?= =?UTF-8?q?tion=20sch=C3=A9ma=20M6=20(storage=5Ftype,=20product,=20jonctio?= =?UTF-8?q?ns,=20type=20PRODUIT)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/Version20260625110000.php | 263 +++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 migrations/Version20260625110000.php diff --git a/migrations/Version20260625110000.php b/migrations/Version20260625110000.php new file mode 100644 index 0000000..341d004 --- /dev/null +++ b/migrations/Version20260625110000.php @@ -0,0 +1,263 @@ + 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, + )); + } +}