> */ public static function comments(): array { return [ 'audit_log' => [ '_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.", 'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).", 'entity_type' => "Type d'entite auditee au format module.Entity (ex: core.User, commercial.Client) — evite les collisions inter-modules.", 'entity_id' => "Identifiant de l'entite auditee (supporte INT et UUID — stocke en varchar pour rester generique).", 'action' => "Type d'operation auditee : 'create', 'update' ou 'delete'.", 'changes' => 'Snapshot complet pour create/delete, diff {champ: {old, new}} pour update. Cles sensibles filtrees (password, token, secret).', 'performed_by' => "Username de l'auteur de l'action (denormalise, survit a la suppression du user) — vaut 'system' en CLI.", 'performed_at' => "Horodatage UTC de l'action auditee.", 'ip_address' => "Adresse IP de l'auteur (IPv4/IPv6) — null hors contexte HTTP.", 'request_id' => "UUID v4 de la requete HTTP — regroupe les changements d'un meme flush, facilite la correlation logs.", ], 'category' => [ '_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.', 'id' => 'Identifiant interne auto-incremente.', 'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).', 'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).', 'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.', ] + self::timestampableBlamableComments(), 'category_type' => [ '_table' => 'Referentiel statique des types de categories — code technique stable + libelle FR.', 'id' => 'Identifiant interne auto-incremente.', 'code' => 'Code technique stable du type (snake_case, ≤ 40 caracteres) — unique, utilise dans le code et les configurations.', 'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).', ], 'permission' => [ '_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.', 'id' => 'Identifiant interne auto-incremente.', 'code' => 'Code RBAC au format module.resource[.subresource].action — unique, synchronise par app:sync-permissions.', 'label' => 'Libelle affichable de la permission (FR).', 'module' => 'Identifiant du module proprietaire de la permission (snake_case, ex: core, commercial).', 'orphan' => "Drapeau permission orpheline — vrai quand son module declarant a ete supprime, masquee de l'interface RBAC.", ], 'role' => [ '_table' => 'Referentiel des roles RBAC — agregent un ensemble de permissions, attribues aux utilisateurs.', 'id' => 'Identifiant interne auto-incremente.', 'code' => 'Code technique stable du role (snake_case) — utilise dans le code (ex: admin, user). Unique.', 'label' => 'Libelle affichable du role (FR).', 'description' => 'Description longue du role (optionnelle).', 'is_system' => "Drapeau role systeme — bloque la suppression et la modification du code via l'interface.", ], 'role_permission' => [ '_table' => 'Table de jointure roles <-> permissions (ManyToMany).', 'role_id' => 'FK -> role.id, ON DELETE CASCADE — role qui porte la permission.', 'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission attribuee au role.', ], 'site' => [ '_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.', 'id' => 'Identifiant interne auto-incremente.', 'name' => 'Nom du site (≤ 100 caracteres).', 'city' => 'Ville du site (≤ 100 caracteres).', 'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.', 'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.", 'street' => "Numero et voie de l'adresse (≤ 200 caracteres).", 'complement' => "Complement d'adresse (etage, batiment...) — optionnel.", 'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.', 'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.', ], 'user' => [ '_table' => 'Comptes utilisateurs Starseed — authentification JWT, RBAC via roles et permissions directes.', 'id' => 'Identifiant interne auto-incremente.', 'username' => 'Identifiant de connexion (≤ 100 caracteres) — unique.', 'password' => 'Hash du mot de passe (algorithme courant Symfony) — exclu de l audit via #[AuditIgnore].', 'created_at' => 'Horodatage UTC de creation du compte — rempli manuellement dans le constructeur (pas via TimestampableBlamableSubscriber).', 'is_admin' => 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.', 'current_site_id' => "Site actuellement selectionne par l'utilisateur (contexte de session) — FK -> site.id, ON DELETE SET NULL.", ], 'user_permission' => [ '_table' => 'Table de jointure utilisateurs <-> permissions directes (hors role).', 'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur destinataire de la permission directe.', 'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission accordee individuellement.', ], 'user_role' => [ '_table' => 'Table de jointure utilisateurs <-> roles (ManyToMany).', 'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur portant le role.', 'role_id' => 'FK -> role.id, ON DELETE CASCADE — role attribue a l utilisateur.', ], 'user_site' => [ '_table' => 'Table de jointure utilisateurs <-> sites accessibles — gere le scoping multi-site (un user ne voit que les donnees de ses sites).', 'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.', 'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.', ], // === M1 Commercial (ERP-53/54) — miroir des COMMENT de la migration // Version20260601000000 pour le chemin schema:update (dev/test). === 'tva_mode' => [ '_table' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).', 'id' => 'Identifiant interne auto-incremente.', 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', ], 'payment_delay' => [ '_table' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).', 'id' => 'Identifiant interne auto-incremente.', 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', ], 'payment_type' => [ '_table' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).', 'id' => 'Identifiant interne auto-incremente.', 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', ], 'bank' => [ '_table' => 'Referentiel des banques selectionnables pour le reglement par virement.', 'id' => 'Identifiant interne auto-incremente.', 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', ], 'client' => [ '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', 'id' => 'Identifiant interne auto-incremente.', 'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).', 'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', 'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', 'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.', 'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).', 'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).', 'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.', 'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.', 'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.', 'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.', 'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).', 'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).', 'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).', 'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).', 'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).', 'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).', 'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).', 'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.', 'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.', 'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.', 'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.', 'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).', 'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).', 'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).', 'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).', 'deleted_at' => 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.', ] + self::timestampableBlamableComments(), 'client_category' => [ '_table' => 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).', 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.', 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.', ], 'client_contact' => [ '_table' => 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).', 'id' => 'Identifiant interne auto-incremente.', 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.', 'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).', 'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).', 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).', 'phone_primary' => 'Telephone principal du contact — chiffres uniquement (RG-1.20).', 'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).', 'email' => 'Email du contact (lowercase serveur, RG-1.21).', 'position' => 'Ordre d affichage du contact dans la liste du client (croissant).', ] + self::timestampableBlamableComments(), 'client_address' => [ '_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).', 'id' => 'Identifiant interne auto-incremente.', 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.', 'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.', 'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.', 'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.', 'country' => 'Pays de l adresse — defaut France.', 'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).', 'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).', 'street' => 'Numero et voie de l adresse.', 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', 'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).', 'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).', ] + self::timestampableBlamableComments(), 'client_address_site' => [ '_table' => 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).', 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', 'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.', ], 'client_address_contact' => [ '_table' => 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.', 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', 'client_contact_id' => 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.', ], 'client_address_category' => [ '_table' => 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).', 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).', ], 'client_rib' => [ '_table' => 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).', 'id' => 'Identifiant interne auto-incremente.', 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.', 'label' => 'Libelle du RIB (ex: compte principal).', 'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).', 'iban' => 'IBAN du compte (≤ 34 caracteres).', 'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).', ] + self::timestampableBlamableComments(), ]; } /** * Descriptions standardisees pour les 4 colonnes du pattern * Timestampable/Blamable (`TimestampableBlamableTrait`). * * @return array */ public static function timestampableBlamableComments(): array { return [ 'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.', 'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.', 'created_by' => "ID de l'utilisateur ayant cree la ligne — null hors HTTP (CLI, migration, fixture). FK -> \"user\".id, ON DELETE SET NULL.", 'updated_by' => "ID de l'utilisateur ayant modifie la ligne en dernier — null hors HTTP. FK -> \"user\".id, ON DELETE SET NULL.", ]; } /** * Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en * dollar-quoting Postgres `$_$`) a partir du catalogue. * * @param null|list $onlyTables Restreint la generation a ces tables * (utile pour la migration retrofit qui * ne doit commenter que les tables deja * presentes a son instant T — les tables * des modules crees plus tard posent * leurs propres COMMENT). null = tout. * * @return list */ public static function toSqlStatements(?array $onlyTables = null): array { $allowed = null === $onlyTables ? null : array_fill_keys($onlyTables, true); $statements = []; foreach (self::comments() as $table => $entries) { if (null !== $allowed && !isset($allowed[$table])) { continue; } $quotedTable = self::quoteIdent($table); foreach ($entries as $column => $description) { if ('_table' === $column) { $statements[] = sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description); continue; } $statements[] = sprintf( 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', $quotedTable, self::quoteIdent($column), $description, ); } } return $statements; } /** * Quote un identifiant SQL avec des guillemets doubles. Necessaire pour * la table `user` (mot reserve PG) ; applique a tous par coherence. */ private static function quoteIdent(string $name): string { return '"'.str_replace('"', '""', $name).'"'; } }