[ERP-78] Refonte taxonomie Catégories : type unique CLIENT + Category.code + RG-1.03/1.29 par code (#42)
Auto Tag Develop / tag (push) Successful in 8s
Auto Tag Develop / tag (push) Successful in 8s
Refonte de la taxonomie Catégories (décision produit 01/06) : le modèle est inversé. ## Modèle - **UN SEUL `category_type` : CLIENT**. `Distributeur` / `Courtier` / `Secteur` / `Autre` (+ catégories métier) deviennent des `Category` rattachées à CLIENT. - Filtrage métier sur un **`code` stable porté par `Category`** (NOT NULL, unique partiel `uq_category_code`), slug MAJUSCULE auto-généré du nom (`CategoryCodeGenerator`), figé à la création, exposé en **lecture seule**. ## Contenu - **M0** : `Category.code` (entité + migration corrective `Version20260602100000` au namespace racine + `COMMENT ON COLUMN` + catalogue + ligne `test-db-setup`). Retrofit `Version20260528120000` rendu conscient des colonnes. - **Seed** : type unique CLIENT, catégories codées (`Distributeur→DISTRIBUTEUR`, etc.), anciens types supprimés. Fixtures `CategoryType`/`Category`/`Client` alignées. - **RG-1.03** : `ClientProcessor::hasCategoryCode` — un distributor/broker doit porter la `Category` de code `DISTRIBUTEUR`/`COURTIER`. Filtre liste/export `categoryType` → `categoryCode`. - **RG-1.29** : `ClientAddress::validateCategoryCodes` — denylist des codes `DISTRIBUTEUR`/`COURTIER` sur une adresse (toute autre catégorie autorisée). - **Specs** M0/M1 (back + front) amendées. ## Tests `make php-cs-fixer-allow-risky` OK ; `make db-reset` OK (type unique CLIENT + 11 catégories codées, idempotent) ; `make test` **443 vert**. Ajouts : RG-1.03 courtier, génération/unicité/lecture-seule du code (`CategoryCodeTest`). ## Coordination - #76 (#500) : RG-1.29 réécrite ici sur le nouveau modèle ; #76 ne garde que le gap 2 (mapping CHECK adresse → 422), indépendant de la taxonomie. - ERP-68 (#486) : fixtures démo (déjà mergées via #41) adaptées ici au type unique CLIENT + codes. - Front #480–483 : selects Catégorie / distributeur / courtier basés sur le `code` (`?categoryCode=`), plus le type. Décisions actées avec le PO : `code` NOT NULL auto-généré (slug) ; périmètre complet (réécriture RG + fixtures déjà mergées). --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #42 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #42.
This commit is contained in:
@@ -39,20 +39,40 @@ final class Version20260528120000 extends AbstractMigration
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Ne commente que les tables deja presentes a ce stade de la chaine de
|
||||
// migrations. Les modules crees plus tard (ex: M1 Commercial, 06-01)
|
||||
// figurent desormais dans le catalogue partage mais leurs tables
|
||||
// n'existent pas encore ici : elles posent leurs propres COMMENT dans
|
||||
// leur migration dediee (regle ABSOLUE n°12). Garde-fou indispensable,
|
||||
// sinon l'ajout d'un module au catalogue casse ce retrofit avec un
|
||||
// "relation X does not exist".
|
||||
$existingTables = array_values(array_filter(
|
||||
array_keys(ColumnCommentsCatalog::comments()),
|
||||
static fn (string $table): bool => $schema->hasTable($table),
|
||||
));
|
||||
// Ne commente que les tables ET colonnes deja presentes a ce stade de la
|
||||
// chaine de migrations. Les tables des modules crees plus tard (M1
|
||||
// Commercial, 06-01) ET les colonnes ajoutees ensuite sur une table
|
||||
// existante (ex: category.code, ERP-78 06-02) figurent desormais dans le
|
||||
// catalogue partage mais n'existent pas encore ici : elles posent leur
|
||||
// propre COMMENT dans leur migration dediee (regle ABSOLUE n°12). Garde-fou
|
||||
// indispensable (table + colonne), sinon enrichir le catalogue casse ce
|
||||
// retrofit avec un "relation/column X does not exist".
|
||||
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
|
||||
if (!$schema->hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (ColumnCommentsCatalog::toSqlStatements($existingTables) as $sql) {
|
||||
$this->addSql($sql);
|
||||
$dbTable = $schema->getTable($table);
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
foreach ($entries as $column => $description) {
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$dbTable->hasColumn($column)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\CategoryCodeSql;
|
||||
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. 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user