feat(field_sales) : carte interactive Leaflet + écran de planification de tournée (ERP-127)
- 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:
@@ -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 {}
|
||||
+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\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);
|
||||
}
|
||||
}
|
||||
+322
@@ -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])));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user