0052eab1fe
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>
219 lines
12 KiB
PHP
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,
|
|
));
|
|
}
|
|
}
|