Files
Starseed/migrations/Version20260611140000.php
T
Matthieu 0052eab1fe
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 29m14s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 11s
feat(field_sales) : entités et API Tournée + Étape (Tour/TourStop) (ERP-124)
Modèle et API CRUD du module Tournées (M6.3, scope réduit V0.2 : pas de
rapport de visite, donc TourStop sans report_id ni check-in).

- Entités Tour (table tour) + TourStop (table tour_stop) : #[Auditable],
  Timestampable/Blamable, enum TourStatus (draft|planned|in_progress|done),
  soft delete sur Tour.
- API Platform : GET/POST/GET/PATCH/DELETE /api/tours (DELETE = soft delete),
  sous-ressource POST /api/tours/{tourId}/stops + PATCH/DELETE /api/tour_stops/{id}.
- RG-6.01 : tournée personnelle (TourProvider filtre owner ; admin/Bureau
  voient tout). RG-6.03 : adresse appartient au Tiers (TourStopProcessor +
  TierAddressResolver DBAL, sans import inter-module). RG-6.07 : pas d'unicité
  tier_id. RG-6.12 : cohérence custom/Tiers (Assert\Callback).
- Migration racine : tables + COMMENT ON COLUMN FR + index unique
  (tour_id, position) + FK CASCADE ; mirror dans ColumnCommentsCatalog.
- i18n audit (fieldsales_tour / _tourstop), mappings Doctrine + API Platform.
- Tests fonctionnels : owner, RG-6.03/6.07/6.12, pagination, unicité position,
  soft delete, RBAC (17 tests).

Co-Authored-By: Matthieu <mtholot19@gmail.com>
2026-06-11 15:54:10 +02:00

219 lines
12 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.3 (ERP-124) — Tournees commerciales terrain : creation des tables `tour`
* (tournee) et `tour_stop` (etape) du module FieldSales.
*
* SCOPE REDUIT (V0.2) : pas de rapport de visite -> `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,
));
}
}