a9c14704b7
Auto Tag Develop / tag (push) Successful in 7s
## Contexte Une `Category` ne pouvait appartenir qu'à **un seul** `CategoryType` (ManyToOne). Le besoin métier : plusieurs types par catégorie. Cette MR fait passer la relation en **ManyToMany** et ajoute le bouton **« Filtres »** à droite de la liste des catégories (modèle Répertoire Clients). Slice vertical complet (le passage M:N casse mécaniquement le contrat inter-module `CategoryInterface`, consommé par la RG-2.10 fournisseurs). ## Volet A — Relation M:N - `Category.categoryType` (ManyToOne) → `categoryTypes` (ManyToMany, jonction `category_category_type`). Au moins un type obligatoire (`Assert\\Count(min:1)`). - **Unicité du nom GLOBALE** parmi les actifs (`uq_category_name_active`, remplace `uq_category_name_type_active`). Message 409 reformulé. - Migration : table de jonction + backfill + drop colonne `category_type_id` + nouvel index. Validée **rejouable sur base fraîche**. - Contrat Shared : `getCategoryTypeCode()` → `getCategoryTypeCodes(): array`. `Supplier`/`SupplierAddress`/`SupplierFixtures` revalident « contient FOURNISSEUR » (RG-2.10). - Provider/Repository : filtre type via sous-requête `EXISTS` (ne tronque pas la collection embarquée), eager-load anti-N+1. ## Volet B — Bouton « Filtres » - Drawer recherche par nom + types multi (OR). Compteur de filtres actifs. État local, jamais persisté en URL. - Back : filtres `?name=` et `?typeId[]=` sur la collection. ## Front - Multi-select `MalioSelectCheckbox`, `useCategoryForm` en `categoryTypeIds[]`, colonne « Types », clés i18n. ## Tests / vérifs - `make test` : **582 tests, 2474 assertions, 0 échec** ✅ - `make nuxt-test` : **236 tests** ✅ - `make php-cs-fixer-allow-risky` ✅ - Migration rejouée sur base fraîche (`make db-reset`) ✅ - Nouveau `CategoryFilterTest` (name partiel + typeId[] OR + multi-type non dupliqué) --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #75 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
150 lines
6.8 KiB
PHP
150 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace DoctrineMigrations;
|
|
|
|
use Doctrine\DBAL\Schema\Schema;
|
|
use Doctrine\Migrations\AbstractMigration;
|
|
|
|
/**
|
|
* Catalog — Category multi-types : passage de la relation Category -> CategoryType
|
|
* de ManyToOne a ManyToMany.
|
|
*
|
|
* Ordre critique :
|
|
* 1. Creation de la table de jonction `category_category_type` (FK category ON
|
|
* DELETE CASCADE, FK category_type ON DELETE RESTRICT — conserve le garde-fou
|
|
* « on ne supprime pas un type encore reference »).
|
|
* 2. Backfill : chaque categorie existante recoit une ligne de jonction vers son
|
|
* ancien `category_type_id` (avant de dropper la colonne).
|
|
* 3. Drop de l'index unique (LOWER(name), category_type_id), de l'index FK et de
|
|
* la colonne `category.category_type_id` (Postgres drope la FK dependante).
|
|
* 4. Nouvel index unique GLOBAL sur le nom : LOWER(name) WHERE deleted_at IS NULL
|
|
* (l'unicite n'est plus liee au type — RG-1.07 reformulee).
|
|
*
|
|
* Sur base fraiche, les categories seedees CLIENT (Distributeur/Courtier/Secteur/
|
|
* Autre) et FOURNISSEUR (Negociant/Cooperative/...) n'ont aucun nom en collision
|
|
* -> l'index unique global passe sans conflit.
|
|
*
|
|
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) :
|
|
* Doctrine Migrations 3.x trie par FQCN puis version ; le namespace racine garantit
|
|
* l'ordre par timestamp apres les migrations d'init des tables.
|
|
*/
|
|
final class Version20260608120000 extends AbstractMigration
|
|
{
|
|
public function getDescription(): string
|
|
{
|
|
return 'Catalog : Category <-> CategoryType en ManyToMany (jonction category_category_type), unicite du nom globalisee.';
|
|
}
|
|
|
|
public function up(Schema $schema): void
|
|
{
|
|
// 1. Table de jonction.
|
|
$this->addSql(<<<'SQL'
|
|
CREATE TABLE category_category_type (
|
|
category_id INT NOT NULL,
|
|
category_type_id INT NOT NULL,
|
|
PRIMARY KEY (category_id, category_type_id),
|
|
CONSTRAINT fk_category_category_type_category
|
|
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_category_category_type_type
|
|
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
|
|
)
|
|
SQL);
|
|
$this->addSql('CREATE INDEX idx_cat_cat_type_type ON category_category_type (category_type_id)');
|
|
|
|
$this->comment('category_category_type', '_table', 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).');
|
|
$this->comment('category_category_type', 'category_id', 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.');
|
|
$this->comment('category_category_type', 'category_type_id', 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).');
|
|
|
|
// 2. Backfill depuis l'ancienne colonne ManyToOne (chaque categorie -> 1 type).
|
|
$this->addSql(<<<'SQL'
|
|
INSERT INTO category_category_type (category_id, category_type_id)
|
|
SELECT id, category_type_id FROM category
|
|
SQL);
|
|
|
|
// 3. Suppression de l'ancien modele : index unique par type, index FK, colonne.
|
|
$this->addSql('DROP INDEX uq_category_name_type_active');
|
|
$this->addSql('DROP INDEX idx_category_type_id');
|
|
// DROP COLUMN drope automatiquement la FK fk_category_type qui en depend.
|
|
$this->addSql('ALTER TABLE category DROP COLUMN category_type_id');
|
|
|
|
// 4. Unicite du nom desormais GLOBALE parmi les actifs (RG-1.07 reformulee).
|
|
$this->addSql(<<<'SQL'
|
|
CREATE UNIQUE INDEX uq_category_name_active
|
|
ON category (LOWER(name))
|
|
WHERE deleted_at IS NULL
|
|
SQL);
|
|
|
|
// Realignement de la doc SQL de `category` (le type n'est plus une colonne).
|
|
$this->comment('category', '_table', 'Categories — referentiel multi-types via la jonction category_category_type, soft-delete via deleted_at, unicite LOWER(name) GLOBALE parmi les actifs (uq_category_name_active).');
|
|
$this->comment('category', 'name', 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).');
|
|
}
|
|
|
|
public function down(Schema $schema): void
|
|
{
|
|
// Restauration best-effort de l'ancien modele ManyToOne (1 type par categorie).
|
|
$this->addSql('DROP INDEX IF EXISTS uq_category_name_active');
|
|
|
|
$this->addSql('ALTER TABLE category ADD COLUMN category_type_id INT DEFAULT NULL');
|
|
|
|
// Reprend le premier type de chaque categorie (l'ordre des types perdus
|
|
// au-dela du premier est best-effort : le modele cible n'en gardait qu'un).
|
|
$this->addSql(<<<'SQL'
|
|
UPDATE category c
|
|
SET category_type_id = (
|
|
SELECT cct.category_type_id
|
|
FROM category_category_type cct
|
|
WHERE cct.category_id = c.id
|
|
ORDER BY cct.category_type_id ASC
|
|
LIMIT 1
|
|
)
|
|
SQL);
|
|
|
|
// Categories sans aucun type (theorique) : on les rattache a defaut au
|
|
// premier type existant pour pouvoir reposer le NOT NULL.
|
|
$this->addSql(<<<'SQL'
|
|
UPDATE category
|
|
SET category_type_id = (SELECT id FROM category_type ORDER BY id ASC LIMIT 1)
|
|
WHERE category_type_id IS NULL
|
|
SQL);
|
|
|
|
$this->addSql('ALTER TABLE category ALTER COLUMN category_type_id SET NOT NULL');
|
|
$this->addSql(<<<'SQL'
|
|
ALTER TABLE category
|
|
ADD CONSTRAINT fk_category_type
|
|
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
|
|
SQL);
|
|
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
|
|
$this->addSql(<<<'SQL'
|
|
CREATE UNIQUE INDEX uq_category_name_type_active
|
|
ON category (LOWER(name), category_type_id)
|
|
WHERE deleted_at IS NULL
|
|
SQL);
|
|
|
|
$this->addSql('DROP TABLE category_category_type');
|
|
}
|
|
|
|
/**
|
|
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
|
|
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement.
|
|
*/
|
|
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,
|
|
));
|
|
}
|
|
}
|