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; } }