elle s'executerait avant la creation * des tables et le seed dont elle depend. Le namespace racine garantit l'ordre * par timestamp. * * Idempotence : `ADD COLUMN IF NOT EXISTS`, `INSERT ... ON CONFLICT` / guards * `NOT EXISTS`, `CREATE UNIQUE INDEX IF NOT EXISTS`. En prod la table `category` * est vide (aucune fixture metier) : l'ajout de `code NOT NULL` est sur. En * dev/test, le purger Doctrine vide `category`/`category_type` avant les * fixtures, qui reproduisent le meme etat final (cf. CategoryTypeFixtures / * CategoryFixtures). */ final class Version20260602100000 extends AbstractMigration { /** * Categories systeme reportees depuis les anciens types : nom => code. * Le code est la cle metier stable (RG-1.03 / RG-1.29). */ private const array SYSTEM_CATEGORIES = [ 'Distributeur' => 'DISTRIBUTEUR', 'Courtier' => 'COURTIER', 'Secteur' => 'SECTEUR', 'Autre' => 'AUTRE', ]; /** Anciens codes de `category_type` devenus inutiles. */ private const array LEGACY_TYPE_CODES = ['DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE']; public function getDescription(): string { return 'ERP-78 : Category.code + type unique CLIENT (categories Distributeur/Courtier/Secteur/Autre codees, anciens types supprimes).'; } public function up(Schema $schema): void { // 1. Colonne `code` (nullable d'abord pour pouvoir backfiller, NOT NULL ensuite). $this->addSql('ALTER TABLE category ADD COLUMN IF NOT EXISTS code VARCHAR(50) DEFAULT NULL'); // 2. Type unique CLIENT (idempotent via l'index unique uq_category_type_code). $this->addSql(<<<'SQL' INSERT INTO category_type (code, label) VALUES ('CLIENT', 'Client') ON CONFLICT (code) DO NOTHING SQL); // 3. Re-pointer toute categorie pre-existante (rattachee a un ancien type) // vers le type CLIENT, en lui donnant un code derive du nom si absent. // En prod la table est vide -> no-op ; defensif pour les envs qui // auraient deja seede des categories sous les anciens types. Le slug // SQL est le miroir EXACT de CategoryCodeGenerator::slugify (cf. // CategoryCodeSql + CategoryCodeSqlSlugTest) : un nom accentue produit // le meme code que la generation applicative (« Independant » -> // INDEPENDANT, et non IND_PENDANT). $this->addSql( 'UPDATE category c ' ."SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'), " .'code = COALESCE(c.code, '.CategoryCodeSql::slugExpression('c.name').') ' .'WHERE c.category_type_id IN (SELECT id FROM category_type WHERE code IN (:legacyCodes))', ['legacyCodes' => self::LEGACY_TYPE_CODES], ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING], ); // 4. Creer les 4 categories systeme sous CLIENT (si leur code est libre // parmi les actifs). created_at/updated_at NOT NULL -> now() ; le blame // reste null (seed hors contexte HTTP, libelle « Systeme » cote front). foreach (self::SYSTEM_CATEGORIES as $name => $code) { $this->addSql(<<<'SQL' INSERT INTO category (name, code, category_type_id, created_at, updated_at) SELECT :name, :code, ct.id, NOW(), NOW() FROM category_type ct WHERE ct.code = 'CLIENT' AND NOT EXISTS ( SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL ) SQL, ['name' => $name, 'code' => $code]); } // 5. Backfill defensif : toute categorie encore sans code recoit un slug // de son nom (garantit que le SET NOT NULL passe). Meme expression de // slug fidele au generateur applicatif (CategoryCodeSql). $this->addSql( 'UPDATE category SET code = '.CategoryCodeSql::slugExpression('name').' WHERE code IS NULL', ); // 6. Index unique partiel sur le code parmi les actifs (non exprimable en // ORM : recree aussi dans `test-db-setup` apres schema:update). $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL'); // 7. Code desormais obligatoire. $this->addSql('ALTER TABLE category ALTER COLUMN code SET NOT NULL'); // 8. Documentation SQL (regle ABSOLUE n°12). Dollar-quoting Postgres. $this->addSql(<<<'SQL' COMMENT ON COLUMN category.code IS $_$Code technique stable (slug MAJUSCULE du nom, <= 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.$_$ SQL); // 9. Supprimer les anciens types devenus orphelins (aucune categorie ne // les reference plus apres le re-pointage de l'etape 3). Le guard // NOT EXISTS evite de casser sur la FK RESTRICT category.category_type_id. $this->addSql(<<<'SQL' DELETE FROM category_type WHERE code IN (:legacyCodes) AND NOT EXISTS ( SELECT 1 FROM category c WHERE c.category_type_id = category_type.id ) SQL, ['legacyCodes' => self::LEGACY_TYPE_CODES], ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING]); // 10. Realigner la doc SQL de client_address_category (migration mergee // Version20260601000000, non editable) sur le nouveau modele RG-1.29. $this->addSql(<<<'SQL' COMMENT ON TABLE client_address_category IS $_$Jointure M2M client_address <-> category — codes DISTRIBUTEUR/COURTIER interdits sur une adresse (RG-1.29).$_$ SQL); $this->addSql(<<<'SQL' COMMENT ON COLUMN client_address_category.category_id IS $_$FK -> category.id, ON DELETE RESTRICT — categorie d adresse (tout code sauf DISTRIBUTEUR/COURTIER, RG-1.29).$_$ SQL); } public function down(Schema $schema): void { // Best-effort : rollback du modele CLIENT vers les 4 anciens types. // 1. Retirer l'index unique sur le code. $this->addSql('DROP INDEX IF EXISTS uq_category_code'); // 2. Recreer les 4 anciens types. $this->addSql(<<<'SQL' INSERT INTO category_type (code, label) VALUES ('DISTRIBUTEUR', 'Distributeur'), ('COURTIER', 'Courtier'), ('SECTEUR', 'Secteur'), ('AUTRE', 'Autre') ON CONFLICT (code) DO NOTHING SQL); // 3. Re-pointer les categories systeme (par code) vers leur type d'origine. // Codes inlines : constantes controlees (self::SYSTEM_CATEGORIES), pas // d'entree utilisateur — evite le binding d'un parametre nomme repete. foreach (self::SYSTEM_CATEGORIES as $name => $code) { $this->addSql(sprintf( "UPDATE category SET category_type_id = (SELECT id FROM category_type WHERE code = '%s') WHERE code = '%s'", $code, $code, )); } // 4. Supprimer le type CLIENT s'il ne reference plus aucune categorie. $this->addSql(<<<'SQL' DELETE FROM category_type WHERE code = 'CLIENT' AND NOT EXISTS ( SELECT 1 FROM category c WHERE c.category_type_id = category_type.id ) SQL); // 5. Retirer la colonne code (les categories libres sans type d'origine // restent sous CLIENT si encore presentes — rollback uniquement // pertinent en prod ou seules les 4 categories systeme existent). $this->addSql('ALTER TABLE category DROP COLUMN IF EXISTS code'); } }