feat(field_sales) : calcul de trajet, optimisation, duplication & roadbook PDF (ERP-125)
- RouteEngineInterface (computeMatrix/optimizeOrder/estimateLegDurations) + HaversineRouteEngine V1 (vitesse moyenne parametrable, plus proche voisin)
- TourRouteCalculator : resolution coords, ETA (RG-6.11), exclusion sans coords (RG-6.05), totaux ; optimize = reorder + recompute
- Endpoints API Platform POST /tours/{id}/compute, /optimize, /duplicate (TourDuplicator, RG-6.13) + Processors, security manage
- Feuille de route PDF GET /tours/{id}/roadbook.pdf (Dompdf + Twig) via PdfRendererInterface (Shared), controller priority:1, security view
- TierAddressResolver etendu (coords + location DBAL)
- Tests : HaversineRouteEngine (unit), compute/optimize/duplicate/roadbook (API)
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Application\Duplication;
|
||||
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use App\Module\FieldSales\Domain\Entity\TourStop;
|
||||
use App\Module\FieldSales\Domain\Enum\TourStatus;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Duplication d'une tournee (M6 § 13, RG-6.13). Cree une NOUVELLE tournee `draft`
|
||||
* a la date fournie, copiant :
|
||||
* - les parametres de la tournee source (point de depart, heure de depart, duree
|
||||
* de visite par defaut, libelle) ;
|
||||
* - chaque etape (cible Tiers/adresse ou point libre, position, duree de visite).
|
||||
*
|
||||
* Ne copie PAS les calculs (eta, leg_distance_m, leg_duration_s, totaux) : ils
|
||||
* seront recalcules par /compute sur la copie. La copie appartient au meme
|
||||
* proprietaire que la source (tournee personnelle, RG-6.01).
|
||||
*
|
||||
* Service pur : il construit et retourne l'entite ; la persistance (persist +
|
||||
* flush) est a la charge du processor appelant.
|
||||
*/
|
||||
final class TourDuplicator
|
||||
{
|
||||
public function duplicate(Tour $source, DateTimeImmutable $tourDate): Tour
|
||||
{
|
||||
$copy = new Tour();
|
||||
$copy->setOwner($source->getOwner());
|
||||
$copy->setLabel($source->getLabel());
|
||||
$copy->setTourDate($tourDate);
|
||||
$copy->setDepartureTime($source->getDepartureTime());
|
||||
$copy->setStartLatitude($source->getStartLatitude());
|
||||
$copy->setStartLongitude($source->getStartLongitude());
|
||||
$copy->setStartLabel($source->getStartLabel());
|
||||
$copy->setDefaultVisitMinutes($source->getDefaultVisitMinutes());
|
||||
// Toute copie repart en draft, quel que soit l'etat de la source.
|
||||
$copy->setStatus(TourStatus::Draft->value);
|
||||
|
||||
foreach ($source->getStops() as $stop) {
|
||||
$copy->addStop($this->duplicateStop($stop));
|
||||
}
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copie d'une etape SANS les champs calcules (eta / legs), conformement a
|
||||
* RG-6.13 : ils seront regeneres par /compute.
|
||||
*/
|
||||
private function duplicateStop(TourStop $source): TourStop
|
||||
{
|
||||
$copy = new TourStop();
|
||||
$copy->setTierType($source->getTierType());
|
||||
$copy->setTierId($source->getTierId());
|
||||
$copy->setAddressId($source->getAddressId());
|
||||
$copy->setCustomLabel($source->getCustomLabel());
|
||||
$copy->setCustomAddress($source->getCustomAddress());
|
||||
$copy->setCustomLatitude($source->getCustomLatitude());
|
||||
$copy->setCustomLongitude($source->getCustomLongitude());
|
||||
$copy->setPosition($source->getPosition());
|
||||
$copy->setVisitMinutes($source->getVisitMinutes());
|
||||
|
||||
return $copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Application\Route;
|
||||
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use App\Module\FieldSales\Domain\Entity\TourStop;
|
||||
use App\Module\FieldSales\Domain\Route\RouteEngineInterface;
|
||||
use App\Module\FieldSales\Domain\Route\RoutePoint;
|
||||
use App\Module\FieldSales\Infrastructure\Tier\TierAddressResolver;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Orchestration du calcul de trajet d'une tournee (M6 § 3.4, § 5 /compute +
|
||||
* /optimize). Fait le pont entre les entites (Tour / TourStop) et le moteur
|
||||
* geometrique {@see RouteEngineInterface}, qui lui ignore tout du metier :
|
||||
*
|
||||
* 1. resout les coordonnees de chaque etape (point libre `custom` -> coords
|
||||
* portees par l'etape ; Tiers referentiel -> coords de l'adresse, ERP-122) ;
|
||||
* 2. exclut les etapes sans coordonnees (RG-6.05) : leurs legs/eta sont remis a
|
||||
* null (signalement « a geolocaliser » cote front) ;
|
||||
* 3. calcule, pour les etapes geolocalisees, les segments (leg_distance_m /
|
||||
* leg_duration_s) et l'heure d'arrivee estimee (eta, RG-6.11 : depart + Σ
|
||||
* trajets precedents + Σ durees de visite precedentes) ;
|
||||
* 4. met a jour les totaux de la tournee (total_distance_m / total_duration_s).
|
||||
*
|
||||
* `compute()` respecte l'ordre courant des etapes (position) ; `optimize()`
|
||||
* reordonne d'abord via le moteur (plus proche voisin) puis recompute.
|
||||
*/
|
||||
final class TourRouteCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RouteEngineInterface $routeEngine,
|
||||
private readonly TierAddressResolver $tierAddressResolver,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Recalcule legs + eta + totaux de la tournee, dans l'ordre courant des
|
||||
* etapes. Mutation en place des entites (le flush est a la charge du
|
||||
* processor appelant).
|
||||
*/
|
||||
public function compute(Tour $tour): void
|
||||
{
|
||||
$stops = $this->orderedStops($tour);
|
||||
|
||||
// RG-6.05 : on partitionne les etapes geolocalisees (entrent dans le
|
||||
// calcul) des autres (legs/eta remis a null = « a geolocaliser »).
|
||||
$routedStops = [];
|
||||
$points = [];
|
||||
foreach ($stops as $stop) {
|
||||
$coords = $this->resolveCoordinates($stop);
|
||||
if (null === $coords) {
|
||||
$this->resetStop($stop);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$routedStops[] = $stop;
|
||||
$points[] = new RoutePoint($stop->getId() ?? spl_object_id($stop), $coords['lat'], $coords['lng']);
|
||||
}
|
||||
|
||||
if ([] === $routedStops) {
|
||||
$tour->setTotalDistanceM(null);
|
||||
$tour->setTotalDurationS(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$start = $this->startPoint($tour);
|
||||
$legs = $this->routeEngine->estimateLegDurations($start, $points);
|
||||
|
||||
$departureSeconds = $this->secondsOfDay($tour->getDepartureTime());
|
||||
$elapsedSeconds = 0; // secondes ecoulees depuis le depart
|
||||
$totalDistance = 0;
|
||||
|
||||
foreach ($routedStops as $index => $stop) {
|
||||
$leg = $legs[$index];
|
||||
|
||||
// Trajet pour atteindre cette etape puis heure d'arrivee estimee.
|
||||
$elapsedSeconds += $leg->durationSeconds;
|
||||
$totalDistance += $leg->distanceMeters;
|
||||
|
||||
$stop->setLegDistanceM($leg->distanceMeters);
|
||||
$stop->setLegDurationS($leg->durationSeconds);
|
||||
$stop->setEta($tour->getDepartureTime()->setTime(0, 0)->modify(
|
||||
sprintf('+%d seconds', $departureSeconds + $elapsedSeconds),
|
||||
));
|
||||
|
||||
// La visite a cette etape repousse l'arrivee a l'etape suivante.
|
||||
$elapsedSeconds += $this->visitSeconds($tour, $stop);
|
||||
}
|
||||
|
||||
$tour->setTotalDistanceM($totalDistance);
|
||||
// Duree totale = trajets + visites (du depart a la fin de la derniere visite).
|
||||
$tour->setTotalDurationS($elapsedSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reordonne les etapes geolocalisees selon le plus proche voisin
|
||||
* (RouteEngine::optimizeOrder) puis recompute. Les etapes sans coordonnees
|
||||
* (RG-6.05) restent rejetees en fin de tournee, ordre relatif preserve.
|
||||
*
|
||||
* Persiste les nouvelles positions en DEUX temps pour ne pas heurter l'unique
|
||||
* (tour_id, position) en cours de flush : d'abord un offset temporaire hors
|
||||
* plage, puis les positions finales 0..n-1.
|
||||
*/
|
||||
public function optimize(Tour $tour): void
|
||||
{
|
||||
$stops = $this->orderedStops($tour);
|
||||
|
||||
$routedStops = [];
|
||||
$points = [];
|
||||
$unroutedStops = [];
|
||||
$stopByRef = [];
|
||||
foreach ($stops as $stop) {
|
||||
$coords = $this->resolveCoordinates($stop);
|
||||
if (null === $coords) {
|
||||
$unroutedStops[] = $stop;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$ref = $stop->getId() ?? spl_object_id($stop);
|
||||
$stopByRef[$ref] = $stop;
|
||||
$routedStops[] = $stop;
|
||||
$points[] = new RoutePoint($ref, $coords['lat'], $coords['lng']);
|
||||
}
|
||||
|
||||
// Rien a reordonner (0 ou 1 etape geolocalisee) : on recompute seulement.
|
||||
if (count($routedStops) > 1) {
|
||||
$orderedPoints = $this->routeEngine->optimizeOrder($this->startPoint($tour), $points);
|
||||
|
||||
// Etapes geolocalisees dans le nouvel ordre, puis les non geolocalisees.
|
||||
$orderedStops = array_map(static fn (RoutePoint $p) => $stopByRef[$p->ref], $orderedPoints);
|
||||
$orderedStops = [...$orderedStops, ...$unroutedStops];
|
||||
|
||||
$this->reassignPositions($orderedStops);
|
||||
}
|
||||
|
||||
$this->compute($tour);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reattribue les positions 0..n-1 dans l'ordre fourni, en deux flushes pour
|
||||
* eviter toute collision transitoire avec l'unique (tour_id, position).
|
||||
*
|
||||
* @param list<TourStop> $orderedStops
|
||||
*/
|
||||
private function reassignPositions(array $orderedStops): void
|
||||
{
|
||||
// Phase 1 : positions temporaires hors plage (offset > nb d'etapes
|
||||
// possibles), garanties uniques entre elles.
|
||||
foreach ($orderedStops as $index => $stop) {
|
||||
$stop->setPosition(10_000 + $index);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
// Phase 2 : positions finales contiguës a partir de 0.
|
||||
foreach ($orderedStops as $index => $stop) {
|
||||
$stop->setPosition($index);
|
||||
}
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Etapes de la tournee triees par position croissante.
|
||||
*
|
||||
* @return list<TourStop>
|
||||
*/
|
||||
private function orderedStops(Tour $tour): array
|
||||
{
|
||||
$stops = array_values($tour->getStops()->toArray());
|
||||
usort($stops, static fn (TourStop $a, TourStop $b) => $a->getPosition() <=> $b->getPosition());
|
||||
|
||||
return $stops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordonnees d'une etape : point libre -> coords saisies sur l'etape ; Tiers
|
||||
* referentiel -> coords de l'adresse visee. Null si non geolocalisable.
|
||||
*
|
||||
* @return null|array{lat: float, lng: float}
|
||||
*/
|
||||
private function resolveCoordinates(TourStop $stop): ?array
|
||||
{
|
||||
if (TourStop::TIER_TYPE_CUSTOM === $stop->getTierType()) {
|
||||
$lat = $stop->getCustomLatitude();
|
||||
$lng = $stop->getCustomLongitude();
|
||||
|
||||
return null === $lat || null === $lng ? null : ['lat' => (float) $lat, 'lng' => (float) $lng];
|
||||
}
|
||||
|
||||
$tierType = $stop->getTierType();
|
||||
$addressId = $stop->getAddressId();
|
||||
if (null === $tierType || null === $addressId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->tierAddressResolver->findAddressCoordinates($tierType, $addressId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Point de depart de la tournee : coordonnees explicites (start_*) si les deux
|
||||
* sont posees, sinon null -> la 1re etape geolocalisee fait office de depart
|
||||
* (cf. RouteEngine, 1er segment nul).
|
||||
*/
|
||||
private function startPoint(Tour $tour): ?RoutePoint
|
||||
{
|
||||
$lat = $tour->getStartLatitude();
|
||||
$lng = $tour->getStartLongitude();
|
||||
|
||||
if (null === $lat || null === $lng) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RoutePoint('start', (float) $lat, (float) $lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duree de visite d'une etape en secondes : valeur specifique de l'etape
|
||||
* sinon la duree par defaut de la tournee.
|
||||
*/
|
||||
private function visitSeconds(Tour $tour, TourStop $stop): int
|
||||
{
|
||||
return ($stop->getVisitMinutes() ?? $tour->getDefaultVisitMinutes()) * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de secondes ecoulees depuis minuit pour une heure donnee.
|
||||
*/
|
||||
private function secondsOfDay(DateTimeImmutable $time): int
|
||||
{
|
||||
return (int) $time->format('H') * 3600
|
||||
+ (int) $time->format('i') * 60
|
||||
+ (int) $time->format('s');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remet a null les resultats calcules d'une etape exclue du trajet (RG-6.05).
|
||||
*/
|
||||
private function resetStop(TourStop $stop): void
|
||||
{
|
||||
$stop->setLegDistanceM(null);
|
||||
$stop->setLegDurationS(null);
|
||||
$stop->setEta(null);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ 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\TourComputeProcessor;
|
||||
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourDuplicateProcessor;
|
||||
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourOptimizeProcessor;
|
||||
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;
|
||||
@@ -84,6 +87,45 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
provider: TourProvider::class,
|
||||
processor: TourProcessor::class,
|
||||
),
|
||||
// Recalcule legs + ETA + totaux (HaversineRouteEngine). Sans corps :
|
||||
// deserialize:false / validate:false ; la tournee est chargee par le
|
||||
// provider (RG-6.01). Reponse = la tournee + ses etapes recalculees.
|
||||
new Post(
|
||||
uriTemplate: '/tours/{id}/compute',
|
||||
status: 200,
|
||||
security: "is_granted('field_sales.tours.manage')",
|
||||
deserialize: false,
|
||||
validate: false,
|
||||
read: true,
|
||||
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
|
||||
provider: TourProvider::class,
|
||||
processor: TourComputeProcessor::class,
|
||||
),
|
||||
// Reordonne les etapes (plus proche voisin) puis recompute.
|
||||
new Post(
|
||||
uriTemplate: '/tours/{id}/optimize',
|
||||
status: 200,
|
||||
security: "is_granted('field_sales.tours.manage')",
|
||||
deserialize: false,
|
||||
validate: false,
|
||||
read: true,
|
||||
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
|
||||
provider: TourProvider::class,
|
||||
processor: TourOptimizeProcessor::class,
|
||||
),
|
||||
// Duplique depart + etapes a une nouvelle date (corps {tourDate}), sans
|
||||
// calculs (RG-6.13). deserialize:false : le processor lit tourDate puis
|
||||
// construit une copie draft via TourDuplicator. Reponse 201 = la copie.
|
||||
new Post(
|
||||
uriTemplate: '/tours/{id}/duplicate',
|
||||
security: "is_granted('field_sales.tours.manage')",
|
||||
deserialize: false,
|
||||
validate: false,
|
||||
read: true,
|
||||
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
|
||||
provider: TourProvider::class,
|
||||
processor: TourDuplicateProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineTourRepository::class)]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Domain\Route;
|
||||
|
||||
/**
|
||||
* Contrat du moteur de calcul de trajet (M6 § 3.4). Pose des la V1 pour brancher
|
||||
* un fournisseur routier reel (OrsRouteEngine) en V2 SANS toucher au reste du
|
||||
* module : « on n'ecrit jamais l'algo routier, on branche un fournisseur ».
|
||||
*
|
||||
* - V1 : HaversineRouteEngine — distance a vol d'oiseau, vitesse moyenne
|
||||
* parametrable, ordre « plus proche voisin » depuis le depart (heuristique
|
||||
* gratuite, RG-6.05 / RG-6.11).
|
||||
* - V2 : OrsRouteEngine — matrice de temps routiers reels + optimisation TSP.
|
||||
*
|
||||
* Le contrat est purement geometrique : il opere sur des {@see RoutePoint} et ne
|
||||
* connait aucune entite metier (Tour / TourStop). L'orchestration (resolution des
|
||||
* coordonnees des etapes, ecriture des resultats, ETA) vit dans le service
|
||||
* applicatif TourRouteCalculator.
|
||||
*/
|
||||
interface RouteEngineInterface
|
||||
{
|
||||
/**
|
||||
* Matrice (symetrique, diagonale nulle) des distances en metres entre tous
|
||||
* les points fournis. `$matrix[$i][$j]` = distance de `$points[$i]` a
|
||||
* `$points[$j]`.
|
||||
*
|
||||
* @param list<RoutePoint> $points
|
||||
*
|
||||
* @return array<int, array<int, int>>
|
||||
*/
|
||||
public function computeMatrix(array $points): array;
|
||||
|
||||
/**
|
||||
* Reordonne les points selon l'heuristique du plus proche voisin :
|
||||
* - si `$start` est fourni, on part de `$start` et on enchaine a chaque etape
|
||||
* le point restant le plus proche ;
|
||||
* - si `$start` est null, le premier point de `$points` est considere comme
|
||||
* le depart (il reste en tete) et seuls les suivants sont reordonnes.
|
||||
*
|
||||
* @param list<RoutePoint> $points
|
||||
*
|
||||
* @return list<RoutePoint> les memes points, dans le nouvel ordre
|
||||
*/
|
||||
public function optimizeOrder(?RoutePoint $start, array $points): array;
|
||||
|
||||
/**
|
||||
* Distance + duree de chaque segment de l'itineraire
|
||||
* `depart -> points[0] -> points[1] -> ...`. Retourne un {@see RouteLeg} par
|
||||
* point : `$legs[$i]` est le trajet pour atteindre `$points[$i]`.
|
||||
*
|
||||
* Si `$start` est null, le premier point est le depart : `$legs[0]` est alors
|
||||
* un segment nul (distance/duree = 0).
|
||||
*
|
||||
* @param list<RoutePoint> $points points DEJA ordonnes
|
||||
*
|
||||
* @return list<RouteLeg>
|
||||
*/
|
||||
public function estimateLegDurations(?RoutePoint $start, array $points): array;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Domain\Route;
|
||||
|
||||
/**
|
||||
* Segment d'itineraire calcule par le moteur de trajet : distance et duree pour
|
||||
* rejoindre un point depuis le precedent (ou depuis le point de depart pour le
|
||||
* premier segment). Objet valeur immuable.
|
||||
*
|
||||
* Alimente `tour_stop.leg_distance_m` / `leg_duration_s` (M6 § 4.3).
|
||||
*/
|
||||
final readonly class RouteLeg
|
||||
{
|
||||
public function __construct(
|
||||
public int $distanceMeters,
|
||||
public int $durationSeconds,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Domain\Route;
|
||||
|
||||
/**
|
||||
* Point geographique manipule par le moteur de trajet (M6 § 3.4). Objet valeur
|
||||
* immuable, purement geometrique : il ne connait ni l'entite Tour ni TourStop.
|
||||
*
|
||||
* `$ref` identifie le point cote appelant (ex: id d'une etape, ou un marqueur de
|
||||
* depart) pour reassocier le resultat du moteur a l'etape correspondante apres
|
||||
* reordonnancement. Le moteur ne l'interprete jamais.
|
||||
*/
|
||||
final readonly class RoutePoint
|
||||
{
|
||||
public function __construct(
|
||||
public int|string $ref,
|
||||
public float $latitude,
|
||||
public float $longitude,
|
||||
) {}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\FieldSales\Application\Route\TourRouteCalculator;
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
use function assert;
|
||||
|
||||
/**
|
||||
* Processor de l'operation POST /api/tours/{id}/compute (M6 § 5).
|
||||
*
|
||||
* La tournee est chargee en amont par TourProvider (controle RG-6.01 + soft
|
||||
* delete). L'operation ne porte pas de corps : on recalcule simplement legs +
|
||||
* eta + totaux (HaversineRouteEngine via TourRouteCalculator) puis on persiste.
|
||||
*
|
||||
* @implements ProcessorInterface<Tour, Tour>
|
||||
*/
|
||||
final class TourComputeProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TourRouteCalculator $calculator,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
|
||||
{
|
||||
assert($data instanceof Tour);
|
||||
|
||||
$this->calculator->compute($data);
|
||||
$this->em->flush();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\FieldSales\Application\Duplication\TourDuplicator;
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
use function assert;
|
||||
|
||||
/**
|
||||
* Processor de l'operation POST /api/tours/{id}/duplicate (M6 § 5, RG-6.13).
|
||||
*
|
||||
* La tournee source est chargee par TourProvider (RG-6.01). Le corps porte la
|
||||
* nouvelle date (`tourDate`). On delegue la copie a {@see TourDuplicator} (sans
|
||||
* calculs), on persiste la copie et on la retourne (201).
|
||||
*
|
||||
* Operation deserialize:false : le corps n'est pas mappe sur la source, on lit
|
||||
* `tourDate` manuellement et on leve une 422 (propertyPath `tourDate`) si elle
|
||||
* est absente ou invalide — consommable par useFormErrors cote front.
|
||||
*
|
||||
* @implements ProcessorInterface<Tour, Tour>
|
||||
*/
|
||||
final class TourDuplicateProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TourDuplicator $duplicator,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly RequestStack $requestStack,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
|
||||
{
|
||||
assert($data instanceof Tour);
|
||||
|
||||
$tourDate = $this->readTourDate();
|
||||
|
||||
$copy = $this->duplicator->duplicate($data, $tourDate);
|
||||
$this->em->persist($copy);
|
||||
$this->em->flush();
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit et valide `tourDate` depuis le corps JSON de la requete. Format attendu
|
||||
* `Y-m-d`. Leve une 422 portee sur `tourDate` si absente ou invalide.
|
||||
*/
|
||||
private function readTourDate(): DateTimeImmutable
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$payload = null !== $request ? json_decode($request->getContent(), true) : null;
|
||||
$raw = is_array($payload) ? ($payload['tourDate'] ?? null) : null;
|
||||
|
||||
if (is_string($raw) && '' !== trim($raw)) {
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($raw));
|
||||
if (false !== $date) {
|
||||
return $date;
|
||||
}
|
||||
}
|
||||
|
||||
$this->throwTourDateViolation(
|
||||
is_string($raw) && '' !== trim($raw)
|
||||
? 'La date de la tournée doit être au format AAAA-MM-JJ.'
|
||||
: 'La date de la tournée dupliquée est obligatoire.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return never
|
||||
*/
|
||||
private function throwTourDateViolation(string $message): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation($message, null, [], null, 'tourDate', null));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\FieldSales\Application\Route\TourRouteCalculator;
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
use function assert;
|
||||
|
||||
/**
|
||||
* Processor de l'operation POST /api/tours/{id}/optimize (M6 § 5).
|
||||
*
|
||||
* Reordonne les etapes via le moteur (plus proche voisin) puis recompute legs +
|
||||
* eta + totaux. La tournee est chargee en amont par TourProvider (RG-6.01).
|
||||
*
|
||||
* @implements ProcessorInterface<Tour, Tour>
|
||||
*/
|
||||
final class TourOptimizeProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TourRouteCalculator $calculator,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
|
||||
{
|
||||
assert($data instanceof Tour);
|
||||
|
||||
$this->calculator->optimize($data);
|
||||
$this->em->flush();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Infrastructure\Controller;
|
||||
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use App\Module\FieldSales\Domain\Entity\TourStop;
|
||||
use App\Module\FieldSales\Domain\Repository\TourRepositoryInterface;
|
||||
use App\Module\FieldSales\Infrastructure\Tier\TierAddressResolver;
|
||||
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
||||
use App\Shared\Domain\Contract\PdfRendererInterface;
|
||||
use App\Shared\Domain\Security\BusinessRoles;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Feuille de route PDF d'une tournee (M6 § 5 — GET /api/tours/{id}/roadbook.pdf).
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra — meme motif que les exports
|
||||
* XLSX (ClientExportController). `priority: 1` est OBLIGATOIRE sur la route :
|
||||
* sans cela API Platform capterait `/api/tours/{id}/roadbook.pdf` comme l'item
|
||||
* `GET /api/tours/{id}.{_format}`.
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (HTML -> PDF) est delegue au service Shared {@see PdfRendererInterface} ;
|
||||
* - le rendu HTML est un template Twig (field_sales/roadbook.html.twig) ;
|
||||
* - le QUOI vit ICI : controle d'acces RG-6.01, mapping des etapes en lignes.
|
||||
*
|
||||
* Acces : `field_sales.tours.view` (IsGranted) + RG-6.01 (la Commerciale ne voit
|
||||
* que ses tournees ; admin / Bureau voient tout) — meme regle que TourProvider.
|
||||
*/
|
||||
#[AsController]
|
||||
final class TourRoadbookController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourRepository')]
|
||||
private readonly TourRepositoryInterface $repository,
|
||||
private readonly TierAddressResolver $tierAddressResolver,
|
||||
private readonly PdfRendererInterface $pdfRenderer,
|
||||
private readonly Environment $twig,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
#[Route('/api/tours/{id}/roadbook.pdf', name: 'field_sales_tour_roadbook_pdf', requirements: ['id' => '\d+'], methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('field_sales.tours.view')]
|
||||
public function __invoke(int $id): Response
|
||||
{
|
||||
$tour = $this->repository->findById($id);
|
||||
if (null === $tour || null !== $tour->getDeletedAt() || !$this->canView($tour)) {
|
||||
throw new NotFoundHttpException('Tournée introuvable.');
|
||||
}
|
||||
|
||||
$html = $this->twig->render('field_sales/roadbook.html.twig', [
|
||||
'tour' => $this->mapTour($tour),
|
||||
'stops' => $this->mapStops($tour),
|
||||
]);
|
||||
|
||||
return $this->buildResponse($this->pdfRenderer->renderHtml($html), $tour);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.01 : admin (ROLE_ADMIN) et role metier Bureau voient toutes les
|
||||
* tournees ; sinon seul le proprietaire (meme logique que TourProvider).
|
||||
*/
|
||||
private function canView(Tour $tour): bool
|
||||
{
|
||||
if ($this->security->isGranted('ROLE_ADMIN')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof BusinessRoleAwareInterface && $user->hasBusinessRole(BusinessRoles::BUREAU)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $tour->getOwner() === $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* En-tete de la feuille de route.
|
||||
*
|
||||
* @return array<string, null|int|string>
|
||||
*/
|
||||
private function mapTour(Tour $tour): array
|
||||
{
|
||||
return [
|
||||
'label' => $tour->getLabel(),
|
||||
'date' => $tour->getTourDate()?->format('d/m/Y'),
|
||||
'commercial' => $tour->getOwner()?->getUserIdentifier(),
|
||||
'departureTime' => $tour->getDepartureTime()->format('H\hi'),
|
||||
'startLabel' => $tour->getStartLabel(),
|
||||
'totalDistance' => $this->formatDistance($tour->getTotalDistanceM()),
|
||||
'totalDuration' => $this->formatDuration($tour->getTotalDurationS()),
|
||||
'stopCount' => $tour->getStops()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Une ligne par etape (n°, ETA, duree de visite, Tiers/libelle, adresse,
|
||||
* temps + distance depuis l'etape precedente).
|
||||
*
|
||||
* @return list<array<string, null|int|string>>
|
||||
*/
|
||||
private function mapStops(Tour $tour): array
|
||||
{
|
||||
$stops = $tour->getStops()->toArray();
|
||||
usort($stops, static fn (TourStop $a, TourStop $b) => $a->getPosition() <=> $b->getPosition());
|
||||
|
||||
$rows = [];
|
||||
$number = 1;
|
||||
foreach ($stops as $stop) {
|
||||
[$name, $address] = $this->resolveStopDisplay($stop);
|
||||
|
||||
$rows[] = [
|
||||
'number' => $number++,
|
||||
'eta' => $stop->getEta()?->format('H\hi') ?? '—',
|
||||
'visitMinutes' => $stop->getVisitMinutes() ?? $tour->getDefaultVisitMinutes(),
|
||||
'name' => $name,
|
||||
'address' => $address,
|
||||
'legDistance' => null !== $stop->getLegDistanceM() ? $this->formatDistance($stop->getLegDistanceM()) : '—',
|
||||
'legDuration' => null !== $stop->getLegDurationS() ? $this->formatDuration($stop->getLegDurationS()) : '—',
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom affiche + adresse complete d'une etape : point libre -> libelle/adresse
|
||||
* saisis ; Tiers referentiel -> nom du Tiers + adresse resolue (DBAL).
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function resolveStopDisplay(TourStop $stop): array
|
||||
{
|
||||
if (TourStop::TIER_TYPE_CUSTOM === $stop->getTierType()) {
|
||||
return [
|
||||
$stop->getCustomLabel() ?? 'Point libre',
|
||||
$stop->getCustomAddress() ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$tierType = $stop->getTierType();
|
||||
$addressId = $stop->getAddressId();
|
||||
if (null === $tierType || null === $addressId) {
|
||||
return ['Étape', ''];
|
||||
}
|
||||
|
||||
$location = $this->tierAddressResolver->findStopLocation($tierType, $addressId);
|
||||
if (null === $location) {
|
||||
return ['Tiers #'.(string) $stop->getTierId(), ''];
|
||||
}
|
||||
|
||||
return [$location['tierName'], $this->formatAddress($location)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatene les composantes d'adresse en une ligne lisible.
|
||||
*
|
||||
* @param array{street: ?string, streetComplement: ?string, postalCode: ?string, city: ?string} $location
|
||||
*/
|
||||
private function formatAddress(array $location): string
|
||||
{
|
||||
$street = trim(($location['street'] ?? '').' '.($location['streetComplement'] ?? ''));
|
||||
$city = trim(($location['postalCode'] ?? '').' '.($location['city'] ?? ''));
|
||||
|
||||
return trim(implode(', ', array_filter([$street, $city], static fn (string $p) => '' !== $p)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance en metres -> texte « X,Y km » (ou « — » si inconnue).
|
||||
*/
|
||||
private function formatDistance(?int $meters): string
|
||||
{
|
||||
if (null === $meters) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return number_format($meters / 1000, 1, ',', ' ').' km';
|
||||
}
|
||||
|
||||
/**
|
||||
* Duree en secondes -> texte « XhYY » / « YY min » (ou « — » si inconnue).
|
||||
*/
|
||||
private function formatDuration(?int $seconds): string
|
||||
{
|
||||
if (null === $seconds) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$minutes = (int) round($seconds / 60);
|
||||
if ($minutes < 60) {
|
||||
return $minutes.' min';
|
||||
}
|
||||
|
||||
return sprintf('%dh%02d', intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary, Tour $tour): Response
|
||||
{
|
||||
$filename = sprintf('feuille-de-route-%d-%s.pdf', $tour->getId(), $tour->getTourDate()?->format('Ymd') ?? 'tour');
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/pdf');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Infrastructure\Route;
|
||||
|
||||
use App\Module\FieldSales\Domain\Route\RouteEngineInterface;
|
||||
use App\Module\FieldSales\Domain\Route\RouteLeg;
|
||||
use App\Module\FieldSales\Domain\Route\RoutePoint;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Moteur de trajet V1 (M6 § 3.4) : « heuristique gratuite ».
|
||||
*
|
||||
* - Distance = formule de Haversine (vol d'oiseau, en metres).
|
||||
* - Duree = distance / vitesse moyenne (km/h parametrable, defaut 50).
|
||||
* - Ordre = plus proche voisin glouton depuis le point de depart.
|
||||
*
|
||||
* Aucune dependance reseau ni cout : la V2 (OrsRouteEngine) remplacera cette impl
|
||||
* derriere {@see RouteEngineInterface} sans toucher au calculateur ni au front.
|
||||
*/
|
||||
final class HaversineRouteEngine implements RouteEngineInterface
|
||||
{
|
||||
/** Rayon moyen de la Terre en metres (modele spherique WGS84). */
|
||||
private const float EARTH_RADIUS_M = 6_371_000.0;
|
||||
|
||||
public function __construct(
|
||||
// Vitesse moyenne parametrable (config field_sales.route_average_speed_kmh).
|
||||
#[Autowire(param: 'field_sales.route_average_speed_kmh')]
|
||||
private readonly float $averageSpeedKmh = 50.0,
|
||||
) {}
|
||||
|
||||
public function computeMatrix(array $points): array
|
||||
{
|
||||
$matrix = [];
|
||||
$count = count($points);
|
||||
|
||||
for ($i = 0; $i < $count; ++$i) {
|
||||
for ($j = 0; $j < $count; ++$j) {
|
||||
// Symetrie : on ne calcule que le triangle superieur, on recopie.
|
||||
if ($j < $i) {
|
||||
$matrix[$i][$j] = $matrix[$j][$i];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$matrix[$i][$j] = $i === $j ? 0 : $this->haversineMeters($points[$i], $points[$j]);
|
||||
}
|
||||
}
|
||||
|
||||
return $matrix;
|
||||
}
|
||||
|
||||
public function optimizeOrder(?RoutePoint $start, array $points): array
|
||||
{
|
||||
if (count($points) < 2) {
|
||||
return array_values($points);
|
||||
}
|
||||
|
||||
// Sans depart explicite, le 1er point est le depart : il reste en tete et
|
||||
// sert de point de reference initial pour reordonner les suivants.
|
||||
if (null === $start) {
|
||||
$remaining = array_values($points);
|
||||
$first = array_shift($remaining);
|
||||
$ordered = [$first];
|
||||
$current = $first;
|
||||
} else {
|
||||
$remaining = array_values($points);
|
||||
$ordered = [];
|
||||
$current = $start;
|
||||
}
|
||||
|
||||
// Plus proche voisin glouton : a chaque pas, on rattache le point restant
|
||||
// le plus proche du dernier point retenu.
|
||||
while ([] !== $remaining) {
|
||||
$nearestIndex = 0;
|
||||
$nearestDistance = $this->haversineMeters($current, $remaining[0]);
|
||||
|
||||
foreach ($remaining as $index => $candidate) {
|
||||
$distance = $this->haversineMeters($current, $candidate);
|
||||
if ($distance < $nearestDistance) {
|
||||
$nearestDistance = $distance;
|
||||
$nearestIndex = $index;
|
||||
}
|
||||
}
|
||||
|
||||
$current = $remaining[$nearestIndex];
|
||||
$ordered[] = $current;
|
||||
array_splice($remaining, $nearestIndex, 1);
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
public function estimateLegDurations(?RoutePoint $start, array $points): array
|
||||
{
|
||||
$legs = [];
|
||||
$previous = $start;
|
||||
|
||||
foreach ($points as $point) {
|
||||
// 1er point sans depart explicite : aucun trajet a parcourir.
|
||||
if (null === $previous) {
|
||||
$legs[] = new RouteLeg(0, 0);
|
||||
$previous = $point;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$distance = $this->haversineMeters($previous, $point);
|
||||
$legs[] = new RouteLeg($distance, $this->metersToSeconds($distance));
|
||||
$previous = $point;
|
||||
}
|
||||
|
||||
return $legs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance de Haversine entre deux points, arrondie au metre.
|
||||
*/
|
||||
private function haversineMeters(RoutePoint $from, RoutePoint $to): int
|
||||
{
|
||||
$lat1 = deg2rad($from->latitude);
|
||||
$lat2 = deg2rad($to->latitude);
|
||||
$dLat = $lat2 - $lat1;
|
||||
$dLng = deg2rad($to->longitude - $from->longitude);
|
||||
|
||||
$a = sin($dLat / 2) ** 2
|
||||
+ cos($lat1) * cos($lat2) * sin($dLng / 2) ** 2;
|
||||
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
|
||||
return (int) round(self::EARTH_RADIUS_M * $c);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une distance (metres) en duree (secondes) a la vitesse moyenne.
|
||||
* Garde-fou : une vitesse nulle/negative donnerait une duree infinie -> 0.
|
||||
*/
|
||||
private function metersToSeconds(int $distanceMeters): int
|
||||
{
|
||||
if ($this->averageSpeedKmh <= 0.0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$metersPerSecond = $this->averageSpeedKmh * 1000.0 / 3600.0;
|
||||
|
||||
return (int) round($distanceMeters / $metersPerSecond);
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,11 @@ final class TierAddressResolver
|
||||
* (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}>
|
||||
* @var array<string, array{table: string, ownerColumn: string, tierTable: string}>
|
||||
*/
|
||||
private const array ADDRESS_TABLES = [
|
||||
'client' => ['table' => 'client_address', 'ownerColumn' => 'client_id'],
|
||||
'supplier' => ['table' => 'supplier_address', 'ownerColumn' => 'supplier_id'],
|
||||
'client' => ['table' => 'client_address', 'ownerColumn' => 'client_id', 'tierTable' => 'client'],
|
||||
'supplier' => ['table' => 'supplier_address', 'ownerColumn' => 'supplier_id', 'tierTable' => 'supplier'],
|
||||
];
|
||||
|
||||
public function __construct(private readonly Connection $connection) {}
|
||||
@@ -73,4 +73,71 @@ final class TierAddressResolver
|
||||
{
|
||||
return isset(self::ADDRESS_TABLES[$tierType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordonnees (lat/lng) d'une adresse de Tiers referentiel, posees au
|
||||
* geocodage (ERP-122). Retourne null si le type n'est pas resoluble, si
|
||||
* l'adresse n'existe pas, ou si elle n'est pas encore geolocalisee (une etape
|
||||
* sans coordonnees est exclue du calcul de trajet — RG-6.05).
|
||||
*
|
||||
* @return null|array{lat: float, lng: float}
|
||||
*/
|
||||
public function findAddressCoordinates(string $tierType, int $addressId): ?array
|
||||
{
|
||||
$mapping = self::ADDRESS_TABLES[$tierType] ?? null;
|
||||
if (null === $mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
'SELECT latitude, longitude FROM %s WHERE id = :addressId',
|
||||
$mapping['table'],
|
||||
);
|
||||
|
||||
$row = $this->connection->fetchAssociative($sql, ['addressId' => $addressId]);
|
||||
if (false === $row || null === $row['latitude'] || null === $row['longitude']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['lat' => (float) $row['latitude'], 'lng' => (float) $row['longitude']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Donnees d'affichage d'une etape sur Tiers referentiel pour la feuille de
|
||||
* route PDF (M6 § 5) : nom du Tiers + composantes de l'adresse. Retourne null
|
||||
* si le type n'est pas resoluble ou si l'adresse n'existe pas (le point libre
|
||||
* `custom` porte ses propres libelle/adresse sur l'etape).
|
||||
*
|
||||
* @return null|array{tierName: string, street: ?string, streetComplement: ?string, postalCode: ?string, city: ?string}
|
||||
*/
|
||||
public function findStopLocation(string $tierType, int $addressId): ?array
|
||||
{
|
||||
$mapping = self::ADDRESS_TABLES[$tierType] ?? null;
|
||||
if (null === $mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Noms de table/colonne issus de la whitelist de constantes (jamais de
|
||||
// l'entree utilisateur) ; seul l'id est parametre.
|
||||
$sql = sprintf(
|
||||
'SELECT t.company_name AS tier_name, a.street, a.street_complement, a.postal_code, a.city '
|
||||
.'FROM %s a JOIN %s t ON t.id = a.%s WHERE a.id = :addressId',
|
||||
$mapping['table'],
|
||||
$mapping['tierTable'],
|
||||
$mapping['ownerColumn'],
|
||||
);
|
||||
|
||||
$row = $this->connection->fetchAssociative($sql, ['addressId' => $addressId]);
|
||||
if (false === $row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'tierName' => (string) $row['tier_name'],
|
||||
'street' => null !== $row['street'] ? (string) $row['street'] : null,
|
||||
'streetComplement' => null !== $row['street_complement'] ? (string) $row['street_complement'] : null,
|
||||
'postalCode' => null !== $row['postal_code'] ? (string) $row['postal_code'] : null,
|
||||
'city' => null !== $row['city'] ? (string) $row['city'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat de rendu d'un document HTML en PDF binaire.
|
||||
*
|
||||
* Service GENERIQUE et reutilisable : il ne connait aucune entite metier. Le
|
||||
* module appelant decide QUOI mettre dans le document (HTML deja rendu, ex: via
|
||||
* Twig) ; cette interface decrit seulement COMMENT produire le binaire PDF. On
|
||||
* depend de ce contrat (dans Shared), jamais de l'implementation concrete (regle
|
||||
* ABSOLUE n°1).
|
||||
*
|
||||
* Implementee par App\Shared\Infrastructure\Pdf\DompdfRenderer (non referencee
|
||||
* via @see pour ne pas creer d'import Domain -> Infra).
|
||||
*/
|
||||
interface PdfRendererInterface
|
||||
{
|
||||
/**
|
||||
* Rend un fragment HTML complet en PDF et retourne son contenu binaire.
|
||||
*
|
||||
* @param string $html document HTML (avec ses styles CSS inline / <style>)
|
||||
* @param string $paperSize format papier (ex: 'A4', 'Letter')
|
||||
* @param string $orientation 'portrait' ou 'landscape'
|
||||
*
|
||||
* @return string contenu binaire du fichier PDF
|
||||
*/
|
||||
public function renderHtml(string $html, string $paperSize = 'A4', string $orientation = 'portrait'): string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Pdf;
|
||||
|
||||
use App\Shared\Domain\Contract\PdfRendererInterface;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
|
||||
/**
|
||||
* Implementation du rendu PDF via Dompdf (standard MALIO, cf. Ferme).
|
||||
*
|
||||
* Securite : l'acces aux ressources distantes est DESACTIVE (isRemoteEnabled =
|
||||
* false) — un PDF de feuille de route ne charge aucune URL externe, ce qui ferme
|
||||
* la porte aux SSRF via du HTML/CSS injecte. Les polices systeme suffisent.
|
||||
*/
|
||||
final class DompdfRenderer implements PdfRendererInterface
|
||||
{
|
||||
public function renderHtml(string $html, string $paperSize = 'A4', string $orientation = 'portrait'): string
|
||||
{
|
||||
$options = new Options();
|
||||
$options->set('isRemoteEnabled', false);
|
||||
$options->set('defaultFont', 'DejaVu Sans'); // gere correctement l'UTF-8 / accents FR
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
$dompdf->setPaper($paperSize, $orientation);
|
||||
$dompdf->render();
|
||||
|
||||
return (string) $dompdf->output();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user