`tour_stop` SANS report_id ni * arrived_at / check-in. * * Particularites de modelisation : * - tour.owner_id : FK -> "user".id, ON DELETE RESTRICT (tournee personnelle, * RG-6.01 ; un user proprietaire d'une tournee ne peut etre supprime). * - tour_stop.tier_id / address_id : entiers SANS FK. La cible d'une etape est * polymorphe (Client M1 / Fournisseur M2 / point custom) resolue via * tier_type ; aucune FK unique possible (RG-6.07 : pas d'unicite sur tier_id). * - Unicite (tour_id, position) : un seul ordre par tournee (uq_tour_stop_position). * - tour_stop.tour_id : FK -> tour.id, ON DELETE CASCADE. * * Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non * modulaire : la migration cree des FK cross-module (vers "user"). Avec plusieurs * migrations_paths, Doctrine Migrations 3.x trie par FQCN alphabetique — un * namespace modulaire s'executerait avant la creation de "user" sur base vide. * Le namespace racine garantit l'ordre par timestamp. * * Style DDL aligne sur M1/M2 (INT GENERATED BY DEFAULT AS IDENTITY, * TIMESTAMP(0) WITHOUT TIME ZONE car le trait T/B mappe datetime_immutable), * pour que `schema:update` reste un no-op. Chaque colonne porte son * `COMMENT ON COLUMN` (regle ABSOLUE n°12) ; les 4 colonnes T/B via le catalogue * partage. Les tables sont egalement mirorees dans ColumnCommentsCatalog pour * que `app:apply-column-comments` rejoue les COMMENT apres le schema:update du * setup de test (qui les drope sur les tables mappees par l'ORM). */ final class Version20260611140000 extends AbstractMigration { public function getDescription(): string { return 'ERP-124 (M6.3) : tables tour + tour_stop (module FieldSales), sans rapport de visite (scope reduit V0.2).'; } public function up(Schema $schema): void { $this->createTourTable(); $this->createTourStopTable(); } public function down(Schema $schema): void { // Ordre inverse des dependances FK : tour_stop (FK -> tour) puis tour. $this->addSql('DROP TABLE IF EXISTS tour_stop'); $this->addSql('DROP TABLE IF EXISTS tour'); } // ================================================================= // Table `tour` // ================================================================= private function createTourTable(): void { $this->addSql(<<<'SQL' CREATE TABLE tour ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, owner_id INT NOT NULL, label VARCHAR(120) NOT NULL, tour_date DATE NOT NULL, departure_time TIME(0) WITHOUT TIME ZONE NOT NULL, start_latitude NUMERIC(10, 7) DEFAULT NULL, start_longitude NUMERIC(10, 7) DEFAULT NULL, start_label VARCHAR(180) DEFAULT NULL, default_visit_minutes SMALLINT DEFAULT 30 NOT NULL, status VARCHAR(20) DEFAULT 'draft' NOT NULL, total_distance_m INT DEFAULT NULL, total_duration_s INT 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_tour_owner FOREIGN KEY (owner_id) REFERENCES "user" (id) ON DELETE RESTRICT, CONSTRAINT fk_tour_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_tour_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); $this->addSql('CREATE INDEX idx_tour_owner ON tour (owner_id)'); $this->addSql('CREATE INDEX idx_tour_status ON tour (status)'); $this->addSql('CREATE INDEX idx_tour_deleted_at ON tour (deleted_at)'); $this->addSql('CREATE INDEX idx_tour_created_by ON tour (created_by)'); $this->addSql('CREATE INDEX idx_tour_updated_by ON tour (updated_by)'); $this->comment('tour', '_table', 'Tournees commerciales terrain (M6 FieldSales) — personnelles (owner), soft-deletables (deleted_at).'); $this->comment('tour', 'id', 'Identifiant interne auto-incremente.'); $this->comment('tour', 'owner_id', 'Commercial proprietaire de la tournee (RG-6.01, personnelle) — FK -> "user".id, ON DELETE RESTRICT. Pose au POST par le TourProcessor.'); $this->comment('tour', 'label', 'Nom libre de la tournee (NotBlank, <= 120 caracteres).'); $this->comment('tour', 'tour_date', 'Date de realisation de la tournee (NotNull).'); $this->comment('tour', 'departure_time', 'Heure de depart, alimente les ETA (RG-6.11). Defaut applicatif 08:00 (constructeur).'); $this->comment('tour', 'start_latitude', 'Latitude WGS84 du point de depart (site commercial ou adresse libre). NULL -> depart = 1re etape.'); $this->comment('tour', 'start_longitude', 'Longitude WGS84 du point de depart. NULL -> depart = 1re etape.'); $this->comment('tour', 'start_label', 'Libelle affichable du point de depart (<= 180 caracteres). Optionnel.'); $this->comment('tour', 'default_visit_minutes', 'Duree de visite par defaut d une etape, en minutes (defaut 30) — utilisee si l etape ne fixe pas sa propre duree.'); $this->comment('tour', 'status', 'Cycle de vie (RG-6.02) : draft | planned | in_progress | done (enum TourStatus). Transitions libres en V1. Defaut draft.'); $this->comment('tour', 'total_distance_m', 'Cache d affichage : derniere distance totale calculee, en metres (RG-6.11). Lecture seule API, alimente par le moteur de trajet (M6.4).'); $this->comment('tour', 'total_duration_s', 'Cache d affichage : derniere duree totale calculee, en secondes (RG-6.11). Lecture seule API.'); $this->comment('tour', 'deleted_at', 'Horodatage du soft-delete — pose par le DELETE API. Null = tournee active.'); $this->addTimestampableBlamableComments('tour'); } // ================================================================= // Table `tour_stop` // ================================================================= private function createTourStopTable(): void { $this->addSql(<<<'SQL' CREATE TABLE tour_stop ( id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, tour_id INT NOT NULL, tier_type VARCHAR(30) NOT NULL, tier_id INT DEFAULT NULL, address_id INT DEFAULT NULL, custom_label VARCHAR(180) DEFAULT NULL, custom_address VARCHAR(255) DEFAULT NULL, custom_latitude NUMERIC(10, 7) DEFAULT NULL, custom_longitude NUMERIC(10, 7) DEFAULT NULL, position SMALLINT NOT NULL, visit_minutes SMALLINT DEFAULT NULL, leg_distance_m INT DEFAULT NULL, leg_duration_s INT DEFAULT NULL, eta TIME(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_tour_stop_tour FOREIGN KEY (tour_id) REFERENCES tour (id) ON DELETE CASCADE, CONSTRAINT fk_tour_stop_created_by FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, CONSTRAINT fk_tour_stop_updated_by FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL ) SQL); $this->addSql('CREATE INDEX idx_tour_stop_tour ON tour_stop (tour_id)'); $this->addSql('CREATE INDEX idx_tour_stop_created_by ON tour_stop (created_by)'); $this->addSql('CREATE INDEX idx_tour_stop_updated_by ON tour_stop (updated_by)'); // RG-6.07 : pas d unicite sur tier_id (deux etapes peuvent viser le meme // Tiers). Unicite uniquement sur l ordre dans la tournee. $this->addSql('CREATE UNIQUE INDEX uq_tour_stop_position ON tour_stop (tour_id, position)'); $this->comment('tour_stop', '_table', 'Etapes ordonnees d une tournee (M6) — cible polymorphe (Tiers referentiel ou point custom). Pas de rapport (scope reduit V0.2).'); $this->comment('tour_stop', 'id', 'Identifiant interne auto-incremente.'); $this->comment('tour_stop', 'tour_id', 'FK -> tour.id, ON DELETE CASCADE — tournee proprietaire de l etape.'); $this->comment('tour_stop', 'tier_type', 'Type de cible : client | supplier | ... | custom (point libre). Resolu via VisitableInterface. Chaine ouverte (Assert\\Choice).'); $this->comment('tour_stop', 'tier_id', 'Identifiant du Tiers referentiel cible (NULL si custom). Sans FK (cible polymorphe). RG-6.07 : aucune unicite.'); $this->comment('tour_stop', 'address_id', 'Adresse precise visitee chez le Tiers (NULL si custom). Sans FK (client_address OU supplier_address). RG-6.03 : doit appartenir au Tiers.'); $this->comment('tour_stop', 'custom_label', 'Libelle du point libre — obligatoire ssi tier_type = custom (RG-6.12), sinon NULL.'); $this->comment('tour_stop', 'custom_address', 'Adresse texte du point libre (geocodee) — renseignee uniquement si custom.'); $this->comment('tour_stop', 'custom_latitude', 'Latitude WGS84 du point libre (pin ajustable) — obligatoire ssi custom (RG-6.12).'); $this->comment('tour_stop', 'custom_longitude', 'Longitude WGS84 du point libre — obligatoire ssi custom (RG-6.12).'); $this->comment('tour_stop', 'position', 'Ordre de l etape dans la tournee (drag & drop). Unique par tournee (uq_tour_stop_position).'); $this->comment('tour_stop', 'visit_minutes', 'Duree de visite specifique a l etape, en minutes — sinon tour.default_visit_minutes.'); $this->comment('tour_stop', 'leg_distance_m', 'Cache : distance depuis l etape precedente, en metres (calcule). Lecture seule API (M6.4).'); $this->comment('tour_stop', 'leg_duration_s', 'Cache : temps depuis l etape precedente, en secondes (calcule). Lecture seule API (M6.4).'); $this->comment('tour_stop', 'eta', 'Heure d arrivee estimee a l etape (RG-6.11, calculee). Lecture seule API.'); $this->addTimestampableBlamableComments('tour_stop'); } // ================================================================= // Helpers // ================================================================= /** * Pose les 4 commentaires standardises Timestampable/Blamable sur une table, * en reutilisant le catalogue partage (source unique). */ 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, )); } }