= 1). Comme pour product.states (M6), PAS de * DEFAULT '[]'::jsonb : un tableau vide violerait ce CHECK ; la colonne est * toujours renseignee par l'app (Processor/ORM). * * Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire : la * table storage porte des FK cross-module (site, storage_type, user). Le tri par * timestamp au sein du namespace racine garantit l'ordre apres la creation de ces * tables sur base vide ; un namespace modulaire trierait par FQCN alphabetique et * casserait `make db-reset` (cf. Version20260625110000 pour le M6). * * 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) : `storage` sera mappee en ORM au ticket suivant * (entite Storage). D'ici la, `schema:update --force` la drope sur la base de TEST * uniquement (sans impact : aucun test ne la reference encore, et dev/prod ne lancent * jamais schema:update). Sa description sera ajoutee a ColumnCommentsCatalog au ticket * entite (comme product / weighing_ticket). */ final class Version20260629120000 extends AbstractMigration { public function getDescription(): string { return 'ERP-211 (M7) : creation de la table storage (FK site + storage_type, unicite metier partielle RG-7.01, etats JSONB RG-7.04, soft-delete + Timestampable/Blamable).'; } public function up(Schema $schema): void { $this->addSql(<<<'SQL' CREATE TABLE storage ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, site_id INT NOT NULL, storage_type_id INT NOT NULL, numero VARCHAR(50) NOT NULL, -- Pas de DEFAULT : un tableau vide violerait chk_storage_states_not_empty -- (RG-7.04). La colonne est toujours renseignee par l'app (Processor/ORM). states JSONB 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_storage_states_not_empty CHECK (jsonb_array_length(states) >= 1), CONSTRAINT fk_storage_site FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT, CONSTRAINT fk_storage_storage_type FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT, CONSTRAINT fk_storage_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_storage_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); // RG-7.01 : unicite (site, type, numero) parmi les stockages actifs uniquement // (index partiel) — un numero redevient disponible apres soft-delete. $this->addSql('CREATE UNIQUE INDEX uq_storage_site_type_numero_active ON storage (site_id, storage_type_id, numero) WHERE deleted_at IS NULL'); $this->addSql('CREATE INDEX idx_storage_site ON storage (site_id)'); $this->addSql('CREATE INDEX idx_storage_storage_type ON storage (storage_type_id)'); $this->addSql('CREATE INDEX idx_storage_deleted_at ON storage (deleted_at)'); $this->addSql('CREATE INDEX idx_storage_created_by ON storage (created_by)'); $this->addSql('CREATE INDEX idx_storage_updated_by ON storage (updated_by)'); $this->comment('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.'); $this->comment('storage', 'id', 'Identifiant interne auto-incremente.'); $this->comment('storage', 'site_id', 'Site du stockage. FK -> site.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).'); $this->comment('storage', 'storage_type_id', 'Type de stockage (referentiel M6). FK -> storage_type.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).'); $this->comment('storage', 'numero', 'Numero du stockage (≤ 50), saisi. Unique par (site, type) parmi les actifs (RG-7.01, uq_storage_site_type_numero_active). Normalise serveur.'); $this->comment('storage', 'states', 'Etats du stockage (JSON) : tableau non vide (>= 1 element, RG-7.04, chk_storage_states_not_empty). Multi-valeur.'); $this->comment('storage', '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).'); $this->addTimestampableBlamableComments('storage'); } public function down(Schema $schema): void { $this->addSql('DROP TABLE storage'); } /** * 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, )); } }