feat(catalog) : single CLIENT category type + corrective migration

Migration corrective Version20260602100000 (namespace racine) : ajoute
Category.code, cree le type unique CLIENT, reporte Distributeur/Courtier/
Secteur/Autre en Category codees sous CLIENT, supprime les anciens types.
Fixtures alignees (type unique CLIENT, categories codees). Index partiel
uq_category_code recree dans test-db-setup.
This commit is contained in:
Matthieu
2026-06-02 09:20:58 +02:00
parent 13bb51ee9a
commit 636f2ccb8e
4 changed files with 256 additions and 73 deletions
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-78 — Refonte de la taxonomie Categories (M0/M1).
*
* Modele AVANT (merge via Version20260527164000 + Version20260601000000) :
* DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE sont des `category_type`.
*
* Modele APRES (decision produit 01/06) :
* - UN SEUL `category_type` : CLIENT (code CLIENT, label « Client ») ;
* - Distributeur / Courtier / Secteur / Autre (+ categories metier fines)
* deviennent des `Category` rattachees au type CLIENT ;
* - filtrage metier sur un `code` stable porte par la `Category` (et non plus
* par le type) : on reporte les codes DISTRIBUTEUR / COURTIER sur la categorie
* correspondante. RG-1.03 (distributor/broker) et RG-1.29 (categorie interdite
* sur adresse) s'appuient desormais sur `category.code`.
*
* Migration CORRECTIVE et NOUVELLE : la migration mergee Version20260601000000
* (qui a pu tourner en CI / chez d'autres devs) n'est PAS editee.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire
* Catalog : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par
* FQCN alphabetique (AlphabeticalComparator). Introduire la 1re migration
* modulaire `App\Module\Catalog\...` la ferait trier AVANT toutes les
* `DoctrineMigrations\...` sur base vide -> 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.
$this->addSql(<<<'SQL'
UPDATE category c
SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'),
code = COALESCE(
c.code,
LEFT(UPPER(REGEXP_REPLACE(c.name, '[^A-Za-z0-9]+', '_', 'g')), 50)
)
WHERE c.category_type_id IN (
SELECT id FROM category_type WHERE code IN (:legacyCodes)
)
SQL, ['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).
$this->addSql(<<<'SQL'
UPDATE category
SET code = LEFT(UPPER(REGEXP_REPLACE(name, '[^A-Za-z0-9]+', '_', 'g')), 50)
WHERE code IS NULL
SQL);
// 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');
}
}