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>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+129
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user