feat(field_sales) : entités et API Tournée + Étape (Tour/TourStop) (ERP-124)
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

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>
This commit is contained in:
Matthieu
2026-06-11 15:54:10 +02:00
parent be9204eca7
commit 0052eab1fe
19 changed files with 2041 additions and 1 deletions
+2
View File
@@ -12,6 +12,8 @@ api_platform:
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
# en dehors de Domain/Entity : AuditLogResource, etc.
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
# Module FieldSales (M6) : entites ApiResource Tour / TourStop.
- '%kernel.project_dir%/src/Module/FieldSales/Domain/Entity'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
+10
View File
@@ -87,6 +87,16 @@ doctrine:
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
prefix: 'App\Module\Commercial\Domain\Entity'
alias: Commercial
# Mapping inconditionnel du module FieldSales (M6 — meme logique que
# Commercial) : les tables tour / tour_stop creees par la migration
# M6.3 (Version20260611140000) doivent etre connues de l'ORM.
# L'activation fonctionnelle passe par config/modules.php.
FieldSales:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/FieldSales/Domain/Entity'
prefix: 'App\Module\FieldSales\Domain\Entity'
alias: FieldSales
controller_resolver:
auto_mapping: false
+3 -1
View File
@@ -425,7 +425,9 @@
"commercial_supplier": "Fournisseur",
"commercial_supplieraddress": "Adresse fournisseur",
"commercial_suppliercontact": "Contact fournisseur",
"commercial_supplierrib": "RIB fournisseur"
"commercial_supplierrib": "RIB fournisseur",
"fieldsales_tour": "Tournée",
"fieldsales_tourstop": "Étape de tournée"
},
"empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres",
+218
View File
@@ -0,0 +1,218 @@
<?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,
));
}
}
@@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\FieldSales\Domain\Enum\TourStatus;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider\TourProvider;
use App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Tournee commerciale terrain (M6 § 4.2). Entite racine du module FieldSales :
* porte le point de depart, l'heure de depart, la duree de visite par defaut,
* le statut (cycle de vie RG-6.02) et la liste ordonnee d'etapes (TourStop).
*
* Decisions structurantes :
* - Tournee PERSONNELLE (RG-6.01) : `owner` = commercial proprietaire, pose par
* le TourProcessor au POST (jamais ecrit par le client). Le TourProvider filtre
* la collection sur l'owner courant (admin / Bureau voient tout).
* - owner reference l'utilisateur via UserInterface + resolve_target_entities
* (-> User du module Core), comme le createdBy du trait Blamable : aucun import
* direct du module Core (regle ABSOLUE n°1).
* - status : enum PHP TourStatus stocke en chaine (Assert\Choice sur les valeurs
* de l'enum -> 422 FR si valeur invalide). Defaut Draft a la creation.
* - Soft delete (`deletedAt`) : le DELETE API pose deletedAt (TourProcessor),
* le TourProvider exclut les tournees supprimees.
* - total_distance_m / total_duration_s : cache d'affichage des derniers totaux
* calcules (RG-6.11, lecture seule cote API ; alimente par le moteur de trajet
* au ticket M6.4).
*
* Audite (#[Auditable]) + Timestampable/Blamable.
*
* @phpstan-ignore-next-line owner est resolu en User (getId()) via resolve_target_entities
*/
#[ApiResource(
shortName: 'Tour',
operations: [
new GetCollection(
security: "is_granted('field_sales.tours.view')",
normalizationContext: ['groups' => ['tour:read', 'default:read']],
provider: TourProvider::class,
),
new Get(
security: "is_granted('field_sales.tours.view')",
// Detail : la tournee + ses etapes embarquees (tour:item:read porte
// getStops(), tour_stop:read le contenu de chaque etape).
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
provider: TourProvider::class,
),
new Post(
security: "is_granted('field_sales.tours.manage')",
normalizationContext: ['groups' => ['tour:read', 'default:read']],
denormalizationContext: ['groups' => ['tour:write']],
processor: TourProcessor::class,
),
new Patch(
security: "is_granted('field_sales.tours.manage')",
normalizationContext: ['groups' => ['tour:read', 'default:read']],
denormalizationContext: ['groups' => ['tour:write']],
provider: TourProvider::class,
processor: TourProcessor::class,
),
new Delete(
// DELETE = soft delete (pose deletedAt) — cf. TourProcessor.
security: "is_granted('field_sales.tours.manage')",
provider: TourProvider::class,
processor: TourProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineTourRepository::class)]
#[ORM\Table(name: 'tour')]
#[ORM\Index(name: 'idx_tour_owner', columns: ['owner_id'])]
#[ORM\Index(name: 'idx_tour_status', columns: ['status'])]
#[ORM\Index(name: 'idx_tour_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_tour_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_tour_updated_by', columns: ['updated_by'])]
#[Auditable]
class Tour implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['tour:read'])]
private ?int $id = null;
// Commercial proprietaire (RG-6.01). Pose par le TourProcessor au POST, donc
// PAS de groupe d'ecriture et PAS d'Assert\NotNull (la validation s'execute
// avant le processor) — la colonne NOT NULL en base est le garde-fou final.
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'owner_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Groups(['tour:read'])]
private ?UserInterface $owner = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le nom de la tournée est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'Le nom de la tournée ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['tour:read', 'tour:write'])]
private ?string $label = null;
#[ORM\Column(name: 'tour_date', type: 'date_immutable')]
#[Assert\NotNull(message: 'La date de la tournée est obligatoire.')]
#[Groups(['tour:read', 'tour:write'])]
private ?DateTimeImmutable $tourDate = null;
// Heure de depart (alimente les ETA, RG-6.11). Defaut 08:00 (pose dans le
// constructeur). Colonne TIME -> DateTimeImmutable (partie date 1970 ignoree).
#[ORM\Column(name: 'departure_time', type: 'time_immutable')]
#[Groups(['tour:read', 'tour:write'])]
private DateTimeImmutable $departureTime;
// Point de depart (site commercial ou adresse libre). NULL -> depart = 1re etape.
#[ORM\Column(name: 'start_latitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La latitude doit être comprise entre {{ min }} et {{ max }}.', min: -90, max: 90)]
#[Groups(['tour:read', 'tour:write'])]
private ?string $startLatitude = null;
#[ORM\Column(name: 'start_longitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La longitude doit être comprise entre {{ min }} et {{ max }}.', min: -180, max: 180)]
#[Groups(['tour:read', 'tour:write'])]
private ?string $startLongitude = null;
#[ORM\Column(name: 'start_label', length: 180, nullable: true)]
#[Assert\Length(max: 180, maxMessage: 'Le libellé du point de départ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['tour:read', 'tour:write'])]
private ?string $startLabel = null;
#[ORM\Column(name: 'default_visit_minutes', type: 'smallint', options: ['default' => 30])]
#[Assert\Positive(message: 'La durée de visite par défaut doit être un nombre positif.')]
#[Groups(['tour:read', 'tour:write'])]
private int $defaultVisitMinutes = 30;
// Statut stocke en chaine ; valeurs bornees a l'enum TourStatus (RG-6.02).
#[ORM\Column(length: 20, options: ['default' => TourStatus::Draft->value])]
#[Assert\Choice(callback: [TourStatus::class, 'values'], message: 'Le statut de la tournée est invalide.')]
#[Groups(['tour:read', 'tour:write'])]
private string $status = TourStatus::Draft->value;
// Derniers totaux calcules (cache d'affichage, RG-6.11). Lecture seule cote
// API : alimentes par le moteur de trajet (M6.4), jamais ecrits par le client.
#[ORM\Column(name: 'total_distance_m', nullable: true)]
#[Groups(['tour:read'])]
private ?int $totalDistanceM = null;
#[ORM\Column(name: 'total_duration_s', nullable: true)]
#[Groups(['tour:read'])]
private ?int $totalDurationS = null;
/** @var Collection<int, TourStop> */
#[ORM\OneToMany(mappedBy: 'tour', targetEntity: TourStop::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $stops;
// Soft delete : pose par le TourProcessor sur DELETE, jamais expose en
// ecriture. Le TourProvider exclut les tournees dont deletedAt n'est pas null.
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->stops = new ArrayCollection();
$this->departureTime = new DateTimeImmutable('1970-01-01 08:00:00');
}
public function getId(): ?int
{
return $this->id;
}
public function getOwner(): ?UserInterface
{
return $this->owner;
}
public function setOwner(?UserInterface $owner): static
{
$this->owner = $owner;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = $label;
return $this;
}
public function getTourDate(): ?DateTimeImmutable
{
return $this->tourDate;
}
public function setTourDate(?DateTimeImmutable $tourDate): static
{
$this->tourDate = $tourDate;
return $this;
}
public function getDepartureTime(): DateTimeImmutable
{
return $this->departureTime;
}
public function setDepartureTime(DateTimeImmutable $departureTime): static
{
$this->departureTime = $departureTime;
return $this;
}
public function getStartLatitude(): ?string
{
return $this->startLatitude;
}
public function setStartLatitude(float|string|null $startLatitude): static
{
$this->startLatitude = null === $startLatitude ? null : (string) $startLatitude;
return $this;
}
public function getStartLongitude(): ?string
{
return $this->startLongitude;
}
public function setStartLongitude(float|string|null $startLongitude): static
{
$this->startLongitude = null === $startLongitude ? null : (string) $startLongitude;
return $this;
}
public function getStartLabel(): ?string
{
return $this->startLabel;
}
public function setStartLabel(?string $startLabel): static
{
$this->startLabel = $startLabel;
return $this;
}
public function getDefaultVisitMinutes(): int
{
return $this->defaultVisitMinutes;
}
public function setDefaultVisitMinutes(int $defaultVisitMinutes): static
{
$this->defaultVisitMinutes = $defaultVisitMinutes;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getTotalDistanceM(): ?int
{
return $this->totalDistanceM;
}
public function setTotalDistanceM(?int $totalDistanceM): static
{
$this->totalDistanceM = $totalDistanceM;
return $this;
}
public function getTotalDurationS(): ?int
{
return $this->totalDurationS;
}
public function setTotalDurationS(?int $totalDurationS): static
{
$this->totalDurationS = $totalDurationS;
return $this;
}
/** @return Collection<int, TourStop> */
#[Groups(['tour:item:read'])]
public function getStops(): Collection
{
return $this->stops;
}
public function addStop(TourStop $stop): static
{
if (!$this->stops->contains($stop)) {
$this->stops->add($stop);
$stop->setTour($this);
}
return $this;
}
public function removeStop(TourStop $stop): static
{
if ($this->stops->removeElement($stop) && $stop->getTour() === $this) {
$stop->setTour(null);
}
return $this;
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
}
@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourStopProcessor;
use App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourStopRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Etape d'une tournee (M6 § 4.3). Une etape vise soit un Tiers du referentiel
* (Client M1, Fournisseur M2, futur Prestataire) resolu de facon polymorphe via
* le couple (`tierType`, `tierId`), soit un point libre `custom` (prospect / RDV
* sans fiche : libelle + adresse + coordonnees saisis a la main).
*
* Choix de modelisation (spec § 3.1.bis) :
* - PAS d'association Doctrine vers le Tiers ni vers l'adresse : la cible est
* polymorphe (client_address OU supplier_address selon tierType), donc tierId
* et addressId sont de simples entiers. La coherence « l'adresse appartient au
* Tiers » (RG-6.03) est verifiee cote TourStopProcessor (acces lecture seule au
* schema partage, sans import inter-module — regle ABSOLUE n°1).
* - tierType est une chaine OUVERTE (Assert\Choice = types Visitable connus +
* `custom`), extensible aux futurs Tiers sans toucher au module FieldSales.
*
* SCOPE REDUIT (V0.2) : pas de rapport de visite -> PAS de report_id, PAS de
* arrived_at / check-in.
*
* Audite (#[Auditable]) + Timestampable/Blamable. Unicite (tour_id, position).
*
* Sous-ressource API (spec § 5, pattern ClientAddress) :
* - POST /api/tours/{tourId}/stops : creation rattachee a la tournee parente
* (Link toProperty 'tour', read:false), security field_sales.tours.manage.
* - PATCH /api/tour_stops/{id} : edition (dont position = drag & drop).
* - DELETE /api/tour_stops/{id} : suppression d'une etape.
* - GET /api/tour_stops/{id} : lecture unitaire (security view). La lecture
* courante des etapes passe par le detail de la tournee parente.
*/
#[ApiResource(
shortName: 'TourStop',
operations: [
new Get(
security: "is_granted('field_sales.tours.view')",
normalizationContext: ['groups' => ['tour_stop:read']],
),
new Post(
uriTemplate: '/tours/{tourId}/stops',
uriVariables: [
'tourId' => new Link(fromClass: Tour::class, toProperty: 'tour'),
],
// read:false : comme ClientAddress, le Link toProperty resoudrait
// l'enfant (SELECT WHERE tour = :id) et casserait en NonUniqueResult
// des >= 2 etapes. La tournee parente est rattachee manuellement par
// TourStopProcessor::linkParent (404 si absente).
read: false,
security: "is_granted('field_sales.tours.manage')",
normalizationContext: ['groups' => ['tour_stop:read']],
denormalizationContext: ['groups' => ['tour_stop:write']],
processor: TourStopProcessor::class,
),
new Patch(
security: "is_granted('field_sales.tours.manage')",
normalizationContext: ['groups' => ['tour_stop:read']],
denormalizationContext: ['groups' => ['tour_stop:write']],
processor: TourStopProcessor::class,
),
new Delete(
security: "is_granted('field_sales.tours.manage')",
processor: TourStopProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineTourStopRepository::class)]
#[ORM\Table(name: 'tour_stop')]
#[ORM\UniqueConstraint(name: 'uq_tour_stop_position', columns: ['tour_id', 'position'])]
#[ORM\Index(name: 'idx_tour_stop_tour', columns: ['tour_id'])]
#[ORM\Index(name: 'idx_tour_stop_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_tour_stop_updated_by', columns: ['updated_by'])]
#[Auditable]
class TourStop implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
/** Point libre (prospect / RDV sans fiche Tiers) — cf. RG-6.12. */
public const string TIER_TYPE_CUSTOM = 'custom';
/**
* Valeurs autorisees de `tierType` : types Visitable connus du referentiel
* (client, supplier) + le point libre `custom`. Liste OUVERTE par nature
* (de simples chaines, aucun import de classe d'un autre module) : un futur
* Tiers (prestataire...) s'ajoute ici sans autre changement.
*/
public const array TIER_TYPES = ['client', 'supplier', self::TIER_TYPE_CUSTOM];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['tour_stop:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Tour::class, inversedBy: 'stops')]
#[ORM\JoinColumn(name: 'tour_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Tour $tour = null;
#[ORM\Column(name: 'tier_type', length: 30)]
#[Assert\NotBlank(message: 'Le type de cible de l\'étape est obligatoire.')]
#[Assert\Choice(choices: self::TIER_TYPES, message: 'Le type de cible de l\'étape est invalide.')]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?string $tierType = null;
// Identifiant du Tiers referentiel (NULL si custom). Pas de FK : cible
// polymorphe resolue via tierType (RG-6.07 : aucune unicite sur tierId).
#[ORM\Column(name: 'tier_id', nullable: true)]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?int $tierId = null;
// Adresse precise visitee chez le Tiers (NULL si custom). Pas de FK : cible
// polymorphe (client_address OU supplier_address). RG-6.03 : doit appartenir
// au Tiers (verifie par le TourStopProcessor).
#[ORM\Column(name: 'address_id', nullable: true)]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?int $addressId = null;
#[ORM\Column(name: 'custom_label', length: 180, nullable: true)]
#[Assert\Length(max: 180, maxMessage: 'Le libellé du point libre ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?string $customLabel = null;
#[ORM\Column(name: 'custom_address', length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'L\'adresse du point libre ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?string $customAddress = null;
#[ORM\Column(name: 'custom_latitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La latitude doit être comprise entre {{ min }} et {{ max }}.', min: -90, max: 90)]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?string $customLatitude = null;
#[ORM\Column(name: 'custom_longitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La longitude doit être comprise entre {{ min }} et {{ max }}.', min: -180, max: 180)]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?string $customLongitude = null;
#[ORM\Column(type: 'smallint')]
#[Assert\PositiveOrZero(message: 'La position de l\'étape doit être un nombre positif ou nul.')]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private int $position = 0;
// Duree de visite specifique (sinon tour.default_visit_minutes).
#[ORM\Column(name: 'visit_minutes', type: 'smallint', nullable: true)]
#[Assert\Positive(message: 'La durée de visite doit être un nombre positif.')]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?int $visitMinutes = null;
// Distance / temps depuis l'etape precedente (calcules — lecture seule API,
// alimentes par le moteur de trajet au M6.4).
#[ORM\Column(name: 'leg_distance_m', nullable: true)]
#[Groups(['tour_stop:read'])]
private ?int $legDistanceM = null;
#[ORM\Column(name: 'leg_duration_s', nullable: true)]
#[Groups(['tour_stop:read'])]
private ?int $legDurationS = null;
// Heure d'arrivee estimee (calculee, RG-6.11). Lecture seule API.
#[ORM\Column(name: 'eta', type: 'time_immutable', nullable: true)]
#[Groups(['tour_stop:read'])]
private ?DateTimeImmutable $eta = null;
/**
* RG-6.12 : coherence du point libre vs Tiers referentiel.
* - `custom` : tierId / addressId doivent etre NULL ; customLabel et les
* coordonnees (customLatitude / customLongitude) sont obligatoires.
* - non-`custom` : tierId est obligatoire (cible du referentiel) et les
* champs custom_* n'ont pas de sens (doivent rester NULL).
*
* Note : la coherence « l'adresse appartient au Tiers » (RG-6.03) n'est PAS
* verifiable ici (acces BDD requis) -> portee par le TourStopProcessor.
*/
#[Assert\Callback]
public function validateCustomCoherence(ExecutionContextInterface $context): void
{
if (self::TIER_TYPE_CUSTOM === $this->tierType) {
if (null !== $this->tierId) {
$context->buildViolation('Un point libre ne peut pas référencer un Tiers.')
->atPath('tierId')->addViolation()
;
}
if (null !== $this->addressId) {
$context->buildViolation('Un point libre ne peut pas référencer une adresse.')
->atPath('addressId')->addViolation()
;
}
if (null === $this->customLabel || '' === trim($this->customLabel)) {
$context->buildViolation('Le libellé du point libre est obligatoire.')
->atPath('customLabel')->addViolation()
;
}
if (null === $this->customLatitude) {
$context->buildViolation('La latitude du point libre est obligatoire.')
->atPath('customLatitude')->addViolation()
;
}
if (null === $this->customLongitude) {
$context->buildViolation('La longitude du point libre est obligatoire.')
->atPath('customLongitude')->addViolation()
;
}
return;
}
// Etape sur Tiers referentiel : tierId + addressId obligatoires (l'etape
// vise une adresse precise du Tiers, RG-6.03), champs custom interdits.
if (null === $this->tierId) {
$context->buildViolation('Le Tiers de l\'étape est obligatoire.')
->atPath('tierId')->addViolation()
;
}
if (null === $this->addressId) {
$context->buildViolation('L\'adresse de l\'étape est obligatoire.')
->atPath('addressId')->addViolation()
;
}
if (null !== $this->customLabel && '' !== trim($this->customLabel)) {
$context->buildViolation('Un libellé de point libre n\'est autorisé que sur une étape « custom ».')
->atPath('customLabel')->addViolation()
;
}
}
public function getId(): ?int
{
return $this->id;
}
public function getTour(): ?Tour
{
return $this->tour;
}
public function setTour(?Tour $tour): static
{
$this->tour = $tour;
return $this;
}
public function getTierType(): ?string
{
return $this->tierType;
}
public function setTierType(?string $tierType): static
{
$this->tierType = $tierType;
return $this;
}
public function getTierId(): ?int
{
return $this->tierId;
}
public function setTierId(?int $tierId): static
{
$this->tierId = $tierId;
return $this;
}
public function getAddressId(): ?int
{
return $this->addressId;
}
public function setAddressId(?int $addressId): static
{
$this->addressId = $addressId;
return $this;
}
public function getCustomLabel(): ?string
{
return $this->customLabel;
}
public function setCustomLabel(?string $customLabel): static
{
$this->customLabel = $customLabel;
return $this;
}
public function getCustomAddress(): ?string
{
return $this->customAddress;
}
public function setCustomAddress(?string $customAddress): static
{
$this->customAddress = $customAddress;
return $this;
}
public function getCustomLatitude(): ?string
{
return $this->customLatitude;
}
public function setCustomLatitude(float|string|null $customLatitude): static
{
$this->customLatitude = null === $customLatitude ? null : (string) $customLatitude;
return $this;
}
public function getCustomLongitude(): ?string
{
return $this->customLongitude;
}
public function setCustomLongitude(float|string|null $customLongitude): static
{
$this->customLongitude = null === $customLongitude ? null : (string) $customLongitude;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
public function getVisitMinutes(): ?int
{
return $this->visitMinutes;
}
public function setVisitMinutes(?int $visitMinutes): static
{
$this->visitMinutes = $visitMinutes;
return $this;
}
public function getLegDistanceM(): ?int
{
return $this->legDistanceM;
}
public function setLegDistanceM(?int $legDistanceM): static
{
$this->legDistanceM = $legDistanceM;
return $this;
}
public function getLegDurationS(): ?int
{
return $this->legDurationS;
}
public function setLegDurationS(?int $legDurationS): static
{
$this->legDurationS = $legDurationS;
return $this;
}
public function getEta(): ?DateTimeImmutable
{
return $this->eta;
}
public function setEta(?DateTimeImmutable $eta): static
{
$this->eta = $eta;
return $this;
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Enum;
/**
* Cycle de vie d'une tournee (M6 § 4.2, RG-6.02). Transitions libres en V1
* (aucune machine a etats imposee) : la valeur est simplement contrainte a
* l'une des quatre etapes ci-dessous.
*
* Enum backed string : sert a la fois de source de verite des valeurs
* autorisees (cf. values(), consommee par l'Assert\Choice de Tour::$status) et
* de constantes typees pour le code metier (defaut Draft a la creation).
*/
enum TourStatus: string
{
/** Brouillon — tournee en cours de construction (etat initial au POST). */
case Draft = 'draft';
/** Planifiee — etapes posees, prete a etre realisee. */
case Planned = 'planned';
/** En cours — la tournee est en train d'etre effectuee. */
case InProgress = 'in_progress';
/** Terminee — tournee realisee. */
case Done = 'done';
/**
* Liste des valeurs autorisees (cle stockee en base), pour l'Assert\Choice
* de l'entite Tour. Source unique : ajouter un case suffit.
*
* @return list<string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Repository;
use App\Module\FieldSales\Domain\Entity\Tour;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Contrat du repository des tournees (M6). L'implementation Doctrine vit dans
* Infrastructure/Doctrine (DoctrineTourRepository).
*/
interface TourRepositoryInterface
{
public function findById(int $id): ?Tour;
public function save(Tour $tour): void;
/**
* QueryBuilder de liste des tournees actives (deletedAt IS NULL), triees par
* date decroissante puis libelle. Si $owner est fourni, filtre sur le
* proprietaire (RG-6.01 : la Commerciale ne voit que les siennes) ; null =
* toutes les tournees (admin / Bureau).
*/
public function createListQueryBuilder(?UserInterface $owner = null): QueryBuilder;
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\FieldSales\Domain\Entity\Tour;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Processor d'ecriture des tournees (M6 § 5).
*
* Sequence :
* - POST : pose l'owner = utilisateur courant (RG-6.01, tournee personnelle).
* L'owner n'est jamais accepte dans le payload (pas de groupe d'ecriture).
* - PATCH : aucune reaffectation d'owner.
* - DELETE : soft delete (pose deletedAt) au lieu d'une suppression physique.
*
* La security (field_sales.tours.manage) et la validation Symfony sont deja
* appliquees en amont par API Platform.
*
* @implements ProcessorInterface<Tour, null|Tour>
*/
final class TourProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Tour) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// DELETE = soft delete : on pose deletedAt et on re-persiste (pas de
// suppression physique) — le TourProvider exclut ensuite la tournee.
if ($operation instanceof DeleteOperationInterface) {
$data->setDeletedAt(new DateTimeImmutable());
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// POST : la tournee est personnelle -> owner = utilisateur courant.
if (null === $data->getOwner()) {
$data->setOwner($this->security->getUser());
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Entity\TourStop;
use App\Module\FieldSales\Infrastructure\Tier\TierAddressResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Etape d'une tournee (M6 § 5).
*
* Sequence :
* - POST : rattache l'etape a la tournee parente (Link toProperty 'tour' non
* peuple en ecriture, cf. pattern ClientAddressProcessor::linkParent), puis
* verifie RG-6.03.
* - PATCH : revalide RG-6.03 si la cible/adresse change.
* - DELETE : suppression physique de l'etape.
*
* RG-6.03 (l'adresse appartient au Tiers) : non verifiable par une Assert sur
* l'entite (acces BDD requis). Le TierAddressResolver interroge le schema partage
* en lecture seule (sans import Commercial) ; en cas d'incoherence on leve une
* ValidationException (422) portee sur `addressId`, consommable par useFormErrors.
*
* @implements ProcessorInterface<TourStop, null|TourStop>
*/
final class TourStopProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly TierAddressResolver $tierAddressResolver,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof TourStop) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->validateAddressBelongsToTier($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache l'etape a la tournee parente de la sous-ressource POST
* (/tours/{tourId}/stops). Sur PATCH, no-op (la tournee est deja resolue).
*/
private function linkParent(TourStop $stop, array $uriVariables): void
{
if (null !== $stop->getTour()) {
return;
}
$tourId = $uriVariables['tourId'] ?? null;
if (null === $tourId) {
return;
}
$tour = $tourId instanceof Tour
? $tourId
: $this->em->getRepository(Tour::class)->find($tourId);
// read:false sur le POST : un parent introuvable n'est plus intercepte en
// amont -> 404 explicite (sinon 500 au persist sur tour_id NOT NULL).
if (!$tour instanceof Tour || null !== $tour->getDeletedAt()) {
throw new NotFoundHttpException('Tournée introuvable.');
}
$stop->setTour($tour);
}
/**
* RG-6.03 : pour une etape sur Tiers referentiel (tierType != custom), si une
* adresse est ciblee, elle doit appartenir au Tiers. Le point libre (custom)
* n'a pas d'adresse referentielle -> non concerne (l'entite a deja garanti
* addressId null en custom via le callback).
*/
private function validateAddressBelongsToTier(TourStop $stop): void
{
$tierType = $stop->getTierType();
$tierId = $stop->getTierId();
$addressId = $stop->getAddressId();
// Hors perimetre RG-6.03 : custom, ou champs incomplets (deja couverts par
// le callback RG-6.12), ou type non resoluble en table d'adresses.
if (null === $tierType
|| null === $tierId
|| null === $addressId
|| !$this->tierAddressResolver->isResolvableTierType($tierType)) {
return;
}
if ($this->tierAddressResolver->addressBelongsToTier($tierType, $tierId, $addressId)) {
return;
}
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'L\'adresse sélectionnée n\'appartient pas au Tiers de l\'étape.',
null,
[],
$stop,
'addressId',
$addressId,
));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Repository\TourRepositoryInterface;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider des tournees (M6 § 5). Applique RG-6.01 (tournee personnelle) :
* - Collection (GET /api/tours) : filtree sur l'owner courant, sauf admin ou
* role metier Bureau qui voient toutes les tournees. Toujours paginee.
* - Item (GET / PATCH / DELETE /api/tours/{id}) : 404 si soft-deletee, et 404
* si la tournee appartient a un autre commercial (sauf admin / Bureau).
*
* @implements ProviderInterface<Tour>
*/
final class TourProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourRepository')]
private readonly TourRepositoryInterface $repository,
private readonly Pagination $pagination,
private readonly Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Tour|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Tour>|Paginator<Tour>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
// RG-6.01 : la Commerciale ne voit que ses tournees ; admin / Bureau tout.
$ownerFilter = $this->canSeeAll() ? null : $this->security->getUser();
$qb = $this->repository->createListQueryBuilder($ownerFilter);
// Echappatoire ?pagination=false (convention ERP-72).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<Tour> $tours */
return $qb->getQuery()->getResult();
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Tour
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$tour = $this->repository->findById((int) $id);
if (null === $tour || null !== $tour->getDeletedAt()) {
return null;
}
// RG-6.01 : une Commerciale ne peut pas acceder a la tournee d'autrui.
if (!$this->canSeeAll() && $tour->getOwner() !== $this->security->getUser()) {
return null;
}
return $tour;
}
/**
* Vrai si l'utilisateur courant voit/edite toutes les tournees : admin
* (ROLE_ADMIN) ou role metier Bureau (RG-6.01).
*/
private function canSeeAll(): bool
{
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::BUREAU);
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\Doctrine;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Repository\TourRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @extends ServiceEntityRepository<Tour>
*/
class DoctrineTourRepository extends ServiceEntityRepository implements TourRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Tour::class);
}
public function findById(int $id): ?Tour
{
return $this->find($id);
}
public function save(Tour $tour): void
{
$this->getEntityManager()->persist($tour);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(?UserInterface $owner = null): QueryBuilder
{
// Exclut toujours les tournees soft-deletees (RG : deletedAt IS NULL).
$qb = $this->createQueryBuilder('t')
->andWhere('t.deletedAt IS NULL')
->orderBy('t.tourDate', 'DESC')
->addOrderBy('t.label', 'ASC')
;
// RG-6.01 : filtre proprietaire pour la Commerciale (owner non null).
if (null !== $owner) {
$qb->andWhere('t.owner = :owner')->setParameter('owner', $owner);
}
return $qb;
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\Doctrine;
use App\Module\FieldSales\Domain\Entity\TourStop;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TourStop>
*/
class DoctrineTourStopRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TourStop::class);
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\Tier;
use Doctrine\DBAL\Connection;
/**
* Verifie qu'une adresse appartient bien a un Tiers du referentiel (RG-6.03),
* sans importer aucune classe des modules Commercial (Client / Supplier) — regle
* ABSOLUE n°1.
*
* Approche : lecture seule du schema PARTAGE par nom de table. Une etape de
* tournee cible un Tiers polymorphe (tierType -> table d'adresses + colonne FK
* du proprietaire). On interroge la table d'adresses correspondante en DBAL pur :
* aucune dependance de code vers Commercial, seulement une lecture du schema
* commun (integration « shared database » assumee du monolithe modulaire).
*
* Extensible : ajouter un type Visitable (ex: prestataire) = une entree dans
* self::ADDRESS_TABLES.
*/
final class TierAddressResolver
{
/**
* Mapping tierType -> [table d'adresses, colonne FK du Tiers proprietaire].
* Aligne sur les tables M1 (client_address.client_id) et M2
* (supplier_address.supplier_id). Les identifiants sont des constantes
* statiques (jamais d'entree utilisateur) -> pas de risque d'injection.
*
* @var array<string, array{table: string, ownerColumn: string}>
*/
private const array ADDRESS_TABLES = [
'client' => ['table' => 'client_address', 'ownerColumn' => 'client_id'],
'supplier' => ['table' => 'supplier_address', 'ownerColumn' => 'supplier_id'],
];
public function __construct(private readonly Connection $connection) {}
/**
* Vrai si l'adresse $addressId existe ET appartient au Tiers ($tierType,
* $tierId). Faux si l'adresse n'existe pas, appartient a un autre Tiers, ou
* si le type n'est pas resoluble en table d'adresses (ex: custom).
*/
public function addressBelongsToTier(string $tierType, int $tierId, int $addressId): bool
{
$mapping = self::ADDRESS_TABLES[$tierType] ?? null;
if (null === $mapping) {
return false;
}
// Noms de table/colonne issus d'une whitelist de constantes (jamais de
// l'entree utilisateur) ; seuls les ids sont parametres.
$sql = sprintf(
'SELECT 1 FROM %s WHERE id = :addressId AND %s = :tierId',
$mapping['table'],
$mapping['ownerColumn'],
);
$found = $this->connection->fetchOne($sql, [
'addressId' => $addressId,
'tierId' => $tierId,
]);
return false !== $found;
}
/**
* Vrai si le tierType cible un Tiers du referentiel adressable (par
* opposition au point libre `custom`, qui n'a pas de table d'adresses).
*/
public function isResolvableTierType(string $tierType): bool
{
return isset(self::ADDRESS_TABLES[$tierType]);
}
}
@@ -37,6 +37,15 @@ final class BusinessRoles
*/
public const string COMMERCIALE = 'commerciale';
/**
* Role metier « Bureau » — code de Role RBAC. Utilise par FieldSales (M6,
* RG-6.01) : le Bureau voit TOUTES les tournees en lecture (comme l'admin),
* la Commerciale ne voit que les siennes. Reference ici (Shared) pour que le
* TourProvider raisonne sur le role metier via BusinessRoleAwareInterface
* sans importer le RbacSeeder du module Core (regle ABSOLUE n°1).
*/
public const string BUREAU = 'bureau';
private function __construct()
{
// Classe de constantes : non instanciable.
@@ -369,6 +369,44 @@ final class ColumnCommentsCatalog
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
] + self::timestampableBlamableComments(),
// === M6.3 FieldSales (ERP-124) — miroir des COMMENT de la migration
// Version20260611140000 pour le chemin schema:update (dev/test). ===
'tour' => [
'_table' => 'Tournees commerciales terrain (M6 FieldSales) — personnelles (owner), soft-deletables (deleted_at).',
'id' => 'Identifiant interne auto-incremente.',
'owner_id' => 'Commercial proprietaire de la tournee (RG-6.01, personnelle) — FK -> "user".id, ON DELETE RESTRICT. Pose au POST par le TourProcessor.',
'label' => 'Nom libre de la tournee (NotBlank, <= 120 caracteres).',
'tour_date' => 'Date de realisation de la tournee (NotNull).',
'departure_time' => 'Heure de depart, alimente les ETA (RG-6.11). Defaut applicatif 08:00 (constructeur).',
'start_latitude' => 'Latitude WGS84 du point de depart (site commercial ou adresse libre). NULL -> depart = 1re etape.',
'start_longitude' => 'Longitude WGS84 du point de depart. NULL -> depart = 1re etape.',
'start_label' => 'Libelle affichable du point de depart (<= 180 caracteres). Optionnel.',
'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.',
'status' => 'Cycle de vie (RG-6.02) : draft | planned | in_progress | done (enum TourStatus). Transitions libres en V1. Defaut draft.',
'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).',
'total_duration_s' => 'Cache d affichage : derniere duree totale calculee, en secondes (RG-6.11). Lecture seule API.',
'deleted_at' => 'Horodatage du soft-delete — pose par le DELETE API. Null = tournee active.',
] + self::timestampableBlamableComments(),
'tour_stop' => [
'_table' => 'Etapes ordonnees d une tournee (M6) — cible polymorphe (Tiers referentiel ou point custom). Pas de rapport (scope reduit V0.2).',
'id' => 'Identifiant interne auto-incremente.',
'tour_id' => 'FK -> tour.id, ON DELETE CASCADE — tournee proprietaire de l etape.',
'tier_type' => 'Type de cible : client | supplier | ... | custom (point libre). Resolu via VisitableInterface. Chaine ouverte (Assert\Choice).',
'tier_id' => 'Identifiant du Tiers referentiel cible (NULL si custom). Sans FK (cible polymorphe). RG-6.07 : aucune unicite.',
'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.',
'custom_label' => 'Libelle du point libre — obligatoire ssi tier_type = custom (RG-6.12), sinon NULL.',
'custom_address' => 'Adresse texte du point libre (geocodee) — renseignee uniquement si custom.',
'custom_latitude' => 'Latitude WGS84 du point libre (pin ajustable) — obligatoire ssi custom (RG-6.12).',
'custom_longitude' => 'Longitude WGS84 du point libre — obligatoire ssi custom (RG-6.12).',
'position' => 'Ordre de l etape dans la tournee (drag & drop). Unique par tournee (uq_tour_stop_position).',
'visit_minutes' => 'Duree de visite specifique a l etape, en minutes — sinon tour.default_visit_minutes.',
'leg_distance_m' => 'Cache : distance depuis l etape precedente, en metres (calcule). Lecture seule API (M6.4).',
'leg_duration_s' => 'Cache : temps depuis l etape precedente, en secondes (calcule). Lecture seule API (M6.4).',
'eta' => 'Heure d arrivee estimee a l etape (RG-6.11, calculee). Lecture seule API.',
] + self::timestampableBlamableComments(),
];
}
@@ -58,6 +58,10 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
// Le Choice (valeurs de l'enum TourStatus) borne les valeurs (<= 11 < 20).
'Tour::status' => 'Choice (cas TourStatus) borne deja les valeurs.',
// Le Choice {client,supplier,custom} borne les valeurs (<= 8 < 30).
'TourStop::tierType' => 'Choice {client,supplier,custom} borne deja les valeurs.',
];
/**
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\FieldSales\Api;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use DateTimeImmutable;
/**
* Base des tests fonctionnels du module FieldSales (M6 — tournees).
*
* Mutualise :
* - des factories EM (sans API) pour seeder vite un Client + une adresse
* geolocalisee (cible d'etape), et une tournee appartenant a un user donne ;
* - un helper d'indexation des violations 422 par propertyPath ;
* - le cleanup des donnees jetables (tournees, clients de test, users/roles test_*).
*
* Les imports cross-module (Commercial / Core) sont autorises dans les TESTS
* (la regle ABSOLUE n°1 vise le code de production).
*
* @internal
*/
abstract class AbstractFieldSalesApiTestCase extends AbstractApiTestCase
{
protected const string TEST_CLIENT_PREFIX = 'TEST_FS_CLIENT_';
protected function tearDown(): void
{
$this->cleanupFieldSalesTestData();
parent::tearDown();
}
/**
* Seede un Client minimal (companyName uniquement — les categories sont une
* contrainte Assert non rejouee hors API).
*/
protected function seedClient(string $companyName): Client
{
$em = $this->getEm();
$client = new Client();
$client->setCompanyName(self::TEST_CLIENT_PREFIX.mb_strtoupper($companyName, 'UTF-8'));
$em->persist($client);
$em->flush();
return $client;
}
/**
* Seede une adresse de prospection geolocalisee rattachee a $client. Les
* sites/categories (Assert\Count min 1) ne sont pas rejoues hors API : on
* persiste directement les colonnes NOT NULL + des coordonnees.
*/
protected function seedClientAddress(Client $client, float $lat = 47.218, float $lng = -1.553): ClientAddress
{
$em = $this->getEm();
$address = new ClientAddress();
$address->setClient($client);
$address->setIsProspect(true);
$address->setPostalCode('44000');
$address->setCity('NANTES');
$address->setStreet('1 rue de Test');
$address->setLatitude($lat);
$address->setLongitude($lng);
$em->persist($address);
$em->flush();
return $address;
}
/**
* Seede une tournee appartenant a $owner (sans passer par l'API).
*/
protected function seedTour(User $owner, string $label = 'Tournée test'): Tour
{
$em = $this->getEm();
$tour = new Tour();
$tour->setOwner($owner);
$tour->setLabel($label);
$tour->setTourDate(new DateTimeImmutable('2026-07-01'));
$em->persist($tour);
$em->flush();
return $tour;
}
/**
* Recupere un User par username (ex: 'admin', ou un username jetable cree par
* createUserWithPermission).
*/
protected function getUserByUsername(string $username): User
{
$user = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user, sprintf('User "%s" introuvable.', $username));
return $user;
}
/**
* Indexe les violations d'un corps 422 par propertyPath.
*
* @param array<string, mixed> $body
*
* @return array<string, string>
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
private function cleanupFieldSalesTestData(): void
{
$em = $this->getEm();
// Etapes purgees par CASCADE a la suppression des tournees.
$em->createQuery('DELETE FROM '.Tour::class)->execute();
// Adresses puis clients de test (FK client_address.client_id CASCADE).
$em->createQuery(
'DELETE FROM '.ClientAddress::class.' a WHERE a.client IN ('
.'SELECT c.id FROM '.Client::class.' c WHERE c.companyName LIKE :prefix)',
)->setParameter('prefix', self::TEST_CLIENT_PREFIX.'%')->execute();
$em->createQuery(
'DELETE FROM '.Client::class.' c WHERE c.companyName LIKE :prefix',
)->setParameter('prefix', self::TEST_CLIENT_PREFIX.'%')->execute();
$em->createQuery(
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix',
)->setParameter('prefix', 'testuser_%')->execute();
$em->createQuery(
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix',
)->setParameter('prefix', 'test_%')->execute();
}
}
+332
View File
@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\FieldSales\Api;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Entity\TourStop;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
/**
* Tests fonctionnels du module FieldSales (M6.3 — tournees + etapes).
*
* Couvre : creation d'une tournee draft personnelle, pagination, filtre owner
* (RG-6.01), securite RBAC, sous-ressource etapes, RG-6.03 (adresse hors Tiers),
* RG-6.07 (deux etapes meme Tiers), RG-6.12 (coherence custom / Tiers) et
* l'unicite (tour_id, position).
*
* @internal
*/
final class TourApiTest extends AbstractFieldSalesApiTestCase
{
private const string LD = 'application/ld+json';
/** Permissions du commercial type (voit + gere ses tournees). */
private const array TOUR_PERMISSIONS = ['field_sales.tours.view', 'field_sales.tours.manage'];
// =================================================================
// Tournee : creation, pagination, RBAC, filtre owner
// =================================================================
public function testPostCreatesDraftTourOwnedByCurrentUser(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/tours', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Tournée Loire',
'tourDate' => '2026-07-15',
],
]);
self::assertResponseStatusCodeSame(201);
$body = $response->toArray();
self::assertSame('Tournée Loire', $body['label']);
self::assertSame('draft', $body['status'], 'RG-6.02 : une tournee est creee en draft.');
// RG-6.01 : owner = utilisateur courant (admin), pose par le processor.
$reloaded = $this->getEm()->getRepository(Tour::class)->find($body['id']);
self::assertInstanceOf(Tour::class, $reloaded);
self::assertSame('admin', $reloaded->getOwner()?->getUserIdentifier(), 'owner = utilisateur courant (RG-6.01).');
}
public function testCollectionIsPaginated(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
for ($i = 0; $i < 12; ++$i) {
$this->seedTour($admin, 'Tournée '.$i);
}
$data = $client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(12, $data['totalItems'], 'Les 12 tournees sont comptees.');
self::assertCount(10, $data['member'], 'Page par defaut = 10 items (regle ABSOLUE n°13).');
self::assertArrayHasKey('view', $data, 'Enveloppe Hydra paginee (view present).');
}
public function testListRequiresViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, 'Sans field_sales.tours.view -> 403.');
}
/**
* RG-6.01 : la Commerciale ne voit que ses propres tournees.
*/
public function testOwnerFilterHidesOthersTours(): void
{
$credsA = $this->createUserWithPermissions(self::TOUR_PERMISSIONS);
$credsB = $this->createUserWithPermissions(self::TOUR_PERMISSIONS);
$client = $this->authenticatedClient($credsA['username'], $credsA['password']);
$userA = $this->getUserByUsername($credsA['username']);
$userB = $this->getUserByUsername($credsB['username']);
$this->seedTour($userA, 'À moi');
$this->seedTour($userB, "À l'autre");
$data = $client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(1, $data['totalItems'], 'A ne voit que sa tournee (RG-6.01).');
self::assertSame('À moi', $data['member'][0]['label']);
}
public function testAdminSeesAllTours(): void
{
$credsA = $this->createUserWithPermissions(self::TOUR_PERMISSIONS);
$userA = $this->getUserByUsername($credsA['username']);
$this->seedTour($userA, 'Tournée de A');
$client = $this->authenticatedClient('admin', 'admin');
$data = $client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(1, $data['totalItems'], "L'admin voit toutes les tournees, y compris celles d'autrui.");
}
/**
* RG-6.01 : une Commerciale ne peut pas acceder a la tournee d'un autre
* commercial (404 via le provider).
*/
public function testCommercialeCannotAccessOthersTour(): void
{
$credsA = $this->createUserWithPermissions(self::TOUR_PERMISSIONS);
$credsB = $this->createUserWithPermissions(self::TOUR_PERMISSIONS);
$client = $this->authenticatedClient($credsA['username'], $credsA['password']);
$userB = $this->getUserByUsername($credsB['username']);
$tourB = $this->seedTour($userB, 'Privée B');
$client->request('GET', '/api/tours/'.$tourB->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(404);
}
public function testDeleteSoftDeletesTour(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin, 'À supprimer');
$tourId = $tour->getId();
$client->request('DELETE', '/api/tours/'.$tourId, ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(204);
// Plus accessible via l'API...
$client->request('GET', '/api/tours/'.$tourId, ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(404, 'DELETE = soft delete -> 404 ensuite.');
// ...mais la ligne existe toujours avec deletedAt pose (soft delete).
$em = $this->getEm();
$reloaded = $em->getRepository(Tour::class)->find($tourId);
self::assertInstanceOf(Tour::class, $reloaded);
self::assertNotNull($reloaded->getDeletedAt(), 'deletedAt doit etre pose (pas de suppression physique).');
}
// =================================================================
// Etapes : sous-ressource + regles de gestion
// =================================================================
public function testValidTierStopIsCreated(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
$tier = $this->seedClient('Ferme A');
$address = $this->seedClientAddress($tier);
$client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'tierType' => 'client',
'tierId' => $tier->getId(),
'addressId' => $address->getId(),
'position' => 0,
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testValidCustomStopIsCreated(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
$client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'tierType' => 'custom',
'customLabel' => 'RDV prospect',
'customAddress' => '5 place du Marché, 44000 Nantes',
'customLatitude' => '47.2184000',
'customLongitude' => '-1.5536000',
'position' => 0,
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* RG-6.12 : un point custom exige un libelle (et des coordonnees).
*/
public function testCustomStopRequiresLabel(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
$response = $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'tierType' => 'custom',
'customLatitude' => '47.2184000',
'customLongitude' => '-1.5536000',
'position' => 0,
],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('customLabel', $this->violationsByPath($response->toArray(false)));
}
/**
* RG-6.12 : une etape sur Tiers exige une adresse precise.
*/
public function testTierStopRequiresAddress(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
$tier = $this->seedClient('Ferme B');
$response = $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'tierType' => 'client',
'tierId' => $tier->getId(),
'position' => 0,
],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('addressId', $this->violationsByPath($response->toArray(false)));
}
/**
* RG-6.03 : l'adresse d'une etape doit appartenir au Tiers vise -> 422 sinon.
*/
public function testAddressMustBelongToTier(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
$tierA = $this->seedClient('Ferme A');
$tierB = $this->seedClient('Ferme B');
$addressB = $this->seedClientAddress($tierB);
// tier = A mais adresse = celle de B -> incoherent (RG-6.03).
$response = $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'tierType' => 'client',
'tierId' => $tierA->getId(),
'addressId' => $addressB->getId(),
'position' => 0,
],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('addressId', $this->violationsByPath($response->toArray(false)));
}
/**
* RG-6.07 : deux etapes peuvent viser le meme Tiers (positions distinctes).
*/
public function testTwoStopsSameTierAccepted(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
$tier = $this->seedClient('Ferme A');
$address = $this->seedClientAddress($tier);
foreach ([0, 1] as $position) {
$client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'tierType' => 'client',
'tierId' => $tier->getId(),
'addressId' => $address->getId(),
'position' => $position,
],
]);
self::assertResponseStatusCodeSame(201, 'RG-6.07 : meme Tiers accepte sur deux etapes.');
}
}
/**
* Unicite (tour_id, position) : deux etapes au meme rang sont refusees par
* l'index unique. Teste au niveau DBAL (sans casser l'EM de l'ORM).
*/
public function testPositionUniquenessIsEnforced(): void
{
$em = $this->getEm();
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
$stop = new TourStop();
$stop->setTour($tour);
$stop->setTierType(TourStop::TIER_TYPE_CUSTOM);
$stop->setCustomLabel('Étape 1');
$stop->setCustomLatitude(47.2184);
$stop->setCustomLongitude(-1.5536);
$stop->setPosition(0);
$em->persist($stop);
$em->flush();
// Insertion brute d'une 2e etape au meme (tour_id, position) -> viole
// uq_tour_stop_position. Passage par DBAL pour ne pas fermer l'EM ORM.
$now = (new DateTimeImmutable())->format('Y-m-d H:i:s');
$this->expectException(UniqueConstraintViolationException::class);
$em->getConnection()->insert('tour_stop', [
'tour_id' => $tour->getId(),
'tier_type' => TourStop::TIER_TYPE_CUSTOM,
'position' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
}
}