string de l'ORM). Seule * carrier.qualimat_carrier_id est BIGINT pour matcher qualimat_carrier.id (existant). * Horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe * `datetime_immutable`), pour que `schema:update --force` reste un no-op. * * Chaque colonne porte son `COMMENT ON COLUMN` (regle ABSOLUE n°12). Les 4 * tables carrier* etant mappees par l'ORM des ce ticket, elles sont aussi ajoutees * a ColumnCommentsCatalog : `app:apply-column-comments` (test-db-setup) rejoue ces * COMMENT apres le `schema:update --force` qui les droperait sinon. */ final class Version20260615150000 extends AbstractMigration { public function getDescription(): string { return 'ERP-155/157 (M4) : tables carrier + carrier_address + carrier_contact + carrier_price (repertoire transporteurs).'; } public function up(Schema $schema): void { $this->createCarrierTable(); $this->createCarrierAddress(); $this->createCarrierContact(); $this->createCarrierPrice(); } public function down(Schema $schema): void { // Ordre inverse des dependances FK : sous-collections d'abord, puis carrier. $this->addSql('DROP TABLE IF EXISTS carrier_price'); $this->addSql('DROP TABLE IF EXISTS carrier_contact'); $this->addSql('DROP TABLE IF EXISTS carrier_address'); $this->addSql('DROP TABLE IF EXISTS carrier'); } // ================================================================= // Table principale `carrier` // ================================================================= private function createCarrierTable(): void { $this->addSql(<<<'SQL' CREATE TABLE carrier ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, qualimat_carrier_id BIGINT DEFAULT NULL, name VARCHAR(255) NOT NULL, certification_type VARCHAR(20) DEFAULT NULL, is_chartered BOOLEAN DEFAULT FALSE NOT NULL, indexation_rate NUMERIC(5, 2) DEFAULT NULL, container_type VARCHAR(12) DEFAULT NULL, volume_m3 NUMERIC(10, 2) DEFAULT NULL, discharge_document_id INT DEFAULT NULL, liot_plates TEXT 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 chk_carrier_certification_type CHECK (certification_type IS NULL OR certification_type IN ('QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE')), CONSTRAINT chk_carrier_container_type CHECK (container_type IS NULL OR container_type IN ('BENNE', 'FOND_MOUVANT')), CONSTRAINT fk_carrier_qualimat FOREIGN KEY (qualimat_carrier_id) REFERENCES qualimat_carrier (id) ON DELETE SET NULL, CONSTRAINT fk_carrier_discharge_document FOREIGN KEY (discharge_document_id) REFERENCES uploaded_document (id) ON DELETE SET NULL, CONSTRAINT fk_carrier_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_carrier_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); $this->addSql('CREATE INDEX idx_carrier_is_archived ON carrier (is_archived)'); $this->addSql('CREATE INDEX idx_carrier_deleted_at ON carrier (deleted_at)'); $this->addSql('CREATE INDEX idx_carrier_qualimat ON carrier (qualimat_carrier_id)'); $this->addSql('CREATE INDEX idx_carrier_discharge_document ON carrier (discharge_document_id)'); $this->addSql('CREATE INDEX idx_carrier_created_by ON carrier (created_by)'); $this->addSql('CREATE INDEX idx_carrier_updated_by ON carrier (updated_by)'); // Unicite metier partielle : nom insensible a la casse, parmi les // non-archives ET non soft-deletes uniquement (§ 2.6). Inexprimable en ORM. $this->addSql(<<<'SQL' CREATE UNIQUE INDEX uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL SQL); $this->comment('carrier', '_table', 'Repertoire transporteurs (M4 Transport) — entites editables, archivables (is_archived) et soft-deletables (deleted_at). Distinct du referentiel qualimat_carrier.'); $this->comment('carrier', 'id', 'Identifiant interne auto-incremente.'); $this->comment('carrier', 'qualimat_carrier_id', 'Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01). FK -> qualimat_carrier.id, ON DELETE SET NULL : transporteur conserve si la ligne QUALIMAT disparait.'); $this->comment('carrier', 'name', 'Raison sociale du transporteur (stockee en MAJUSCULES). Unique case-insensitive parmi les non-archives/non-supprimes (uq_carrier_name_active, RG-4.12 / § 2.6).'); $this->comment('carrier', 'certification_type', 'Type de certification : QUALIMAT (si lie, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE declenche le champ Decharge (RG-4.02). Null en cas LIOT (RG-4.01).'); $this->comment('carrier', 'is_chartered', '« Affreter » coche : declenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03). Faux par defaut.'); $this->comment('carrier', 'indexation_rate', 'Taux d indexation en pourcentage (NUMERIC 5,2) — renseigne si affrete (RG-4.03).'); $this->comment('carrier', 'container_type', 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_container_type) — renseigne si affrete (RG-4.03).'); $this->comment('carrier', 'volume_m3', 'Volume en m3 (NUMERIC 10,2) — renseigne si affrete (RG-4.03).'); $this->comment('carrier', 'discharge_document_id', 'Document de Decharge (visible si certification_type = AUTRE, RG-4.02). FK -> uploaded_document.id (infra Shared § 2.7), ON DELETE SET NULL.'); $this->comment('carrier', 'liot_plates', 'Immatriculations LIOT separees par « ; » (cas special nom=LIOT, RG-4.01). Les autres champs sont masques dans ce cas.'); $this->comment('carrier', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission transport.carriers.archive (Admin seul).'); $this->comment('carrier', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.'); $this->comment('carrier', 'deleted_at', 'Horodatage du soft-delete technique — non expose par l API au M4. Null = ligne active.'); $this->addTimestampableBlamableComments('carrier'); } // ================================================================= // Sous-collection : adresses (1:n) // ================================================================= private function createCarrierAddress(): void { $this->addSql(<<<'SQL' CREATE TABLE carrier_address ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, carrier_id INT NOT NULL, country VARCHAR(80) DEFAULT 'France' NOT NULL, postal_code VARCHAR(20) DEFAULT NULL, city VARCHAR(120) DEFAULT NULL, street VARCHAR(255) DEFAULT 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_carrier_address_carrier FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE, CONSTRAINT fk_carrier_address_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_carrier_address_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); $this->addSql('CREATE INDEX idx_carrier_address_carrier ON carrier_address (carrier_id)'); $this->addSql('CREATE INDEX idx_carrier_address_created_by ON carrier_address (created_by)'); $this->addSql('CREATE INDEX idx_carrier_address_updated_by ON carrier_address (updated_by)'); $this->comment('carrier_address', '_table', 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).'); $this->comment('carrier_address', 'id', 'Identifiant interne auto-incremente.'); $this->comment('carrier_address', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.'); $this->comment('carrier_address', 'country', 'Pays de l adresse — defaut France.'); $this->comment('carrier_address', 'postal_code', 'Code postal (saisie assistee BAN cote front, RG-4.06).'); $this->comment('carrier_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.'); $this->comment('carrier_address', 'street', 'Numero et voie de l adresse.'); $this->comment('carrier_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.'); $this->comment('carrier_address', 'position', 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).'); $this->addTimestampableBlamableComments('carrier_address'); } // ================================================================= // Sous-collection : contacts (1:n) // ================================================================= private function createCarrierContact(): void { $this->addSql(<<<'SQL' CREATE TABLE carrier_contact ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, carrier_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_carrier_contact_filled CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL), CONSTRAINT fk_carrier_contact_carrier FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE, CONSTRAINT fk_carrier_contact_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_carrier_contact_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); $this->addSql('CREATE INDEX idx_carrier_contact_carrier ON carrier_contact (carrier_id)'); $this->addSql('CREATE INDEX idx_carrier_contact_created_by ON carrier_contact (created_by)'); $this->addSql('CREATE INDEX idx_carrier_contact_updated_by ON carrier_contact (updated_by)'); $this->comment('carrier_contact', '_table', 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.'); $this->comment('carrier_contact', 'id', 'Identifiant interne auto-incremente.'); $this->comment('carrier_contact', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.'); $this->comment('carrier_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).'); $this->comment('carrier_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).'); $this->comment('carrier_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).'); $this->comment('carrier_contact', 'phone_primary', 'Telephone principal — chiffres uniquement (normalisation serveur).'); $this->comment('carrier_contact', 'phone_secondary', 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).'); $this->comment('carrier_contact', 'email', 'Email du contact (lowercase serveur).'); $this->comment('carrier_contact', 'position', 'Ordre d affichage du contact dans la liste du transporteur (croissant).'); $this->addTimestampableBlamableComments('carrier_contact'); } // ================================================================= // Sous-collection : prix (1:n) — onglet Prix (RG-4.09 -> RG-4.11) // ================================================================= private function createCarrierPrice(): void { $this->addSql(<<<'SQL' CREATE TABLE carrier_price ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, carrier_id INT NOT NULL, direction VARCHAR(12) NOT NULL, client_id INT DEFAULT NULL, client_delivery_address_id INT DEFAULT NULL, departure_site_id INT DEFAULT NULL, supplier_id INT DEFAULT NULL, supplier_supply_address_id INT DEFAULT NULL, delivery_site_id INT DEFAULT NULL, container_type VARCHAR(12) NOT NULL, pricing_unit VARCHAR(8) NOT NULL, price NUMERIC(12, 2) NOT NULL, price_state VARCHAR(12) 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 chk_carrier_price_direction CHECK (direction IN ('CLIENT', 'FOURNISSEUR')), CONSTRAINT chk_carrier_price_container CHECK (container_type IN ('BENNE', 'FOND_MOUVANT')), CONSTRAINT chk_carrier_price_unit CHECK (pricing_unit IN ('FORFAIT', 'TONNE')), CONSTRAINT chk_carrier_price_state CHECK (price_state IN ('EN_COURS', 'VALIDE', 'NON_VALIDE')), CONSTRAINT chk_carrier_price_client_branch CHECK (direction <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL)), CONSTRAINT chk_carrier_price_supplier_branch CHECK (direction <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL)), CONSTRAINT fk_carrier_price_carrier FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE, CONSTRAINT fk_carrier_price_client FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE RESTRICT, CONSTRAINT fk_carrier_price_client_address FOREIGN KEY (client_delivery_address_id) REFERENCES client_address (id) ON DELETE RESTRICT, CONSTRAINT fk_carrier_price_departure_site FOREIGN KEY (departure_site_id) REFERENCES site (id) ON DELETE RESTRICT, CONSTRAINT fk_carrier_price_supplier FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE RESTRICT, CONSTRAINT fk_carrier_price_supplier_address FOREIGN KEY (supplier_supply_address_id) REFERENCES supplier_address (id) ON DELETE RESTRICT, CONSTRAINT fk_carrier_price_delivery_site FOREIGN KEY (delivery_site_id) REFERENCES site (id) ON DELETE RESTRICT, CONSTRAINT fk_carrier_price_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_carrier_price_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); $this->addSql('CREATE INDEX idx_carrier_price_carrier ON carrier_price (carrier_id)'); $this->addSql('CREATE INDEX idx_carrier_price_client ON carrier_price (client_id)'); $this->addSql('CREATE INDEX idx_carrier_price_client_address ON carrier_price (client_delivery_address_id)'); $this->addSql('CREATE INDEX idx_carrier_price_departure_site ON carrier_price (departure_site_id)'); $this->addSql('CREATE INDEX idx_carrier_price_supplier ON carrier_price (supplier_id)'); $this->addSql('CREATE INDEX idx_carrier_price_supplier_address ON carrier_price (supplier_supply_address_id)'); $this->addSql('CREATE INDEX idx_carrier_price_delivery_site ON carrier_price (delivery_site_id)'); $this->addSql('CREATE INDEX idx_carrier_price_created_by ON carrier_price (created_by)'); $this->addSql('CREATE INDEX idx_carrier_price_updated_by ON carrier_price (updated_by)'); $this->comment('carrier_price', '_table', 'Prix d un transporteur (1:n) — onglet Prix (M4). Branche CLIENT ou FOURNISSEUR selon direction (RG-4.09→4.11, CHECK chk_carrier_price_*).'); $this->comment('carrier_price', 'id', 'Identifiant interne auto-incremente.'); $this->comment('carrier_price', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du prix.'); $this->comment('carrier_price', 'direction', 'Sens du prix : CLIENT ou FOURNISSEUR (RG-4.09). Pilote l affichage et l obligation des colonnes client_*/supplier_* (RG-4.10/4.11).'); $this->comment('carrier_price', 'client_id', 'Branche CLIENT (RG-4.10) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi direction = CLIENT.'); $this->comment('carrier_price', 'client_delivery_address_id', 'Branche CLIENT : adresse de livraison du client. FK -> client_address.id, ON DELETE RESTRICT.'); $this->comment('carrier_price', 'departure_site_id', 'Branche CLIENT : adresse de depart = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.'); $this->comment('carrier_price', 'supplier_id', 'Branche FOURNISSEUR (RG-4.11) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi direction = FOURNISSEUR.'); $this->comment('carrier_price', 'supplier_supply_address_id', 'Branche FOURNISSEUR : adresse d approvisionnement du fournisseur. FK -> supplier_address.id, ON DELETE RESTRICT.'); $this->comment('carrier_price', 'delivery_site_id', 'Branche FOURNISSEUR : adresse de livraison = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.'); $this->comment('carrier_price', 'container_type', 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_price_container).'); $this->comment('carrier_price', 'pricing_unit', 'Unite de tarification FORFAIT|TONNE (chk_carrier_price_unit).'); $this->comment('carrier_price', 'price', 'Montant du prix (NUMERIC 12,2).'); $this->comment('carrier_price', 'price_state', 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.'); $this->comment('carrier_price', 'position', 'Ordre d affichage du prix dans la liste du transporteur (croissant).'); $this->addTimestampableBlamableComments('carrier_price'); } // ================================================================= // Helpers (identiques au M2 Version20260605130000) // ================================================================= /** * 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, )); } }