Files
Starseed/migrations/Version20260625110000.php
T
matthieu 4207a4ae12
Auto Tag Develop / tag (push) Successful in 11s
feat(catalog) : M6 — Catalogue produits (ERP-197 → ERP-203) (#154)
Module **M6 — Catalogue produits** (ERP-197 → ERP-203), pile consolidée en une seule MR vers `develop` pour un CI unique.

Contenu (commits) :
- ERP-197 — permissions `catalog.products.*` + sidebar + 3 miroirs RBAC
- ERP-198 — migration schéma M6 (storage_type, product, jonctions, type PRODUIT)
- ERP-199 — entités Product + StorageType + repositories + contrat de sérialisation
- ERP-200 — ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06, normalisation)
- ERP-201 — référentiel StorageType exposé (filtre site) + seed Figma + catégories PRODUIT
- ERP-202 — export XLSX du catalogue produits (filtres liste)
- ERP-203 — tests PHPUnit RG-6.01→6.10 + capture du contrat JSON produit
- fix review M6 — default jsonb mort (states) + constante préfixe storage-type de test

Remplace et clôt les MR #148, #149, #150, #151, #152, #153 (commits intégralement inclus ici).

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #154
2026-06-25 12:50:14 +00:00

266 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M6 — Catalogue produit (ERP-198) : creation du schema BDD du module.
*
* Objets crees (spec-back § 3.2) :
* - storage_type : referentiel PROVISOIRE des types de stockage (en attente de la
* liste definitive d'Aurore — § 2.4 / RG-6.06). Lecture seule au M6.
* - storage_type_site : jonction M2M storage_type <-> site (sur quels sites un type
* de stockage est disponible — alimente le filtrage du multi-select par site).
* - product : table principale (code unique global parmi les actifs, etats
* multi-valeur JSONB, champs conditionnels SALE, categorie de type PRODUIT,
* soft-delete prepare + Timestampable/Blamable).
* - product_site : jonction M2M product <-> site (sites de disponibilite, RG-6.04).
* - product_storage_type : jonction M2M product <-> storage_type (RG-6.06).
*
* Seed : ajout du `category_type` PRODUIT (miroir CategoryTypeFixtures, comme
* CLIENT/FOURNISSEUR/PRESTATAIRE/ADRESSE — § 2.5). Les `Category` de type PRODUIT et
* le seed Figma du referentiel storage_type suivent au ticket ERP-201.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
* la table product porte des FK cross-module (user, site, category). Le tri par
* timestamp au sein du namespace racine garantit l'ordre apres la creation de ces
* tables sur base vide ; un namespace modulaire casserait `make db-reset` (cf.
* Version20260617150000 pour le M5).
*
* 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) : product / storage_type et leurs jonctions seront
* mappes en ORM au ticket suivant (entites Product + StorageType, ERP-199). D'ici la,
* `schema:update --force` les drope sur la base de TEST uniquement (sans impact :
* aucun test ne les reference encore, et dev/prod ne lancent jamais schema:update).
* Leurs descriptions seront ajoutees a ColumnCommentsCatalog au ticket entites (comme
* weighing_ticket : migration ERP-182, catalogue ERP-183).
*/
final class Version20260625110000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-198 (M6) : storage_type (+ jonction site) + product (+ jonctions site/stockage) + seed category_type PRODUIT.';
}
public function up(Schema $schema): void
{
$this->createStorageType();
$this->createStorageTypeSite();
$this->createProduct();
$this->createProductSite();
$this->createProductStorageType();
$this->seedCategoryTypeProduit();
}
public function down(Schema $schema): void
{
// Ordre inverse des dependances FK.
$this->addSql('DROP TABLE IF EXISTS product_storage_type');
$this->addSql('DROP TABLE IF EXISTS product_site');
$this->addSql('DROP TABLE IF EXISTS product');
$this->addSql('DROP TABLE IF EXISTS storage_type_site');
$this->addSql('DROP TABLE IF EXISTS storage_type');
// Retrait du type seede (best-effort : echoue si des categories le referencent
// encore — attendu, le down sert au dev sur base saine).
$this->addSql("DELETE FROM category_type WHERE code = 'PRODUIT'");
}
// =================================================================
// Referentiel des types de stockage (PROVISOIRE) — § 2.4 / RG-6.06
// =================================================================
private function createStorageType(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE storage_type (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(40) NOT NULL,
label VARCHAR(120) NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code)');
$this->comment('storage_type', '_table', 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.');
$this->comment('storage_type', 'id', 'Identifiant interne auto-incremente.');
$this->comment('storage_type', 'code', 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).');
$this->comment('storage_type', 'label', 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).');
}
private function createStorageTypeSite(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE storage_type_site (
storage_type_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (storage_type_id, site_id),
CONSTRAINT fk_storage_type_site_type
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE CASCADE,
CONSTRAINT fk_storage_type_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
)
SQL);
$this->addSql('CREATE INDEX idx_storage_type_site_site ON storage_type_site (site_id)');
$this->comment('storage_type_site', '_table', 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).');
$this->comment('storage_type_site', 'storage_type_id', 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.');
$this->comment('storage_type_site', 'site_id', 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.');
}
// =================================================================
// Table principale `product`
// =================================================================
private function createProduct(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE product (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
-- Pas de DEFAULT : un tableau vide violerait chk_product_states_not_empty
-- (RG-6.02). La colonne est toujours renseignee par l'app (Processor/ORM).
states JSONB NOT NULL,
manufactured BOOLEAN DEFAULT FALSE NOT NULL,
contains_molasses BOOLEAN DEFAULT FALSE NOT NULL,
category_id INT 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_product_states_not_empty
CHECK (jsonb_array_length(states) >= 1),
CONSTRAINT fk_product_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT,
CONSTRAINT fk_product_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_product_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Unicite GLOBALE du code parmi les actifs (soft-delete tolere) — index partiel.
$this->addSql('CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL');
$this->addSql('CREATE INDEX idx_product_category ON product (category_id)');
$this->addSql('CREATE INDEX idx_product_deleted_at ON product (deleted_at)');
$this->addSql('CREATE INDEX idx_product_created_by ON product (created_by)');
$this->addSql('CREATE INDEX idx_product_updated_by ON product (updated_by)');
$this->comment('product', '_table', 'Produits du catalogue (M6 Catalog) etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.');
$this->comment('product', 'id', 'Identifiant interne auto-incremente.');
$this->comment('product', 'code', 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).');
$this->comment('product', 'name', 'Nom du produit ( 255). Normalise serveur (trim).');
$this->comment('product', 'states', 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.');
$this->comment('product', 'manufactured', '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).');
$this->comment('product', 'contains_molasses', '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).');
$this->comment('product', 'category_id', 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) type PRODUIT, obligatoire, validee applicativement (RG-6.05).');
$this->comment('product', 'deleted_at', 'Horodatage du soft-delete technique non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.');
$this->addTimestampableBlamableComments('product');
}
private function createProductSite(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE product_site (
product_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (product_id, site_id),
CONSTRAINT fk_product_site_product
FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE,
CONSTRAINT fk_product_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
)
SQL);
$this->addSql('CREATE INDEX idx_product_site_site ON product_site (site_id)');
$this->comment('product_site', '_table', 'Jointure M2M product <-> site (Sites) sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).');
$this->comment('product_site', 'product_id', 'FK -> product.id, ON DELETE CASCADE produit concerne.');
$this->comment('product_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT site de disponibilite rattache au produit.');
}
private function createProductStorageType(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE product_storage_type (
product_id INT NOT NULL,
storage_type_id INT NOT NULL,
PRIMARY KEY (product_id, storage_type_id),
CONSTRAINT fk_product_storage_type_product
FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE,
CONSTRAINT fk_product_storage_type_type
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT
)
SQL);
$this->addSql('CREATE INDEX idx_product_storage_type_type ON product_storage_type (storage_type_id)');
$this->comment('product_storage_type', '_table', 'Jointure M2M product <-> storage_type types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).');
$this->comment('product_storage_type', 'product_id', 'FK -> product.id, ON DELETE CASCADE produit concerne.');
$this->comment('product_storage_type', 'storage_type_id', 'FK -> storage_type.id, ON DELETE RESTRICT type de stockage rattache au produit.');
}
// =================================================================
// Seed du type de categorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
// =================================================================
private function seedCategoryTypeProduit(): void
{
// Idempotent via l'index unique uq_category_type_code (comme CLIENT/FOURNISSEUR/
// PRESTATAIRE/ADRESSE). Les Category de type PRODUIT suivent en ERP-201.
$this->addSql(<<<'SQL'
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
ON CONFLICT (code) DO NOTHING
SQL);
}
// =================================================================
// Helpers (identiques au M5 Version20260617150000)
// =================================================================
/**
* 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,
));
}
}