0052eab1fe
Modèle et API CRUD du module Tournées (M6.3, scope réduit V0.2 : pas de
rapport de visite, donc TourStop sans report_id ni check-in).
- Entités Tour (table tour) + TourStop (table tour_stop) : #[Auditable],
Timestampable/Blamable, enum TourStatus (draft|planned|in_progress|done),
soft delete sur Tour.
- API Platform : GET/POST/GET/PATCH/DELETE /api/tours (DELETE = soft delete),
sous-ressource POST /api/tours/{tourId}/stops + PATCH/DELETE /api/tour_stops/{id}.
- RG-6.01 : tournée personnelle (TourProvider filtre owner ; admin/Bureau
voient tout). RG-6.03 : adresse appartient au Tiers (TourStopProcessor +
TierAddressResolver DBAL, sans import inter-module). RG-6.07 : pas d'unicité
tier_id. RG-6.12 : cohérence custom/Tiers (Assert\Callback).
- Migration racine : tables + COMMENT ON COLUMN FR + index unique
(tour_id, position) + FK CASCADE ; mirror dans ColumnCommentsCatalog.
- i18n audit (fieldsales_tour / _tourstop), mappings Doctrine + API Platform.
- Tests fonctionnels : owner, RG-6.03/6.07/6.12, pagination, unicité position,
soft delete, RBAC (17 tests).
Co-Authored-By: Matthieu <mtholot19@gmail.com>
407 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|