= 1). Sert aussi le cloisonnement * par site (idx_provider_site_site, § 2.13). * - provider_address SIMPLIFIEE : pas de address_type / bennes / * triage_provider (specifiques fournisseur). Champs : country / postal_code * / city / street / street_complement / position + M2M sites/contacts/categories. * * Referentiels comptables NON recrees : tva_mode / payment_delay / payment_type * / bank sont ceux du M1 (FK partagees, zero duplication — spec § 2.3). * * CategoryType PRESTATAIRE NON re-seede : il est cree par ERP-131 * (Version20260612080000) avec ses categories de demonstration. Le M2M * provider_category / provider_address_category s appuie sur ce type existant. * * Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON * `App\Module\Technique\...` : la migration cree un schema avec FK cross-module * (user, category, site, et les referentiels comptables M1). Avec plusieurs * migrations_paths, Doctrine Migrations 3.x trie par FQCN alphabetique — un * namespace modulaire s executerait avant la creation de user/category/site sur * base vide -> echec des FK. Le namespace racine garantit l ordre par timestamp. * * Style DDL aligne sur le M1/M2 (Version20260605130000) : `INT GENERATED BY * DEFAULT AS IDENTITY` (et non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non * TIMESTAMPTZ, car le TimestampableBlamableTrait mappe `datetime_immutable`). * Garantit que `schema:update` restera un no-op quand les entites arriveront * (ticket ERP-133). * * Decision unicite (alignee Q4 M1 / § 2.6 M2) : unicite metier sur le NOM DE * SOCIETE uniquement (uq_provider_company_name_active, partiel). Pas d index * unique sur siren ni email. * * COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne metier porte sa * description ici-meme. Volontairement NON ajoutees a `ColumnCommentsCatalog` / * `makefile test-db-setup` a ce stade : tant que les entites Provider* n existent * pas (ERP-133), `schema:update --force` du setup de test droppe ces tables non * mappees — les referencer dans le catalogue ferait planter * `app:apply-column-comments`. Le catalogue + la ligne `dbal:run-sql` * (uq_provider_company_name_active) seront ajoutes au ticket entites (ERP-133), * exactement comme supplier (ERP-86) apres sa migration (ERP-85). Les 4 colonnes * Timestampable/Blamable reutilisent les textes standardises du catalogue * (`timestampableBlamableComments()`, simple tableau statique sans dependance DB). */ final class Version20260612100000 extends AbstractMigration { public function getDescription(): string { return 'ERP-132 (M3) : tables provider + sous-collections + jointures M2M (referentiels comptables et CategoryType PRESTATAIRE reutilises).'; } public function up(Schema $schema): void { $this->createProviderTable(); $this->createProviderCategory(); $this->createProviderSite(); $this->createProviderContact(); $this->createProviderAddress(); $this->createProviderAddressJoinTables(); $this->createProviderRib(); } public function down(Schema $schema): void { // Ordre inverse des dependances FK : jointures et sous-collections // d abord, puis provider. Les referentiels comptables et le // CategoryType PRESTATAIRE ne sont pas touches (crees ailleurs). $this->addSql('DROP TABLE IF EXISTS provider_address_category'); $this->addSql('DROP TABLE IF EXISTS provider_address_contact'); $this->addSql('DROP TABLE IF EXISTS provider_address_site'); $this->addSql('DROP TABLE IF EXISTS provider_rib'); $this->addSql('DROP TABLE IF EXISTS provider_address'); $this->addSql('DROP TABLE IF EXISTS provider_contact'); $this->addSql('DROP TABLE IF EXISTS provider_site'); $this->addSql('DROP TABLE IF EXISTS provider_category'); $this->addSql('DROP TABLE IF EXISTS provider'); } // ================================================================= // Table principale `provider` // ================================================================= private function createProviderTable(): void { $this->addSql(<<<'SQL' CREATE TABLE provider ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, company_name VARCHAR(180) NOT NULL, siren VARCHAR(20) DEFAULT NULL, account_number VARCHAR(40) DEFAULT NULL, tva_mode_id INT DEFAULT NULL, n_tva VARCHAR(40) DEFAULT NULL, payment_delay_id INT DEFAULT NULL, payment_type_id INT DEFAULT NULL, bank_id INT DEFAULT NULL, is_archived BOOLEAN DEFAULT FALSE NOT NULL, archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT 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 fk_provider_tva_mode FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT, CONSTRAINT fk_provider_payment_delay FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT, CONSTRAINT fk_provider_payment_type FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT, CONSTRAINT fk_provider_bank FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT, CONSTRAINT fk_provider_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_provider_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); $this->addSql('CREATE INDEX idx_provider_is_archived ON provider (is_archived)'); $this->addSql('CREATE INDEX idx_provider_deleted_at ON provider (deleted_at)'); $this->addSql('CREATE INDEX idx_provider_created_by ON provider (created_by)'); $this->addSql('CREATE INDEX idx_provider_updated_by ON provider (updated_by)'); // Index sur les FK des referentiels comptables (Postgres n indexe pas // automatiquement les colonnes portant une FOREIGN KEY). $this->addSql('CREATE INDEX idx_provider_tva_mode_id ON provider (tva_mode_id)'); $this->addSql('CREATE INDEX idx_provider_payment_delay_id ON provider (payment_delay_id)'); $this->addSql('CREATE INDEX idx_provider_payment_type_id ON provider (payment_type_id)'); $this->addSql('CREATE INDEX idx_provider_bank_id ON provider (bank_id)'); // Unicite metier partielle : nom de societe insensible a la casse, parmi // les non-archives ET non soft-deletes uniquement (RG-3.10). Pas d index // unique sur siren ni email. $this->addSql(<<<'SQL' CREATE UNIQUE INDEX uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL SQL); $this->comment('provider', '_table', 'Repertoire prestataires (M3 Technique) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M4). Pas d onglet Information (≠ supplier).'); $this->comment('provider', 'id', 'Identifiant interne auto-incremente.'); $this->comment('provider', 'company_name', 'Raison sociale du prestataire (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_provider_company_name_active, RG-3.10).'); $this->comment('provider', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-3.10).'); $this->comment('provider', 'account_number', 'Onglet Comptabilite : numero de compte comptable du prestataire.'); $this->comment('provider', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.'); $this->comment('provider', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.'); $this->comment('provider', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.'); $this->comment('provider', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque si VIREMENT) et RG-3.08 (RIB).'); $this->comment('provider', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-3.07), null sinon.'); $this->comment('provider', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission technique.providers.archive.'); $this->comment('provider', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.'); $this->comment('provider', 'deleted_at', 'Horodatage du soft-delete technique (HP M4) — non expose par l API au M3. Null = ligne active.'); $this->addTimestampableBlamableComments('provider'); } // ================================================================= // M2M provider <-> category (type PRESTATAIRE — RG-3.09) // ================================================================= private function createProviderCategory(): void { $this->addSql(<<<'SQL' CREATE TABLE provider_category ( provider_id INT NOT NULL, category_id INT NOT NULL, PRIMARY KEY (provider_id, category_id), CONSTRAINT fk_provider_category_provider FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE, CONSTRAINT fk_provider_category_category FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT ) SQL); $this->addSql('CREATE INDEX idx_provider_category_category ON provider_category (category_id)'); $this->comment('provider_category', '_table', 'Jointure M2M provider <-> category (Catalog) — categories de type PRESTATAIRE du prestataire, au moins une obligatoire (RG-3.09).'); $this->comment('provider_category', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur de la categorie.'); $this->comment('provider_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie de type PRESTATAIRE rattachee au prestataire (RG-3.09).'); } // ================================================================= // M2M provider <-> site (formulaire principal — RG-3.03) // ================================================================= private function createProviderSite(): void { $this->addSql(<<<'SQL' CREATE TABLE provider_site ( provider_id INT NOT NULL, site_id INT NOT NULL, PRIMARY KEY (provider_id, site_id), CONSTRAINT fk_provider_site_provider FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE, CONSTRAINT fk_provider_site_site FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT ) SQL); // Index sur site_id : sert le filtre de cloisonnement par site // (WHERE site = :currentSite, § 2.13). $this->addSql('CREATE INDEX idx_provider_site_site ON provider_site (site_id)'); $this->comment('provider_site', '_table', 'Jointure M2M provider <-> site (Sites) — sites du prestataire, selecteur du formulaire principal, au moins un obligatoire (RG-3.03). Sert le cloisonnement par site (§ 2.13).'); $this->comment('provider_site', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur du site.'); $this->comment('provider_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache au prestataire (RG-3.03, idx_provider_site_site).'); } // ================================================================= // Sous-collection : contacts (1:n) // ================================================================= private function createProviderContact(): void { $this->addSql(<<<'SQL' CREATE TABLE provider_contact ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, provider_id INT NOT NULL, first_name VARCHAR(120) DEFAULT NULL, last_name VARCHAR(120) DEFAULT NULL, job_title VARCHAR(120) DEFAULT NULL, phone_primary VARCHAR(20) DEFAULT NULL, phone_secondary VARCHAR(20) DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, position INT DEFAULT 0 NOT 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_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL), CONSTRAINT fk_provider_contact_provider FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE, CONSTRAINT fk_provider_contact_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_provider_contact_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); $this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)'); $this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).'); $this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.'); $this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.'); $this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).'); $this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).'); $this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).'); $this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).'); $this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).'); $this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).'); $this->comment('provider_contact', 'position', 'Ordre d affichage du contact dans la liste du prestataire (croissant).'); $this->addTimestampableBlamableComments('provider_contact'); } // ================================================================= // Sous-collection : adresses (1:n) — SANS address_type / bennes / triage // ================================================================= private function createProviderAddress(): void { $this->addSql(<<<'SQL' CREATE TABLE provider_address ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, provider_id INT NOT NULL, country VARCHAR(80) DEFAULT 'France' NOT NULL, postal_code VARCHAR(20) NOT NULL, city VARCHAR(120) NOT NULL, street VARCHAR(255) NOT NULL, street_complement VARCHAR(255) DEFAULT NULL, position INT DEFAULT 0 NOT 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 fk_provider_address_provider FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE, CONSTRAINT fk_provider_address_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_provider_address_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); $this->addSql('CREATE INDEX idx_provider_address_provider ON provider_address (provider_id)'); $this->comment('provider_address', '_table', 'Adresses d un prestataire (1:n) — >= 1 site rattache (RG-3.05). SANS address_type / bennes / triage_provider (specifiques fournisseur).'); $this->comment('provider_address', 'id', 'Identifiant interne auto-incremente.'); $this->comment('provider_address', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire de l adresse.'); $this->comment('provider_address', 'country', 'Pays de l adresse — defaut France.'); $this->comment('provider_address', 'postal_code', 'Code postal (4-5 chiffres attendus) — declenche l autocompletion ville via l API BAN cote front (RG-3.06).'); $this->comment('provider_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.'); $this->comment('provider_address', 'street', 'Numero et voie de l adresse.'); $this->comment('provider_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.'); $this->comment('provider_address', 'position', 'Ordre d affichage de l adresse dans la liste du prestataire (croissant).'); $this->addTimestampableBlamableComments('provider_address'); } // ================================================================= // Jointures de provider_address (M2M) // ================================================================= private function createProviderAddressJoinTables(): void { $this->addSql(<<<'SQL' CREATE TABLE provider_address_site ( provider_address_id INT NOT NULL, site_id INT NOT NULL, PRIMARY KEY (provider_address_id, site_id), CONSTRAINT fk_provider_address_site_address FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE, CONSTRAINT fk_provider_address_site_site FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT ) SQL); $this->comment('provider_address_site', '_table', 'Jointure M2M provider_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-3.05).'); $this->comment('provider_address_site', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.'); $this->comment('provider_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.'); $this->addSql(<<<'SQL' CREATE TABLE provider_address_contact ( provider_address_id INT NOT NULL, provider_contact_id INT NOT NULL, PRIMARY KEY (provider_address_id, provider_contact_id), CONSTRAINT fk_provider_address_contact_address FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE, CONSTRAINT fk_provider_address_contact_contact FOREIGN KEY (provider_contact_id) REFERENCES provider_contact (id) ON DELETE CASCADE ) SQL); $this->comment('provider_address_contact', '_table', 'Jointure M2M provider_address <-> provider_contact — contacts associes a une adresse.'); $this->comment('provider_address_contact', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.'); $this->comment('provider_address_contact', 'provider_contact_id', 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.'); $this->addSql(<<<'SQL' CREATE TABLE provider_address_category ( provider_address_id INT NOT NULL, category_id INT NOT NULL, PRIMARY KEY (provider_address_id, category_id), CONSTRAINT fk_provider_address_category_address FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE, CONSTRAINT fk_provider_address_category_category FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT ) SQL); $this->comment('provider_address_category', '_table', 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).'); $this->comment('provider_address_category', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.'); $this->comment('provider_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).'); } // ================================================================= // Sous-collection : RIB (1:n) // ================================================================= private function createProviderRib(): void { $this->addSql(<<<'SQL' CREATE TABLE provider_rib ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, provider_id INT NOT NULL, label VARCHAR(120) NOT NULL, bic VARCHAR(20) NOT NULL, iban VARCHAR(34) NOT NULL, position INT DEFAULT 0 NOT 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 fk_provider_rib_provider FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE, CONSTRAINT fk_provider_rib_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_provider_rib_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); $this->addSql('CREATE INDEX idx_provider_rib_provider ON provider_rib (provider_id)'); $this->comment('provider_rib', '_table', 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).'); $this->comment('provider_rib', 'id', 'Identifiant interne auto-incremente.'); $this->comment('provider_rib', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du RIB.'); $this->comment('provider_rib', 'label', 'Libelle du RIB (ex: compte principal).'); $this->comment('provider_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).'); $this->comment('provider_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).'); $this->comment('provider_rib', 'position', 'Ordre d affichage du RIB dans la liste du prestataire (croissant).'); $this->addTimestampableBlamableComments('provider_rib'); } // ================================================================= // Helpers // ================================================================= /** * Pose les 4 commentaires standardises Timestampable/Blamable sur une table, * en reutilisant le catalogue partage (source unique, cf. ERP-67). Seul le * tableau statique des textes est reutilise — aucune dependance a l etat DB. */ 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, )); } }