feat(field_sales) : carte interactive Leaflet + écran de planification de tournée (ERP-127)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 56s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 21s

- API visitable_tiers (provider DBAL bbox/q/type, paginé) pour les pins de la carte
- POST /tours/{id}/reorder (drag & drop) : renumérotation atomique + recompute
- Layer front field-sales : TourMap (pins, popup, polyline, sélection rectangle),
  liste d'étapes draggable (vuedraggable), composable de planification + Vitest
- Pages /tours, /tours/new, /tours/[id]/plan (split responsive, point custom géocodé)
- i18n FR, deep links Waze/Google/Apple, état 100% local
This commit is contained in:
Matthieu
2026-06-11 17:38:40 +02:00
parent f8f7571cc0
commit f8793ab359
23 changed files with 2721 additions and 2 deletions
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Application\DTO;
/**
* DTO de sortie d'un « Tiers visitable » geolocalise (M6 § 5,
* GET /api/visitable_tiers) : un pin de la carte = une adresse geolocalisee
* d'un Tiers du referentiel (Client M1 / Fournisseur M2, extensible).
*
* Readonly : aucune mutation apres hydration. La resource API Platform expose
* directement ce DTO (pas d'entite ORM — lecture DBAL pure du schema partage,
* regle ABSOLUE n°1 : FieldSales n'importe aucune classe de Commercial).
*/
final readonly class VisitableTierOutput
{
public function __construct(
/** Identifiant synthetique stable « {type}-{addressId} » (ex: client-42) pour l'IRI Hydra. */
public string $id,
/** Type de Tiers visitable : client | supplier (extensible). */
public string $tierType,
/** ID du Tiers dans son referentiel (client.id / supplier.id). */
public int $tierId,
/** ID de l'adresse precise geolocalisee (client_address.id / supplier_address.id). */
public int $addressId,
/** Raison sociale du Tiers (libelle du pin). */
public string $displayName,
/** Adresse formatee sur une ligne (rue, CP ville). */
public string $address,
/** Latitude WGS84 du pin. */
public float $latitude,
/** Longitude WGS84 du pin. */
public float $longitude,
) {}
}
@@ -143,6 +143,45 @@ final class TourRouteCalculator
$this->compute($tour);
}
/**
* Reordonne les etapes selon la liste d'ids fournie (drag & drop cote front),
* puis recompute. Les ids inconnus sont ignores ; les etapes absentes de la
* liste sont conservees en fin (ordre courant). Persiste en deux temps
* (reassignPositions) pour ne pas heurter l'unique (tour_id, position).
*
* @param list<int> $orderedStopIds
*/
public function reorder(Tour $tour, array $orderedStopIds): void
{
$stops = $this->orderedStops($tour);
$byId = [];
foreach ($stops as $stop) {
$byId[$stop->getId()] = $stop;
}
$ordered = [];
$seen = [];
foreach ($orderedStopIds as $id) {
if (isset($byId[$id]) && !isset($seen[$id])) {
$ordered[] = $byId[$id];
$seen[$id] = true;
}
}
// Etapes non citees dans la liste : placees en fin, ordre courant preserve.
foreach ($stops as $stop) {
if (!isset($seen[$stop->getId()])) {
$ordered[] = $stop;
}
}
if (count($ordered) > 1) {
$this->reassignPositions($ordered);
}
$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).
@@ -15,6 +15,7 @@ use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourCompute
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\Processor\TourReorderProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider\TourProvider;
use App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourRepository;
use App\Shared\Domain\Attribute\Auditable;
@@ -101,6 +102,20 @@ use Symfony\Component\Validator\Constraints as Assert;
provider: TourProvider::class,
processor: TourComputeProcessor::class,
),
// Reordonne les etapes selon l'ordre fourni (drag & drop) puis recompute.
// Corps {stopIds: [ids dans le nouvel ordre]}. La renumerotation est
// atomique (anti-collision unique (tour_id, position)), cf. processor.
new Post(
uriTemplate: '/tours/{id}/reorder',
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: TourReorderProcessor::class,
),
// Reordonne les etapes (plus proche voisin) puis recompute.
new Post(
uriTemplate: '/tours/{id}/optimize',
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\FieldSales\Application\DTO\VisitableTierOutput;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider\VisitableTierProvider;
/**
* Resource API Platform en lecture seule : les « Tiers visitables » geolocalises
* (M6 § 5). Alimente les pins de la carte interactive de planification de
* tournee (M6.5).
*
* Un item = une adresse geolocalisee d'un Tiers (Client M1 / Fournisseur M2).
* Le provider lit via DBAL le schema partage (regle ABSOLUE n°1 : aucun import
* d'une classe Commercial) et retourne des `VisitableTierOutput`. Aucune entite
* ORM derriere — pas d'ecriture exposee.
*
* Filtres query-param (cf. provider) :
* ?bbox=minLng,minLat,maxLng,maxLat zone visible de la carte (Leaflet getBounds().toBBoxString())
* ?q=durand recherche raison sociale / ville (ILIKE)
* ?type=client,supplier restreint les types de Tiers
*
* Pagination : standard global (10/page, max 50). La carte charge en general
* tous les pins de la bbox via l'echappatoire `?pagination=false` (la bbox borne
* deja le volume) — gere par le provider, comme TourProvider.
*
* L'operation Get item (par id synthetique « {type}-{addressId} ») existe pour
* que API Platform genere l'IRI Hydra (`@id`) de chaque membre de la collection
* JSON-LD (meme contrainte que AuditLogResource).
*/
#[ApiResource(
shortName: 'VisitableTier',
operations: [
new GetCollection(
uriTemplate: '/visitable_tiers',
security: "is_granted('field_sales.tours.view')",
provider: VisitableTierProvider::class,
),
new Get(
uriTemplate: '/visitable_tiers/{id}',
requirements: ['id' => '[a-z_]+-[0-9]+'],
security: "is_granted('field_sales.tours.view')",
provider: VisitableTierProvider::class,
),
],
output: VisitableTierOutput::class,
)]
final class VisitableTierResource {}
@@ -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\Route\TourRouteCalculator;
use App\Module\FieldSales\Domain\Entity\Tour;
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}/reorder (M6 § 5, drag & drop).
*
* La tournee est chargee par TourProvider (RG-6.01). Le corps porte l'ordre
* souhaite des etapes (`stopIds` : liste d'ids dans le nouvel ordre). On delegue
* la renumerotation atomique (deux flushes, anti-collision unique (tour_id,
* position)) + le recalcul a {@see TourRouteCalculator::reorder()}, puis on
* retourne la tournee recalculee (200).
*
* Operation deserialize:false : on lit `stopIds` manuellement et on leve une 422
* (propertyPath `stopIds`) si absente ou invalide — consommable par useFormErrors.
*
* @implements ProcessorInterface<Tour, Tour>
*/
final class TourReorderProcessor implements ProcessorInterface
{
public function __construct(
private readonly TourRouteCalculator $calculator,
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
{
assert($data instanceof Tour);
$this->calculator->reorder($data, $this->readStopIds());
$this->em->flush();
return $data;
}
/**
* Lit et valide `stopIds` depuis le corps JSON : liste non vide d'entiers.
* Leve une 422 portee sur `stopIds` si absente ou malformee.
*
* @return list<int>
*/
private function readStopIds(): array
{
$request = $this->requestStack->getCurrentRequest();
$payload = null !== $request ? json_decode($request->getContent(), true) : null;
$raw = is_array($payload) ? ($payload['stopIds'] ?? null) : null;
if (!is_array($raw) || [] === $raw) {
$this->throwViolation('La liste ordonnée des étapes (stopIds) est obligatoire.');
}
$ids = [];
foreach ($raw as $value) {
if (!is_int($value) && !(is_string($value) && ctype_digit($value))) {
$this->throwViolation('La liste des étapes doit ne contenir que des identifiants entiers.');
}
$ids[] = (int) $value;
}
return $ids;
}
/**
* @return never
*/
private function throwViolation(string $message): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation($message, null, [], null, 'stopIds', null));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
use App\Module\FieldSales\Application\DTO\VisitableTierOutput;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Provider de la resource VisitableTier (M6 § 5, pins de la carte de tournee).
*
* Lit en DBAL pur le schema PARTAGE (client_address + supplier_address jointes a
* client/supplier) — aucune classe du module Commercial n'est importee (regle
* ABSOLUE n°1). Les types visitables sont une whitelist de constantes
* (self::SOURCES) ; seuls les Tiers actifs (non archives, non soft-deletes) avec
* une adresse geolocalisee (latitude ET longitude non nulles, RG-6.05) sont
* exposes.
*
* Collection : DbalPaginator (hydra:view auto) ou, sur `?pagination=false`,
* la liste complete bornee par la bbox (la carte affiche TOUS les pins de la
* zone visible — la bbox limite le volume, pas la pagination).
*
* Extensible : ajouter un type Visitable = une entree dans self::SOURCES.
*
* @implements ProviderInterface<VisitableTierOutput>
*/
final readonly class VisitableTierProvider implements ProviderInterface
{
/**
* Mapping tierType -> tables/colonnes du schema partage. Identifiants issus
* d'une whitelist de constantes (jamais de l'entree utilisateur) -> aucun
* risque d'injection ; seules les valeurs (bbox, q) sont parametrees.
*
* @var array<string, array{addressTable: string, ownerColumn: string, tierTable: string}>
*/
private const array SOURCES = [
'client' => ['addressTable' => 'client_address', 'ownerColumn' => 'client_id', 'tierTable' => 'client'],
'supplier' => ['addressTable' => 'supplier_address', 'ownerColumn' => 'supplier_id', 'tierTable' => 'supplier'],
];
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
private Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|DbalPaginator|VisitableTierOutput|null
{
if (!$operation instanceof CollectionOperationInterface) {
return $this->provideItem((string) ($uriVariables['id'] ?? ''));
}
return $this->provideCollection($operation, $context);
}
private function provideItem(string $id): ?VisitableTierOutput
{
// id synthetique « {type}-{addressId} » (cf. VisitableTierOutput::$id).
if (1 !== preg_match('/^([a-z_]+)-([0-9]+)$/', $id, $m)) {
return null;
}
$type = $m[1];
$addressId = (int) $m[2];
$source = self::SOURCES[$type] ?? null;
if (null === $source) {
return null;
}
$sql = $this->buildSelect($source, $type).' AND a.id = :addressId';
/** @var array<string, mixed>|false $row */
$row = $this->connection->fetchAssociative($sql, ['addressId' => $addressId]);
return false === $row ? null : $this->hydrate($row);
}
/**
* @param array<string, mixed> $context
*
* @return DbalPaginator|list<VisitableTierOutput>
*/
private function provideCollection(Operation $operation, array $context): array|DbalPaginator
{
$filters = $context['filters'] ?? [];
$types = $this->extractTypes($filters);
$bbox = $this->extractBbox($filters);
$search = $this->extractSearch($filters);
// Aucun type resoluble demande -> collection vide.
if ([] === $types) {
return $this->pagination->isEnabled($operation, $context)
? new DbalPaginator([], 1, $this->pagination->getLimit($operation, $context), 0)
: [];
}
[$unionSql, $params] = $this->buildUnion($types, $bbox, $search);
// Echappatoire ?pagination=false (convention ERP-72) : la carte charge
// tous les pins de la bbox d'un coup (volume borne par la zone visible).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<array<string, mixed>> $rows */
$rows = $this->connection->fetchAllAssociative(
sprintf('SELECT * FROM (%s) sub ORDER BY display_name ASC, address_id ASC', $unionSql),
$params,
);
return array_map($this->hydrate(...), $rows);
}
$page = max(1, $this->pagination->getPage($context));
$itemsPerPage = $this->pagination->getLimit($operation, $context);
$offset = ($page - 1) * $itemsPerPage;
/** @var list<array<string, mixed>> $rows */
$rows = $this->connection->fetchAllAssociative(
sprintf(
'SELECT * FROM (%s) sub ORDER BY display_name ASC, address_id ASC LIMIT :limit OFFSET :offset',
$unionSql,
),
[...$params, 'limit' => $itemsPerPage, 'offset' => $offset],
);
$totalItems = (int) $this->connection->fetchOne(
sprintf('SELECT COUNT(*) FROM (%s) sub', $unionSql),
$params,
);
$items = array_map($this->hydrate(...), $rows);
return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
}
/**
* Construit l'UNION ALL des SELECT par type demande, en partageant les memes
* parametres nommes (bbox/q) sur chaque moitie.
*
* @param list<string> $types
* @param null|array{minLng: float, minLat: float, maxLng: float, maxLat: float} $bbox
*
* @return array{0: string, 1: array<string, mixed>}
*/
private function buildUnion(array $types, ?array $bbox, ?string $search): array
{
$halves = [];
foreach ($types as $type) {
$halves[] = $this->buildSelect(self::SOURCES[$type], $type, $bbox, null !== $search);
}
$params = [];
if (null !== $bbox) {
$params += $bbox;
}
if (null !== $search) {
// Echappe %, _ et \ pour un ILIKE « contient » litteral.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $search);
$params['q'] = '%'.$escaped.'%';
}
return [implode(' UNION ALL ', $halves), $params];
}
/**
* SELECT d'une source (table d'adresses + Tiers). Filtre toujours sur Tiers
* actif + adresse geolocalisee ; ajoute bbox/q selon les arguments.
*
* @param array{addressTable: string, ownerColumn: string, tierTable: string} $source
* @param null|array{minLng: float, minLat: float, maxLng: float, maxLat: float} $bbox
*/
private function buildSelect(array $source, string $type, ?array $bbox = null, bool $withSearch = false): string
{
$sql = sprintf(
"SELECT '%s' AS tier_type, a.%s AS tier_id, a.id AS address_id, "
.'t.company_name AS display_name, a.street, a.street_complement, a.postal_code, a.city, '
.'a.latitude, a.longitude '
.'FROM %s a JOIN %s t ON t.id = a.%s '
.'WHERE a.latitude IS NOT NULL AND a.longitude IS NOT NULL '
.'AND t.is_archived = FALSE AND t.deleted_at IS NULL',
$type,
$source['ownerColumn'],
$source['addressTable'],
$source['tierTable'],
$source['ownerColumn'],
);
if (null !== $bbox) {
$sql .= ' AND a.latitude BETWEEN :minLat AND :maxLat AND a.longitude BETWEEN :minLng AND :maxLng';
}
if ($withSearch) {
$sql .= ' AND (t.company_name ILIKE :q OR a.city ILIKE :q)';
}
return $sql;
}
/**
* Types demandes (?type=client,supplier), intersectes avec la whitelist.
* Defaut = tous les types resolubles. Un type inconnu -> 400 explicite.
*
* @param array<string, mixed> $filters
*
* @return list<string>
*/
private function extractTypes(array $filters): array
{
$raw = $filters['type'] ?? null;
if (null === $raw || '' === $raw) {
return array_keys(self::SOURCES);
}
$requested = is_array($raw) ? $raw : explode(',', (string) $raw);
$types = [];
foreach ($requested as $t) {
$t = trim((string) $t);
if ('' === $t) {
continue;
}
if (!isset(self::SOURCES[$t])) {
throw new BadRequestHttpException(sprintf(
'Filtre "type" invalide : "%s". Valeurs autorisees : %s.',
$t,
implode(', ', array_keys(self::SOURCES)),
));
}
$types[$t] = true;
}
return array_keys($types);
}
/**
* Parse ?bbox=minLng,minLat,maxLng,maxLat (format Leaflet
* getBounds().toBBoxString() = west,south,east,north). Absent -> null (pas de
* filtre geo). Malforme -> 400.
*
* @param array<string, mixed> $filters
*
* @return null|array{minLng: float, minLat: float, maxLng: float, maxLat: float}
*/
private function extractBbox(array $filters): ?array
{
$raw = $filters['bbox'] ?? null;
if (null === $raw || '' === $raw) {
return null;
}
$parts = explode(',', (string) $raw);
if (4 !== count($parts)) {
throw new BadRequestHttpException('Filtre "bbox" invalide : 4 valeurs attendues (minLng,minLat,maxLng,maxLat).');
}
foreach ($parts as $p) {
if (!is_numeric(trim($p))) {
throw new BadRequestHttpException('Filtre "bbox" invalide : coordonnees numeriques attendues.');
}
}
return [
'minLng' => (float) $parts[0],
'minLat' => (float) $parts[1],
'maxLng' => (float) $parts[2],
'maxLat' => (float) $parts[3],
];
}
/**
* @param array<string, mixed> $filters
*/
private function extractSearch(array $filters): ?string
{
$raw = $filters['q'] ?? null;
return is_string($raw) && '' !== trim($raw) ? trim($raw) : null;
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): VisitableTierOutput
{
$type = (string) $row['tier_type'];
$addressId = (int) $row['address_id'];
return new VisitableTierOutput(
id: sprintf('%s-%d', $type, $addressId),
tierType: $type,
tierId: (int) $row['tier_id'],
addressId: $addressId,
displayName: (string) $row['display_name'],
address: $this->formatAddress($row),
latitude: (float) $row['latitude'],
longitude: (float) $row['longitude'],
);
}
/**
* Adresse sur une ligne : « rue [complement], CP ville ».
*
* @param array<string, mixed> $row
*/
private function formatAddress(array $row): string
{
$street = trim(implode(' ', array_filter([
null !== $row['street'] ? (string) $row['street'] : null,
null !== $row['street_complement'] ? (string) $row['street_complement'] : null,
])));
$cityLine = trim(implode(' ', array_filter([
null !== $row['postal_code'] ? (string) $row['postal_code'] : null,
null !== $row['city'] ? (string) $row['city'] : null,
])));
return trim(implode(', ', array_filter([$street, $cityLine])));
}
}