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

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

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

407 lines
14 KiB
PHP

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