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:
@@ -14,6 +14,9 @@ api_platform:
|
||||
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
||||
# Module FieldSales (M6) : entites ApiResource Tour / TourStop.
|
||||
- '%kernel.project_dir%/src/Module/FieldSales/Domain/Entity'
|
||||
# Module FieldSales (M6) : resources virtuelles sans entite Doctrine
|
||||
# (VisitableTierResource — pins de la carte, lecture DBAL).
|
||||
- '%kernel.project_dir%/src/Module/FieldSales/Infrastructure/ApiPlatform/Resource'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
|
||||
@@ -50,6 +50,102 @@
|
||||
"title": "Tableau de bord",
|
||||
"welcome": "Bienvenue sur Starseed"
|
||||
},
|
||||
"field_sales": {
|
||||
"tours": {
|
||||
"title": "Tournées",
|
||||
"add": "Nouvelle tournée",
|
||||
"empty": "Aucune tournée pour l'instant.",
|
||||
"column": {
|
||||
"label": "Nom",
|
||||
"date": "Date",
|
||||
"status": "Statut",
|
||||
"stops": "Étapes",
|
||||
"distance": "Distance",
|
||||
"duration": "Durée"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Brouillon",
|
||||
"planned": "Planifiée",
|
||||
"in_progress": "En cours",
|
||||
"done": "Terminée"
|
||||
},
|
||||
"new": {
|
||||
"title": "Nouvelle tournée",
|
||||
"label": "Nom de la tournée",
|
||||
"date": "Date",
|
||||
"create": "Créer la tournée",
|
||||
"cancel": "Annuler",
|
||||
"error": "Impossible de créer la tournée."
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Planification",
|
||||
"back": "Retour aux tournées",
|
||||
"panel": {
|
||||
"title": "Tournée",
|
||||
"label": "Nom de la tournée",
|
||||
"date": "Date",
|
||||
"departureTime": "Heure de départ",
|
||||
"startLabel": "Point de départ",
|
||||
"defaultVisitMinutes": "Durée de visite (min)",
|
||||
"stops": "Étapes",
|
||||
"noStops": "Aucune étape. Sélectionnez des Tiers sur la carte ou ajoutez un point libre.",
|
||||
"distance": "Distance",
|
||||
"duration": "Durée totale",
|
||||
"visits": "Visites"
|
||||
},
|
||||
"actions": {
|
||||
"compute": "Trajet logique",
|
||||
"optimize": "Optimiser",
|
||||
"duplicate": "Dupliquer",
|
||||
"pdf": "PDF",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"stop": {
|
||||
"eta": "Arrivée",
|
||||
"fromPrevious": "depuis l'étape précédente",
|
||||
"toGeolocate": "À géolocaliser",
|
||||
"goThere": "Y aller",
|
||||
"viewTier": "Voir le Tiers",
|
||||
"remove": "Supprimer l'étape",
|
||||
"waze": "Waze",
|
||||
"google": "Google Maps",
|
||||
"apple": "Plan (Apple)"
|
||||
},
|
||||
"custom": {
|
||||
"add": "Ajouter un point libre",
|
||||
"title": "Point libre",
|
||||
"label": "Libellé",
|
||||
"address": "Adresse",
|
||||
"confirm": "Ajouter le point",
|
||||
"cancel": "Annuler",
|
||||
"geocodeFailed": "Adresse introuvable — ajustez le pin sur la carte.",
|
||||
"hint": "Saisissez une adresse, elle sera géolocalisée automatiquement."
|
||||
},
|
||||
"map": {
|
||||
"typeClient": "Clients",
|
||||
"typeSupplier": "Fournisseurs",
|
||||
"search": "Rechercher un Tiers",
|
||||
"add": "Ajouter",
|
||||
"lassoHint": "Maintenez Maj et dessinez un rectangle pour sélectionner plusieurs Tiers."
|
||||
},
|
||||
"duplicateModal": {
|
||||
"title": "Dupliquer la tournée",
|
||||
"date": "Date de la nouvelle tournée",
|
||||
"confirm": "Dupliquer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"toast": {
|
||||
"computeError": "Le calcul du trajet a échoué.",
|
||||
"optimizeError": "L'optimisation a échoué.",
|
||||
"duplicateError": "La duplication a échoué.",
|
||||
"saveError": "L'enregistrement a échoué.",
|
||||
"loadError": "Impossible de charger la tournée.",
|
||||
"stopError": "L'opération sur l'étape a échoué.",
|
||||
"duplicated": "Tournée dupliquée."
|
||||
}
|
||||
}
|
||||
},
|
||||
"commercial": {
|
||||
"title": "Commercial",
|
||||
"welcome": "Module Commercial",
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<!-- Carte Leaflet (exception documentee a @malio/layer-ui : carte interactive,
|
||||
type non couvert par la lib — cf. frontend.md § Composants formulaires).
|
||||
TODO : migrer si la lib couvre un jour les cartes. -->
|
||||
<div class="relative h-full w-full">
|
||||
<div ref="mapEl" class="h-full w-full" data-testid="tour-map" />
|
||||
<!-- Aide a la selection rectangle (lasso facon Badger Maps). -->
|
||||
<div class="pointer-events-none absolute bottom-2 left-2 z-[400] rounded bg-white/90 px-2 py-1 text-xs text-gray-600 shadow">
|
||||
{{ t('field_sales.plan.map.lassoHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map as LeafletMap, Marker, Polyline, Rectangle } from 'leaflet'
|
||||
import type { PlanningStop } from '~/modules/field-sales/composables/useTourPlanning'
|
||||
import type { VisitableTier } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Carte interactive de planification de tournee (M6.5, spec § 6.1).
|
||||
*
|
||||
* - Charge les pins des Tiers geolocalises de la zone visible
|
||||
* (GET /api/visitable_tiers?bbox=...), colores par type (client/fournisseur),
|
||||
* filtrables (types + recherche). Recharge au deplacement/zoom (debounce).
|
||||
* - Popup au clic : nom, adresse, bouton « + Ajouter » (emet `add-tier`).
|
||||
* - Selection rectangle (Maj + glisser) : ajoute tous les Tiers entoures
|
||||
* (emet `add-tiers`).
|
||||
* - Trace la tournee par-dessus : polyline + marqueurs numerotes suivant l'ordre
|
||||
* des etapes geolocalisees.
|
||||
*
|
||||
* Instances Leaflet hors reactivite Vue (un proxy casse l'API Leaflet).
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Etapes geolocalisees a tracer (polyline numerotee). */
|
||||
stops: PlanningStop[]
|
||||
/** Types de pins affiches. */
|
||||
types: Array<'client' | 'supplier'>
|
||||
/** Recherche raison sociale / ville. */
|
||||
search: string
|
||||
/** Centre initial (defaut : Nantes). */
|
||||
center?: [number, number]
|
||||
}>(), {
|
||||
center: () => [47.218, -1.553],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Ajout d'un seul Tiers (popup « + Ajouter »). */
|
||||
'add-tier': [tier: VisitableTier]
|
||||
/** Ajout d'un lot de Tiers (selection rectangle). */
|
||||
'add-tiers': [tiers: VisitableTier[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// Instances Leaflet (hors reactivite).
|
||||
let L: typeof import('leaflet') | null = null
|
||||
let map: LeafletMap | null = null
|
||||
let pinLayer: Marker[] = []
|
||||
let pinTiers: Array<{ tier: VisitableTier, marker: Marker }> = []
|
||||
let routeLine: Polyline | null = null
|
||||
let stopMarkers: Marker[] = []
|
||||
let selectionRect: Rectangle | null = null
|
||||
|
||||
/** Zoom initial (niveau agglomeration). */
|
||||
const INITIAL_ZOOM = 12
|
||||
|
||||
/** Couleur du pin par type de Tiers. */
|
||||
const PIN_COLORS: Record<string, string> = {
|
||||
client: '#2563eb', // bleu
|
||||
supplier: '#16a34a', // vert
|
||||
}
|
||||
|
||||
/** Debounce du rechargement des pins au deplacement de la carte. */
|
||||
let fetchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function ensureMap(): Promise<void> {
|
||||
if (map !== null || mapEl.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const mod = await import('leaflet')
|
||||
L = mod.default ?? mod
|
||||
await import('leaflet/dist/leaflet.css')
|
||||
|
||||
if (mapEl.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
map = L.map(mapEl.value).setView(props.center, INITIAL_ZOOM)
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
|
||||
// Selection rectangle a la place du box-zoom natif (Maj + glisser).
|
||||
map.boxZoom.disable()
|
||||
map.on('mousedown', onMouseDown)
|
||||
|
||||
// Rechargement des pins quand la zone visible change.
|
||||
map.on('moveend', scheduleFetch)
|
||||
|
||||
drawRoute()
|
||||
await fetchPins()
|
||||
}
|
||||
|
||||
/** bbox de la zone visible au format Leaflet (minLng,minLat,maxLng,maxLat). */
|
||||
function currentBbox(): string | null {
|
||||
if (map === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return map.getBounds().toBBoxString()
|
||||
}
|
||||
|
||||
function scheduleFetch(): void {
|
||||
if (fetchTimer !== null) {
|
||||
clearTimeout(fetchTimer)
|
||||
}
|
||||
fetchTimer = setTimeout(() => {
|
||||
void fetchPins()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les pins de la zone visible. `?pagination=false` : la carte affiche
|
||||
* TOUS les pins de la bbox (le volume est borne par la zone, pas par la page).
|
||||
*/
|
||||
async function fetchPins(): Promise<void> {
|
||||
if (map === null || L === null) {
|
||||
return
|
||||
}
|
||||
if (props.types.length === 0) {
|
||||
clearPins()
|
||||
return
|
||||
}
|
||||
|
||||
const bbox = currentBbox()
|
||||
if (bbox === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {
|
||||
bbox,
|
||||
type: props.types.join(','),
|
||||
pagination: 'false',
|
||||
}
|
||||
if (props.search.trim() !== '') {
|
||||
query.q = props.search.trim()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get<{ member?: VisitableTier[] }>(
|
||||
'/visitable_tiers',
|
||||
query,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
renderPins(response.member ?? [])
|
||||
}
|
||||
catch {
|
||||
// Echec non bloquant : la carte reste utilisable, les pins ne se mettent
|
||||
// simplement pas a jour.
|
||||
}
|
||||
}
|
||||
|
||||
function clearPins(): void {
|
||||
pinLayer.forEach(m => m.remove())
|
||||
pinLayer = []
|
||||
pinTiers = []
|
||||
}
|
||||
|
||||
function renderPins(tiers: VisitableTier[]): void {
|
||||
if (map === null || L === null) {
|
||||
return
|
||||
}
|
||||
clearPins()
|
||||
|
||||
for (const tier of tiers) {
|
||||
const marker = L.marker([tier.latitude, tier.longitude], {
|
||||
icon: pinIcon(PIN_COLORS[tier.tierType] ?? '#6b7280'),
|
||||
}).addTo(map)
|
||||
|
||||
marker.bindPopup(popupHtml(tier))
|
||||
marker.on('popupopen', () => bindPopupButton(tier))
|
||||
|
||||
pinLayer.push(marker)
|
||||
pinTiers.push({ tier, marker })
|
||||
}
|
||||
}
|
||||
|
||||
/** divIcon SVG inline colore (evite les assets PNG Leaflet casses par Vite). */
|
||||
function pinIcon(color: string) {
|
||||
return L!.divIcon({
|
||||
className: '',
|
||||
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="26" height="38" fill="${color}" stroke="#ffffff" stroke-width="1"><path d="M12 0C7 0 3 4 3 9c0 6.6 9 15 9 15s9-8.4 9-15c0-5-4-9-9-9zm0 12.5A3.5 3.5 0 1 1 12 5.5a3.5 3.5 0 0 1 0 7z"/></svg>`,
|
||||
iconSize: [26, 38],
|
||||
iconAnchor: [13, 38],
|
||||
popupAnchor: [0, -34],
|
||||
})
|
||||
}
|
||||
|
||||
/** Contenu HTML du popup (le bouton est cable a l'ouverture, cf. bindPopupButton). */
|
||||
function popupHtml(tier: VisitableTier): string {
|
||||
const name = escapeHtml(tier.displayName)
|
||||
const address = escapeHtml(tier.address)
|
||||
|
||||
return `<div class="text-sm">
|
||||
<div class="font-semibold">${name}</div>
|
||||
<div class="text-gray-600">${address}</div>
|
||||
<button type="button" data-add-tier class="mt-2 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white">${t('field_sales.plan.map.add')}</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
/** Cable le bouton « + Ajouter » du popup ouvert vers l'emit `add-tier`. */
|
||||
function bindPopupButton(tier: VisitableTier): void {
|
||||
const el = map?.getContainer().querySelector('[data-add-tier]')
|
||||
el?.addEventListener('click', () => {
|
||||
emit('add-tier', tier)
|
||||
map?.closePopup()
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
// ── Selection rectangle (lasso) ─────────────────────────────────────────────
|
||||
let selectStart: import('leaflet').LatLng | null = null
|
||||
|
||||
function onMouseDown(e: import('leaflet').LeafletMouseEvent): void {
|
||||
if (map === null || L === null || !e.originalEvent.shiftKey) {
|
||||
return
|
||||
}
|
||||
// Empeche le drag de la carte pendant la selection.
|
||||
map.dragging.disable()
|
||||
selectStart = e.latlng
|
||||
selectionRect = L.rectangle(L.latLngBounds(e.latlng, e.latlng), {
|
||||
color: '#2563eb',
|
||||
weight: 1,
|
||||
fillOpacity: 0.1,
|
||||
}).addTo(map)
|
||||
|
||||
map.on('mousemove', onMouseMove)
|
||||
map.on('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
function onMouseMove(e: import('leaflet').LeafletMouseEvent): void {
|
||||
if (selectStart === null || selectionRect === null || L === null) {
|
||||
return
|
||||
}
|
||||
selectionRect.setBounds(L.latLngBounds(selectStart, e.latlng))
|
||||
}
|
||||
|
||||
function onMouseUp(): void {
|
||||
if (map === null) {
|
||||
return
|
||||
}
|
||||
const bounds = selectionRect?.getBounds() ?? null
|
||||
cleanupSelection()
|
||||
|
||||
if (bounds === null) {
|
||||
return
|
||||
}
|
||||
const selected = pinTiers
|
||||
.filter(({ marker }) => bounds.contains(marker.getLatLng()))
|
||||
.map(({ tier }) => tier)
|
||||
|
||||
if (selected.length > 0) {
|
||||
emit('add-tiers', selected)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSelection(): void {
|
||||
selectionRect?.remove()
|
||||
selectionRect = null
|
||||
selectStart = null
|
||||
map?.off('mousemove', onMouseMove)
|
||||
map?.off('mouseup', onMouseUp)
|
||||
map?.dragging.enable()
|
||||
}
|
||||
|
||||
// ── Trace de la tournee ──────────────────────────────────────────────────────
|
||||
function drawRoute(): void {
|
||||
if (map === null || L === null) {
|
||||
return
|
||||
}
|
||||
routeLine?.remove()
|
||||
routeLine = null
|
||||
stopMarkers.forEach(m => m.remove())
|
||||
stopMarkers = []
|
||||
|
||||
const located = props.stops.filter(s => s.latitude != null && s.longitude != null)
|
||||
const points = located.map(s => [s.latitude as number, s.longitude as number] as [number, number])
|
||||
|
||||
if (points.length >= 2) {
|
||||
routeLine = L.polyline(points, { color: '#1e40af', weight: 3, opacity: 0.7 }).addTo(map)
|
||||
}
|
||||
|
||||
located.forEach((stop, index) => {
|
||||
const marker = L!.marker([stop.latitude as number, stop.longitude as number], {
|
||||
icon: numberedIcon(index + 1),
|
||||
zIndexOffset: 1000,
|
||||
}).addTo(map!)
|
||||
marker.bindTooltip(stop.label, { direction: 'top' })
|
||||
stopMarkers.push(marker)
|
||||
})
|
||||
}
|
||||
|
||||
/** Pastille numerotee pour une etape de la tournee. */
|
||||
function numberedIcon(n: number) {
|
||||
return L!.divIcon({
|
||||
className: '',
|
||||
html: `<div style="display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:9999px;background:#1e40af;color:#fff;font-size:12px;font-weight:700;border:2px solid #fff;box-shadow:0 1px 2px rgba(0,0,0,.4)">${n}</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// Recharge les pins quand les filtres changent.
|
||||
watch(() => [props.types, props.search], scheduleFetch, { deep: true })
|
||||
|
||||
// Redessine le trace quand les etapes changent.
|
||||
watch(() => props.stops, drawRoute, { deep: true })
|
||||
|
||||
onMounted(ensureMap)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (fetchTimer !== null) {
|
||||
clearTimeout(fetchTimer)
|
||||
}
|
||||
map?.remove()
|
||||
map = null
|
||||
L = null
|
||||
pinLayer = []
|
||||
pinTiers = []
|
||||
stopMarkers = []
|
||||
routeLine = null
|
||||
selectionRect = null
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
/** Recentre la carte sur une cible (ex: depuis le panneau). */
|
||||
panTo(target: { latitude: number, longitude: number }) {
|
||||
map?.panTo([target.latitude, target.longitude])
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div>
|
||||
<p v-if="stops.length === 0" class="py-6 text-center text-sm text-gray-500" data-testid="stops-empty">
|
||||
{{ t('field_sales.plan.panel.noStops') }}
|
||||
</p>
|
||||
|
||||
<!-- Liste draggable (vuedraggable / SortableJS) : au drop, on emet le
|
||||
nouvel ordre. La poignee limite le drag a l'icone (le reste de la
|
||||
ligne reste cliquable). Etat 100 % local cote parent. -->
|
||||
<draggable
|
||||
v-else
|
||||
:model-value="stops"
|
||||
item-key="id"
|
||||
handle=".drag-handle"
|
||||
ghost-class="opacity-50"
|
||||
class="flex flex-col gap-2"
|
||||
@update:model-value="onReorder"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div
|
||||
class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2"
|
||||
:data-testid="`stop-${element.id}`"
|
||||
>
|
||||
<!-- Poignee de drag + numero d'ordre. -->
|
||||
<button
|
||||
type="button"
|
||||
class="drag-handle mt-0.5 flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-full bg-blue-800 text-xs font-bold text-white"
|
||||
:aria-label="t('field_sales.plan.panel.stops')"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</button>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-gray-900">{{ element.label }}</span>
|
||||
<span
|
||||
v-if="!isStopLocated(element)"
|
||||
class="shrink-0 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
|
||||
>
|
||||
{{ t('field_sales.plan.stop.toGeolocate') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="truncate text-xs text-gray-500">{{ element.displayAddress }}</p>
|
||||
|
||||
<!-- ETA + temps depuis l'etape precedente. -->
|
||||
<p v-if="isStopLocated(element)" class="mt-0.5 text-xs text-gray-600">
|
||||
<span class="font-medium">{{ t('field_sales.plan.stop.eta') }}</span>
|
||||
{{ formatTime(element.eta) }}
|
||||
<span v-if="index > 0" class="text-gray-400">
|
||||
· {{ formatDuration(element.legDurationS) }} / {{ formatDistance(element.legDistanceM) }}
|
||||
{{ t('field_sales.plan.stop.fromPrevious') }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<!-- Actions : Y aller (deep links) · Voir le Tiers. -->
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="font-medium text-blue-700 hover:underline disabled:text-gray-300"
|
||||
:disabled="navLinks(element) === null"
|
||||
@click="toggleMenu(element.id)"
|
||||
>
|
||||
{{ t('field_sales.plan.stop.goThere') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="openMenuId === element.id && navLinks(element) !== null"
|
||||
class="absolute z-10 mt-1 flex flex-col rounded border border-gray-200 bg-white py-1 shadow-lg"
|
||||
>
|
||||
<a :href="navLinks(element)!.waze" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.waze') }}</a>
|
||||
<a :href="navLinks(element)!.google" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.google') }}</a>
|
||||
<a :href="navLinks(element)!.apple" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.apple') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="element.tierType !== 'custom'"
|
||||
type="button"
|
||||
class="text-gray-600 hover:underline"
|
||||
@click="emit('view-tier', element)"
|
||||
>
|
||||
{{ t('field_sales.plan.stop.viewTier') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suppression de l'etape. -->
|
||||
<button
|
||||
type="button"
|
||||
class="mt-0.5 shrink-0 text-gray-400 hover:text-red-600"
|
||||
:aria-label="t('field_sales.plan.stop.remove')"
|
||||
@click="emit('remove', element)"
|
||||
>
|
||||
<Icon name="mdi:close" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import {
|
||||
buildNavigationLinks,
|
||||
isStopLocated,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
type PlanningStop,
|
||||
} from '~/modules/field-sales/composables/useTourPlanning'
|
||||
import type { NavigationLinks } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Liste ordonnee et draggable des etapes d'une tournee (panneau de
|
||||
* planification, M6.5). Le reordonnancement (drag & drop) emet le nouvel ordre ;
|
||||
* la persistance (POST /reorder) est a la charge de la page.
|
||||
*/
|
||||
defineProps<{
|
||||
stops: PlanningStop[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Nouvel ordre des etapes apres drop. */
|
||||
'reorder': [stops: PlanningStop[]]
|
||||
/** Retrait d'une etape. */
|
||||
'remove': [stop: PlanningStop]
|
||||
/** « Voir le Tiers » (etape sur Tiers referentiel). */
|
||||
'view-tier': [stop: PlanningStop]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Menu « Y aller » ouvert (id de l'etape) ou null. */
|
||||
const openMenuId = ref<number | null>(null)
|
||||
|
||||
function toggleMenu(id: number): void {
|
||||
openMenuId.value = openMenuId.value === id ? null : id
|
||||
}
|
||||
|
||||
function navLinks(stop: PlanningStop): NavigationLinks | null {
|
||||
return buildNavigationLinks(stop)
|
||||
}
|
||||
|
||||
function onReorder(next: PlanningStop[]): void {
|
||||
emit('reorder', next)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
reorderStops,
|
||||
computeTotals,
|
||||
buildNavigationLinks,
|
||||
isStopLocated,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
type PlanningStop,
|
||||
} from '../useTourPlanning'
|
||||
|
||||
/** Fabrique une etape de planification minimale pour les tests. */
|
||||
function makeStop(overrides: Partial<PlanningStop> = {}): PlanningStop {
|
||||
return {
|
||||
id: overrides.id ?? 1,
|
||||
tierType: overrides.tierType ?? 'client',
|
||||
tierId: overrides.tierId ?? null,
|
||||
addressId: overrides.addressId ?? null,
|
||||
customLabel: null,
|
||||
customAddress: null,
|
||||
customLatitude: null,
|
||||
customLongitude: null,
|
||||
position: overrides.position ?? 0,
|
||||
visitMinutes: overrides.visitMinutes ?? null,
|
||||
legDistanceM: overrides.legDistanceM ?? null,
|
||||
legDurationS: overrides.legDurationS ?? null,
|
||||
eta: overrides.eta ?? null,
|
||||
label: overrides.label ?? 'Étape',
|
||||
displayAddress: overrides.displayAddress ?? '',
|
||||
latitude: overrides.latitude ?? null,
|
||||
longitude: overrides.longitude ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('reorderStops', () => {
|
||||
it('deplace une etape et renumerote les positions de maniere contigue', () => {
|
||||
const stops = [
|
||||
makeStop({ id: 1, position: 0, label: 'A' }),
|
||||
makeStop({ id: 2, position: 1, label: 'B' }),
|
||||
makeStop({ id: 3, position: 2, label: 'C' }),
|
||||
]
|
||||
|
||||
// Deplace C (index 2) en tete (index 0).
|
||||
const result = reorderStops(stops, 2, 0)
|
||||
|
||||
expect(result.map(s => s.label)).toEqual(['C', 'A', 'B'])
|
||||
expect(result.map(s => s.position)).toEqual([0, 1, 2])
|
||||
})
|
||||
|
||||
it('ne mute pas le tableau source', () => {
|
||||
const stops = [makeStop({ id: 1, position: 0 }), makeStop({ id: 2, position: 1 })]
|
||||
reorderStops(stops, 0, 1)
|
||||
expect(stops.map(s => s.id)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('retourne une copie inchangee si un index est hors borne', () => {
|
||||
const stops = [makeStop({ id: 1, position: 0 })]
|
||||
const result = reorderStops(stops, 0, 5)
|
||||
expect(result.map(s => s.id)).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTotals', () => {
|
||||
it('somme distances/trajets et ajoute les visites (defaut + specifique)', () => {
|
||||
const stops = [
|
||||
// 1re etape : pas de leg (point de depart). Visite = defaut 30 min.
|
||||
makeStop({ id: 1, legDistanceM: null, legDurationS: null }),
|
||||
// 2e : 10 km / 12 min de trajet, visite specifique 15 min.
|
||||
makeStop({ id: 2, legDistanceM: 10_000, legDurationS: 720, visitMinutes: 15 }),
|
||||
// 3e : 5 km / 6 min, visite par defaut.
|
||||
makeStop({ id: 3, legDistanceM: 5_000, legDurationS: 360, visitMinutes: null }),
|
||||
]
|
||||
|
||||
const totals = computeTotals(stops, 30)
|
||||
|
||||
expect(totals.totalDistanceM).toBe(15_000)
|
||||
expect(totals.travelDurationS).toBe(1_080)
|
||||
// Visites : 30 + 15 + 30 = 75 min = 4500 s.
|
||||
expect(totals.visitDurationS).toBe(4_500)
|
||||
expect(totals.totalDurationS).toBe(1_080 + 4_500)
|
||||
expect(totals.visitCount).toBe(3)
|
||||
})
|
||||
|
||||
it('renvoie des totaux nuls pour une tournee vide', () => {
|
||||
const totals = computeTotals([], 30)
|
||||
expect(totals.totalDistanceM).toBe(0)
|
||||
expect(totals.totalDurationS).toBe(0)
|
||||
expect(totals.visitCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNavigationLinks', () => {
|
||||
it('construit les trois deep links Waze/Google/Apple', () => {
|
||||
const links = buildNavigationLinks({ latitude: 47.218, longitude: -1.553 })
|
||||
|
||||
expect(links).not.toBeNull()
|
||||
expect(links!.waze).toBe('https://waze.com/ul?ll=47.218,-1.553&navigate=yes')
|
||||
expect(links!.google).toBe('https://www.google.com/maps/dir/?api=1&destination=47.218,-1.553')
|
||||
expect(links!.apple).toBe('https://maps.apple.com/?daddr=47.218,-1.553')
|
||||
})
|
||||
|
||||
it('retourne null sans coordonnees (etape a geolocaliser)', () => {
|
||||
expect(buildNavigationLinks(null)).toBeNull()
|
||||
expect(buildNavigationLinks({ latitude: 47.2 })).toBeNull()
|
||||
expect(buildNavigationLinks({ latitude: null, longitude: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isStopLocated', () => {
|
||||
it('distingue une etape geolocalisee d\'une etape sans coordonnees', () => {
|
||||
expect(isStopLocated({ latitude: 47.2, longitude: -1.5 })).toBe(true)
|
||||
expect(isStopLocated({ latitude: null, longitude: null })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatteurs', () => {
|
||||
it('formate distances et durees', () => {
|
||||
expect(formatDistance(850)).toBe('850 m')
|
||||
expect(formatDistance(12_340)).toBe('12,3 km')
|
||||
expect(formatDistance(null)).toBe('—')
|
||||
|
||||
expect(formatDuration(1_500)).toBe('25 min')
|
||||
expect(formatDuration(5_100)).toBe('1 h 25')
|
||||
expect(formatDuration(null)).toBe('—')
|
||||
})
|
||||
|
||||
it('extrait l\'heure HH:MM d\'une chaine ISO', () => {
|
||||
expect(formatTime('1970-01-01T08:30:00+00:00')).toBe('08:30')
|
||||
expect(formatTime(null)).toBe('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { NavigationLinks, TierType, TourStop, TourTotals } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Composable de planification de tournee (M6.5).
|
||||
*
|
||||
* Porte la logique PURE de l'ecran de planification, isolee de Vue/Nuxt pour
|
||||
* etre testable directement (Vitest) :
|
||||
* - reordonnancement des etapes (drag & drop) + renumerotation des positions ;
|
||||
* - recalcul instantane des totaux (trajets + visites) pour le feedback UI,
|
||||
* avant le retour serveur du /compute ;
|
||||
* - construction des deep links de navigation « Y aller » (Waze/Google/Apple).
|
||||
*
|
||||
* Les coordonnees et libelles des etapes sur Tiers referentiel ne sont pas
|
||||
* portes par tour_stop:read : l'ecran les resout via GET /visitable_tiers/{id}
|
||||
* et alimente un `PlanningStop` enrichi, sur lequel operent ces fonctions.
|
||||
*/
|
||||
|
||||
/** Coordonnees WGS84 minimales d'une cible. */
|
||||
export interface LatLng {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Etape « enrichie » manipulee par l'ecran : l'etape API + le libelle, l'adresse
|
||||
* et les coordonnees resolus (depuis le Tiers pour une etape referentiel, depuis
|
||||
* les colonnes custom_* pour un point libre).
|
||||
*/
|
||||
export interface PlanningStop extends TourStop {
|
||||
/** Nom affichable (raison sociale du Tiers ou libelle du point libre). */
|
||||
label: string
|
||||
/** Adresse formatee sur une ligne. */
|
||||
displayAddress: string
|
||||
/** Coordonnees resolues, ou null si l'etape n'est pas geolocalisee (RG-6.05). */
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
}
|
||||
|
||||
/** Vitesse moyenne par defaut (km/h) — alignee sur HaversineRouteEngine (back). */
|
||||
const DEFAULT_SPEED_KMH = 50
|
||||
|
||||
/**
|
||||
* Deplace l'etape `fromIndex` vers `toIndex` et renumerote toutes les positions
|
||||
* (0-indexees, contigues). Retourne un NOUVEAU tableau (pas de mutation).
|
||||
*/
|
||||
export function reorderStops<T extends { position: number }>(stops: readonly T[], fromIndex: number, toIndex: number): T[] {
|
||||
const next = [...stops]
|
||||
if (fromIndex < 0 || fromIndex >= next.length || toIndex < 0 || toIndex >= next.length) {
|
||||
return next
|
||||
}
|
||||
const [moved] = next.splice(fromIndex, 1)
|
||||
if (moved === undefined) {
|
||||
return next
|
||||
}
|
||||
next.splice(toIndex, 0, moved)
|
||||
|
||||
return next.map((stop, index) => ({ ...stop, position: index }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalcule les totaux d'une tournee a partir des legs deja calcules et des
|
||||
* durees de visite (RG-6.11). Duree totale = trajets + visites.
|
||||
*/
|
||||
export function computeTotals(stops: readonly PlanningStop[], defaultVisitMinutes: number): TourTotals {
|
||||
let totalDistanceM = 0
|
||||
let travelDurationS = 0
|
||||
let visitDurationS = 0
|
||||
|
||||
for (const stop of stops) {
|
||||
totalDistanceM += stop.legDistanceM ?? 0
|
||||
travelDurationS += stop.legDurationS ?? 0
|
||||
visitDurationS += (stop.visitMinutes ?? defaultVisitMinutes) * 60
|
||||
}
|
||||
|
||||
return {
|
||||
totalDistanceM,
|
||||
travelDurationS,
|
||||
visitDurationS,
|
||||
totalDurationS: travelDurationS + visitDurationS,
|
||||
visitCount: stops.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep links de navigation vers une cible geolocalisee (spec M6 § 6.1).
|
||||
* Waze/Google Maps ne prennent qu'UNE destination -> navigation etape par etape
|
||||
* (HP-M6-7 assume). Retourne null si la cible n'a pas de coordonnees.
|
||||
*/
|
||||
export function buildNavigationLinks(target: { latitude?: number | null, longitude?: number | null } | null): NavigationLinks | null {
|
||||
if (target == null || target.latitude == null || target.longitude == null) {
|
||||
return null
|
||||
}
|
||||
const lat = target.latitude
|
||||
const lng = target.longitude
|
||||
|
||||
return {
|
||||
waze: `https://waze.com/ul?ll=${lat},${lng}&navigate=yes`,
|
||||
google: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`,
|
||||
apple: `https://maps.apple.com/?daddr=${lat},${lng}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Vrai si l'etape est geolocalisee (entre dans le calcul de trajet, RG-6.05). */
|
||||
export function isStopLocated(stop: Pick<PlanningStop, 'latitude' | 'longitude'>): boolean {
|
||||
return stop.latitude != null && stop.longitude != null
|
||||
}
|
||||
|
||||
/** Estime une duree de trajet (s) a partir d'une distance (m) et la vitesse moyenne. */
|
||||
export function estimateDurationSeconds(distanceMeters: number, speedKmh: number = DEFAULT_SPEED_KMH): number {
|
||||
if (speedKmh <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.round((distanceMeters / 1000) / speedKmh * 3600)
|
||||
}
|
||||
|
||||
/** Formate une distance (m) en « 12,3 km » ou « 850 m ». */
|
||||
export function formatDistance(meters: number | null): string {
|
||||
if (meters == null) {
|
||||
return '—'
|
||||
}
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
|
||||
return `${(meters / 1000).toFixed(1).replace('.', ',')} km`
|
||||
}
|
||||
|
||||
/** Formate une duree (s) en « 1 h 25 » ou « 25 min ». */
|
||||
export function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null) {
|
||||
return '—'
|
||||
}
|
||||
const totalMinutes = Math.round(seconds / 60)
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
if (hours === 0) {
|
||||
return `${minutes} min`
|
||||
}
|
||||
|
||||
return `${hours} h ${String(minutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Extrait l'heure « HH:MM » d'une chaine ISO (eta / departureTime). */
|
||||
export function formatTime(iso: string | null): string {
|
||||
if (iso == null || iso === '') {
|
||||
return '—'
|
||||
}
|
||||
const match = iso.match(/(\d{2}):(\d{2})/)
|
||||
|
||||
return match ? `${match[1]}:${match[2]}` : '—'
|
||||
}
|
||||
|
||||
/** Libelle FR court d'un type de Tiers (pour la couleur/le badge du pin). */
|
||||
export function tierTypeLabel(type: TierType): string {
|
||||
switch (type) {
|
||||
case 'client':
|
||||
return 'Client'
|
||||
case 'supplier':
|
||||
return 'Fournisseur'
|
||||
default:
|
||||
return 'Point libre'
|
||||
}
|
||||
}
|
||||
|
||||
export function useTourPlanning() {
|
||||
return {
|
||||
reorderStops,
|
||||
computeTotals,
|
||||
buildNavigationLinks,
|
||||
isStopLocated,
|
||||
estimateDurationSeconds,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
tierTypeLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
import type { Tour } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Liste paginee des tournees (GET /api/tours), branchee sur usePaginatedList
|
||||
* (regle ABSOLUE n°13 : toute collection est paginee). Tri par date decroissante
|
||||
* par defaut. Le filtre `owner` est applique cote back (RG-6.01) — rien a passer
|
||||
* ici.
|
||||
*/
|
||||
export function useToursRepository() {
|
||||
return usePaginatedList<Tour>({
|
||||
url: '/tours',
|
||||
defaultSort: { field: 'tourDate', direction: 'desc' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Layer Nuxt du module « Tournées » (field_sales, M6). Auto-detecte par le
|
||||
// shell via le scan de frontend/modules/*/. Config minimale : pages,
|
||||
// composants et composables sont decouverts par convention.
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,648 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Entete : retour + nom de la tournee. -->
|
||||
<div class="flex items-center gap-3 pt-6 pb-4">
|
||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="t('field_sales.plan.back')" @click="goBack" />
|
||||
<h1 class="truncate text-[24px] font-semibold text-primary-500">
|
||||
{{ tour?.label ?? t('field_sales.plan.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Layout split responsive : carte + panneau cote a cote en desktop,
|
||||
empile en mobile (carte au-dessus). Etat 100 % local. -->
|
||||
<div class="flex flex-col gap-4 lg:h-[calc(100vh-180px)] lg:flex-row">
|
||||
<!-- Carte interactive. -->
|
||||
<div class="relative h-[45vh] overflow-hidden rounded border border-gray-200 lg:h-auto lg:flex-1">
|
||||
<TourMap
|
||||
ref="mapRef"
|
||||
:stops="stops"
|
||||
:types="activeTypes"
|
||||
:search="mapSearch"
|
||||
:center="mapCenter"
|
||||
@add-tier="addTier"
|
||||
@add-tiers="addTiers"
|
||||
/>
|
||||
<!-- Filtres de la carte (types + recherche). -->
|
||||
<div class="absolute right-2 top-2 z-[400] flex flex-col gap-2 rounded bg-white/95 p-2 shadow">
|
||||
<MalioInputText
|
||||
v-model="mapSearch"
|
||||
:placeholder="t('field_sales.plan.map.search')"
|
||||
icon-name="mdi:magnify"
|
||||
:reserve-message-space="false"
|
||||
input-class="w-44"
|
||||
/>
|
||||
<div class="flex gap-3 text-xs">
|
||||
<label class="flex items-center gap-1">
|
||||
<input v-model="showClients" type="checkbox" class="accent-blue-600"> {{ t('field_sales.plan.map.typeClient') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<input v-model="showSuppliers" type="checkbox" class="accent-green-600"> {{ t('field_sales.plan.map.typeSupplier') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panneau tournee. -->
|
||||
<div class="flex flex-col gap-4 overflow-y-auto rounded border border-gray-200 p-4 lg:w-[420px]">
|
||||
<!-- Parametres de la tournee. -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<MalioInputText
|
||||
v-model="panel.label"
|
||||
:label="t('field_sales.plan.panel.label')"
|
||||
@update:model-value="debouncedSaveLabel"
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<MalioDate v-model="panel.tourDate" :label="t('field_sales.plan.panel.date')" class="flex-1" @update:model-value="saveDate" />
|
||||
<MalioTime v-model="panel.departureTime" :label="t('field_sales.plan.panel.departureTime')" class="flex-1" @update:model-value="saveDepartureTime" />
|
||||
</div>
|
||||
<MalioInputText
|
||||
v-model="panel.startLabel"
|
||||
:label="t('field_sales.plan.panel.startLabel')"
|
||||
@update:model-value="debouncedSaveStart"
|
||||
/>
|
||||
<MalioInputNumber
|
||||
v-model="panel.defaultVisitMinutes"
|
||||
:label="t('field_sales.plan.panel.defaultVisitMinutes')"
|
||||
:min="0"
|
||||
@update:model-value="debouncedSaveVisitMinutes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Totaux. -->
|
||||
<div class="grid grid-cols-3 gap-2 rounded bg-gray-50 p-3 text-center">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.distance') }}</p>
|
||||
<p class="font-semibold">{{ formatDistance(totals.totalDistanceM) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.duration') }}</p>
|
||||
<p class="font-semibold">{{ formatDuration(totals.totalDurationS) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.visits') }}</p>
|
||||
<p class="font-semibold">{{ totals.visitCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions tournee. -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<MalioButton variant="primary" :label="t('field_sales.plan.actions.compute')" :disabled="busy" @click="runCompute" />
|
||||
<MalioButton variant="secondary" :label="t('field_sales.plan.actions.optimize')" :disabled="busy" @click="runOptimize" />
|
||||
<MalioButton variant="tertiary" :label="t('field_sales.plan.actions.duplicate')" :disabled="busy" @click="duplicateOpen = true" />
|
||||
<MalioButton variant="tertiary" :label="t('field_sales.plan.actions.pdf')" @click="openPdf" />
|
||||
</div>
|
||||
|
||||
<!-- Etapes draggables. -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-800">{{ t('field_sales.plan.panel.stops') }}</h2>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('field_sales.plan.custom.add')"
|
||||
icon-name="mdi:map-marker-plus"
|
||||
icon-position="left"
|
||||
@click="customOpen = true"
|
||||
/>
|
||||
</div>
|
||||
<TourStopList
|
||||
:stops="stops"
|
||||
@reorder="onReorder"
|
||||
@remove="removeStop"
|
||||
@view-tier="viewTier"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modale : point libre custom. -->
|
||||
<MalioModal v-model="customOpen" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[22px] font-bold">{{ t('field_sales.plan.custom.title') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<MalioInputText v-model="customForm.label" :label="t('field_sales.plan.custom.label')" required :error="customErrors.label" />
|
||||
<MalioInputText v-model="customForm.address" :label="t('field_sales.plan.custom.address')" required :error="customErrors.address" :hint="t('field_sales.plan.custom.hint')" />
|
||||
<p v-if="customGeocodeFailed" class="text-xs text-red-600">{{ t('field_sales.plan.custom.geocodeFailed') }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<MalioButton variant="secondary" :label="t('field_sales.plan.custom.cancel')" button-class="flex-1" @click="customOpen = false" />
|
||||
<MalioButton variant="primary" :label="t('field_sales.plan.custom.confirm')" button-class="flex-1" :disabled="busy" @click="confirmCustom" />
|
||||
</template>
|
||||
</MalioModal>
|
||||
|
||||
<!-- Modale : duplication. -->
|
||||
<MalioModal v-model="duplicateOpen" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[22px] font-bold">{{ t('field_sales.plan.duplicateModal.title') }}</h2>
|
||||
</template>
|
||||
<MalioDate v-model="duplicateDate" :label="t('field_sales.plan.duplicateModal.date')" required :error="duplicateError" />
|
||||
<template #footer>
|
||||
<MalioButton variant="secondary" :label="t('field_sales.plan.duplicateModal.cancel')" button-class="flex-1" @click="duplicateOpen = false" />
|
||||
<MalioButton variant="primary" :label="t('field_sales.plan.duplicateModal.confirm')" button-class="flex-1" :disabled="busy" @click="confirmDuplicate" />
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import TourMap from '~/modules/field-sales/components/TourMap.vue'
|
||||
import TourStopList from '~/modules/field-sales/components/TourStopList.vue'
|
||||
import {
|
||||
computeTotals,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
type PlanningStop,
|
||||
} from '~/modules/field-sales/composables/useTourPlanning'
|
||||
import { useAddressAutocomplete } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { Tour, TourStop, VisitableTier } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Ecran de planification d'une tournee (M6.5, spec § 6.1).
|
||||
*
|
||||
* Carte interactive (pins + lasso + trace) a gauche, panneau (parametres,
|
||||
* totaux, actions, etapes draggables) a droite ; empile en mobile. Etat 100 %
|
||||
* LOCAL (jamais dans l'URL, regle ABSOLUE n°6).
|
||||
*
|
||||
* Les etapes sur Tiers referentiel ne portent pas leurs coordonnees/nom dans
|
||||
* tour_stop:read : on les resout via GET /visitable_tiers/{type}-{addressId}
|
||||
* (cache local) pour alimenter des `PlanningStop` enrichis.
|
||||
*/
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const tourId = computed(() => Number(route.params.id))
|
||||
|
||||
const tour = ref<Tour | null>(null)
|
||||
const stops = ref<PlanningStop[]>([])
|
||||
const busy = ref(false)
|
||||
const mapRef = ref<InstanceType<typeof TourMap> | null>(null)
|
||||
|
||||
useHead({ title: () => tour.value?.label ?? t('field_sales.plan.title') })
|
||||
|
||||
// Cache des infos Tiers (nom/adresse/coords) par cle « type-addressId » : evite
|
||||
// de refetcher /visitable_tiers/{id} a chaque recompute.
|
||||
const tierCache = new Map<string, { label: string, displayAddress: string, latitude: number, longitude: number }>()
|
||||
|
||||
// ── Panneau (formulaire local synchronise avec la tournee) ──────────────────
|
||||
// defaultVisitMinutes est une chaine (MalioInputNumber est un v-model string).
|
||||
const panel = reactive<{
|
||||
label: string
|
||||
tourDate: string | null
|
||||
departureTime: string | null
|
||||
startLabel: string
|
||||
defaultVisitMinutes: string
|
||||
}>({
|
||||
label: '',
|
||||
tourDate: null,
|
||||
departureTime: null,
|
||||
startLabel: '',
|
||||
defaultVisitMinutes: '30',
|
||||
})
|
||||
|
||||
/** Debounce simple (les saves lisent l'etat `panel`, donc sans argument). */
|
||||
function debounce(fn: () => void, ms: number): () => void {
|
||||
let handle: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return () => {
|
||||
if (handle !== null) {
|
||||
clearTimeout(handle)
|
||||
}
|
||||
handle = setTimeout(fn, ms)
|
||||
}
|
||||
}
|
||||
const debouncedSaveLabel = debounce(() => { void saveLabel() }, 600)
|
||||
const debouncedSaveStart = debounce(() => { void saveStart() }, 800)
|
||||
const debouncedSaveVisitMinutes = debounce(() => { void saveVisitMinutes() }, 600)
|
||||
|
||||
// ── Carte : filtres ──────────────────────────────────────────────────────────
|
||||
const showClients = ref(true)
|
||||
const showSuppliers = ref(true)
|
||||
const mapSearch = ref('')
|
||||
const activeTypes = computed<Array<'client' | 'supplier'>>(() => {
|
||||
const types: Array<'client' | 'supplier'> = []
|
||||
if (showClients.value) {
|
||||
types.push('client')
|
||||
}
|
||||
if (showSuppliers.value) {
|
||||
types.push('supplier')
|
||||
}
|
||||
|
||||
return types
|
||||
})
|
||||
const mapCenter = ref<[number, number]>([47.218, -1.553])
|
||||
|
||||
// ── Totaux recalcules localement (feedback instantane) ──────────────────────
|
||||
const totals = computed(() => computeTotals(stops.value, Number(panel.defaultVisitMinutes) || 0))
|
||||
|
||||
// ── Modales ──────────────────────────────────────────────────────────────────
|
||||
const customOpen = ref(false)
|
||||
const customForm = reactive({ label: '', address: '' })
|
||||
const customErrors = reactive<{ label: string, address: string }>({ label: '', address: '' })
|
||||
const customGeocodeFailed = ref(false)
|
||||
|
||||
const duplicateOpen = ref(false)
|
||||
const duplicateDate = ref<string | null>(null)
|
||||
const duplicateError = ref('')
|
||||
|
||||
// =============================================================================
|
||||
// Chargement + enrichissement
|
||||
// =============================================================================
|
||||
onMounted(loadTour)
|
||||
|
||||
async function loadTour(): Promise<void> {
|
||||
try {
|
||||
const raw = await api.get<Tour>(`/tours/${tourId.value}`, {}, {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
await applyTour(raw, true)
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.loadError') })
|
||||
router.push('/tours')
|
||||
}
|
||||
}
|
||||
|
||||
/** Applique une reponse Tour au state local. `withStops` re-enrichit les etapes. */
|
||||
async function applyTour(raw: Tour, withStops: boolean): Promise<void> {
|
||||
tour.value = raw
|
||||
panel.label = raw.label
|
||||
panel.tourDate = raw.tourDate ? raw.tourDate.slice(0, 10) : null
|
||||
panel.departureTime = extractTime(raw.departureTime)
|
||||
panel.startLabel = raw.startLabel ?? ''
|
||||
panel.defaultVisitMinutes = String(raw.defaultVisitMinutes)
|
||||
|
||||
if (withStops) {
|
||||
stops.value = await enrichStops(raw.stops ?? [])
|
||||
recenterOnFirstStop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Resout nom/adresse/coords de chaque etape en `PlanningStop`. */
|
||||
async function enrichStops(rawStops: TourStop[]): Promise<PlanningStop[]> {
|
||||
const ordered = [...rawStops].sort((a, b) => a.position - b.position)
|
||||
|
||||
return Promise.all(ordered.map(async (stop): Promise<PlanningStop> => {
|
||||
if (stop.tierType === 'custom') {
|
||||
return {
|
||||
...stop,
|
||||
label: stop.customLabel ?? '',
|
||||
displayAddress: stop.customAddress ?? '',
|
||||
latitude: stop.customLatitude != null ? Number(stop.customLatitude) : null,
|
||||
longitude: stop.customLongitude != null ? Number(stop.customLongitude) : null,
|
||||
}
|
||||
}
|
||||
|
||||
const info = await resolveTier(stop.tierType, stop.addressId)
|
||||
|
||||
return {
|
||||
...stop,
|
||||
label: info?.label ?? `#${stop.tierId}`,
|
||||
displayAddress: info?.displayAddress ?? '',
|
||||
latitude: info?.latitude ?? null,
|
||||
longitude: info?.longitude ?? null,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/** Infos d'un Tiers (cache + GET /visitable_tiers/{type-addressId}). */
|
||||
async function resolveTier(tierType: string, addressId: number | null) {
|
||||
if (addressId === null) {
|
||||
return null
|
||||
}
|
||||
const key = `${tierType}-${addressId}`
|
||||
const cached = tierCache.get(key)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
try {
|
||||
const tier = await api.get<VisitableTier>(`/visitable_tiers/${key}`, {}, {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
const info = {
|
||||
label: tier.displayName,
|
||||
displayAddress: tier.address,
|
||||
latitude: tier.latitude,
|
||||
longitude: tier.longitude,
|
||||
}
|
||||
tierCache.set(key, info)
|
||||
|
||||
return info
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function recenterOnFirstStop(): void {
|
||||
const located = stops.value.find(s => s.latitude != null && s.longitude != null)
|
||||
if (located) {
|
||||
mapCenter.value = [located.latitude as number, located.longitude as number]
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Ajout d'etapes (carte)
|
||||
// =============================================================================
|
||||
async function addTier(tier: VisitableTier): Promise<void> {
|
||||
// Pre-alimente le cache (la carte connait deja nom/adresse/coords du pin).
|
||||
tierCache.set(`${tier.tierType}-${tier.addressId}`, {
|
||||
label: tier.displayName,
|
||||
displayAddress: tier.address,
|
||||
latitude: tier.latitude,
|
||||
longitude: tier.longitude,
|
||||
})
|
||||
await postStop({
|
||||
tierType: tier.tierType,
|
||||
tierId: tier.tierId,
|
||||
addressId: tier.addressId,
|
||||
position: stops.value.length,
|
||||
})
|
||||
}
|
||||
|
||||
async function addTiers(tiers: VisitableTier[]): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
let position = stops.value.length
|
||||
for (const tier of tiers) {
|
||||
tierCache.set(`${tier.tierType}-${tier.addressId}`, {
|
||||
label: tier.displayName,
|
||||
displayAddress: tier.address,
|
||||
latitude: tier.latitude,
|
||||
longitude: tier.longitude,
|
||||
})
|
||||
await api.post('/tours/' + tourId.value + '/stops', {
|
||||
tierType: tier.tierType,
|
||||
tierId: tier.tierId,
|
||||
addressId: tier.addressId,
|
||||
position: position++,
|
||||
}, { toast: false })
|
||||
}
|
||||
await runCompute()
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** POST d'une etape puis recompute (factorise add tier / custom). */
|
||||
async function postStop(payload: Record<string, unknown>): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await api.post('/tours/' + tourId.value + '/stops', payload, { toast: false })
|
||||
await runCompute()
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCustom(): Promise<void> {
|
||||
customErrors.label = customForm.label.trim() === '' ? t('field_sales.plan.custom.label') : ''
|
||||
customErrors.address = customForm.address.trim() === '' ? t('field_sales.plan.custom.address') : ''
|
||||
if (customErrors.label || customErrors.address) {
|
||||
return
|
||||
}
|
||||
customGeocodeFailed.value = false
|
||||
|
||||
// Geocodage de l'adresse libre (BAN). Echec -> point non geolocalise (exclu
|
||||
// du calcul, RG-6.05) mais l'etape est tout de meme creee.
|
||||
let coords: { latitude: string, longitude: string } | null = null
|
||||
try {
|
||||
coords = await autocomplete.geocode(customForm.address.trim())
|
||||
}
|
||||
catch {
|
||||
coords = null
|
||||
}
|
||||
if (coords === null) {
|
||||
customGeocodeFailed.value = true
|
||||
}
|
||||
|
||||
await postStop({
|
||||
tierType: 'custom',
|
||||
customLabel: customForm.label.trim(),
|
||||
customAddress: customForm.address.trim(),
|
||||
customLatitude: coords?.latitude ?? null,
|
||||
customLongitude: coords?.longitude ?? null,
|
||||
position: stops.value.length,
|
||||
})
|
||||
|
||||
if (!customGeocodeFailed.value) {
|
||||
customOpen.value = false
|
||||
customForm.label = ''
|
||||
customForm.address = ''
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Reordonnancement / suppression / navigation
|
||||
// =============================================================================
|
||||
async function onReorder(next: PlanningStop[]): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
// Optimisme : on reflete l'ordre immediatement, le serveur recalcule ensuite.
|
||||
stops.value = next.map((s, i) => ({ ...s, position: i }))
|
||||
busy.value = true
|
||||
try {
|
||||
const raw = await api.post<Tour>('/tours/' + tourId.value + '/reorder', {
|
||||
stopIds: next.map(s => s.id),
|
||||
}, { headers: { Accept: 'application/ld+json' }, toast: false })
|
||||
await applyTour(raw, true)
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
|
||||
await loadTour()
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStop(stop: PlanningStop): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await api.delete(`/tour_stops/${stop.id}`, {}, { toast: false })
|
||||
await runCompute()
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function viewTier(stop: PlanningStop): void {
|
||||
if (stop.tierId === null) {
|
||||
return
|
||||
}
|
||||
router.push(stop.tierType === 'supplier' ? `/suppliers/${stop.tierId}` : `/clients/${stop.tierId}`)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Actions tournee : compute / optimize / duplicate / pdf
|
||||
// =============================================================================
|
||||
async function runCompute(): Promise<void> {
|
||||
await runTourAction('/compute', 'computeError')
|
||||
}
|
||||
|
||||
async function runOptimize(): Promise<void> {
|
||||
await runTourAction('/optimize', 'optimizeError')
|
||||
}
|
||||
|
||||
/** Factorise compute/optimize : POST sans corps -> reapplique la tournee. */
|
||||
async function runTourAction(path: string, errorKey: string): Promise<void> {
|
||||
const wasBusy = busy.value
|
||||
busy.value = true
|
||||
try {
|
||||
const raw = await api.post<Tour>('/tours/' + tourId.value + path, {}, {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
await applyTour(raw, true)
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t(`field_sales.plan.toast.${errorKey}`) })
|
||||
}
|
||||
finally {
|
||||
busy.value = wasBusy ? busy.value : false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDuplicate(): Promise<void> {
|
||||
duplicateError.value = ''
|
||||
if (duplicateDate.value === null || duplicateDate.value === '') {
|
||||
duplicateError.value = t('field_sales.plan.duplicateModal.date')
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const copy = await api.post<Tour>('/tours/' + tourId.value + '/duplicate', {
|
||||
tourDate: duplicateDate.value,
|
||||
}, { headers: { Accept: 'application/ld+json' }, toast: false })
|
||||
toast.success({ title: t('field_sales.tours.title'), message: t('field_sales.plan.toast.duplicated') })
|
||||
duplicateOpen.value = false
|
||||
router.push(`/tours/${copy.id}/plan`)
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.duplicateError') })
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Ouvre la feuille de route PDF (le cookie JWT est envoye avec la requete). */
|
||||
function openPdf(): void {
|
||||
window.open(`/api/tours/${tourId.value}/roadbook.pdf`, '_blank')
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sauvegarde des parametres du panneau (PATCH, recompute si ETA impactee)
|
||||
// =============================================================================
|
||||
async function saveLabel(): Promise<void> {
|
||||
if (panel.label.trim() !== '' && panel.label !== tour.value?.label) {
|
||||
await patchTour({ label: panel.label.trim() }, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDate(): Promise<void> {
|
||||
if (panel.tourDate) {
|
||||
await patchTour({ tourDate: panel.tourDate }, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDepartureTime(): Promise<void> {
|
||||
if (panel.departureTime) {
|
||||
await patchTour({ departureTime: panel.departureTime }, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVisitMinutes(): Promise<void> {
|
||||
const minutes = Number(panel.defaultVisitMinutes)
|
||||
if (!Number.isFinite(minutes) || minutes < 0) {
|
||||
return
|
||||
}
|
||||
await patchTour({ defaultVisitMinutes: minutes }, true)
|
||||
}
|
||||
|
||||
/** Geocode le point de depart (ou le vide) puis recompute. */
|
||||
async function saveStart(): Promise<void> {
|
||||
const label = panel.startLabel.trim()
|
||||
if (label === '' && (tour.value?.startLabel ?? '') === '') {
|
||||
return
|
||||
}
|
||||
let coords: { latitude: string, longitude: string } | null = null
|
||||
if (label !== '') {
|
||||
try {
|
||||
coords = await autocomplete.geocode(label)
|
||||
}
|
||||
catch {
|
||||
coords = null
|
||||
}
|
||||
}
|
||||
await patchTour({
|
||||
startLabel: label === '' ? null : label,
|
||||
startLatitude: coords?.latitude ?? null,
|
||||
startLongitude: coords?.longitude ?? null,
|
||||
}, true)
|
||||
}
|
||||
|
||||
/** PATCH /tours/{id}. `recompute` enchaine /compute (ETA impactee). */
|
||||
async function patchTour(partial: Record<string, unknown>, recompute: boolean): Promise<void> {
|
||||
busy.value = true
|
||||
try {
|
||||
const raw = await api.patch<Tour>(`/tours/${tourId.value}`, partial, {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
// PATCH renvoie tour:read SANS etapes : on ne touche pas a `stops`.
|
||||
await applyTour(raw, false)
|
||||
if (recompute) {
|
||||
await runCompute()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.saveError') })
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utilitaires
|
||||
// =============================================================================
|
||||
function extractTime(iso: string | null): string | null {
|
||||
const formatted = formatTime(iso)
|
||||
|
||||
return formatted === '—' ? null : formatted
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
router.push('/tours')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('field_sales.tours.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('field_sales.tours.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="t('field_sales.tours.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-tourDate="{ item }">
|
||||
{{ formatDate(item.tourDate as string) }}
|
||||
</template>
|
||||
<template #cell-status="{ item }">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium" :class="statusClass(item.status as TourStatus)">
|
||||
{{ t(`field_sales.tours.status.${item.status}`) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-distance="{ item }">
|
||||
{{ formatDistance((item.totalDistanceM as number | null) ?? null) }}
|
||||
</template>
|
||||
<template #cell-duration="{ item }">
|
||||
{{ formatDuration((item.totalDurationS as number | null) ?? null) }}
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useToursRepository } from '~/modules/field-sales/composables/useToursRepository'
|
||||
import { formatDistance, formatDuration } from '~/modules/field-sales/composables/useTourPlanning'
|
||||
import type { Tour, TourStatus } from '~/modules/field-sales/types/tour'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('field_sales.tours.title') })
|
||||
|
||||
const canManage = computed(() => can('field_sales.tours.manage'))
|
||||
|
||||
const {
|
||||
items: tours,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadTours,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = useToursRepository()
|
||||
|
||||
const rows = computed(() => tours.value.map(tour => ({
|
||||
id: tour.id,
|
||||
label: tour.label,
|
||||
tourDate: tour.tourDate,
|
||||
status: tour.status,
|
||||
totalDistanceM: tour.totalDistanceM,
|
||||
totalDurationS: tour.totalDurationS,
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: t('field_sales.tours.column.label') },
|
||||
{ key: 'tourDate', label: t('field_sales.tours.column.date') },
|
||||
{ key: 'status', label: t('field_sales.tours.column.status') },
|
||||
{ key: 'distance', label: t('field_sales.tours.column.distance') },
|
||||
{ key: 'duration', label: t('field_sales.tours.column.duration') },
|
||||
]
|
||||
|
||||
/** Couleur du badge de statut. */
|
||||
function statusClass(status: TourStatus): string {
|
||||
switch (status) {
|
||||
case 'planned':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'in_progress':
|
||||
return 'bg-amber-100 text-amber-800'
|
||||
case 'done':
|
||||
return 'bg-green-100 text-green-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
/** Date courte FR (la date arrive en ISO depuis l'API). */
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(iso)
|
||||
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString('fr-FR')
|
||||
}
|
||||
|
||||
/** Clic ligne → ecran de planification de la tournee. */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/tours/${(item as { id: Tour['id'] }).id}/plan`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/tours/new')
|
||||
}
|
||||
|
||||
onMounted(loadTours)
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('field_sales.tours.new.title') }}
|
||||
</PageHeader>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
:label="t('field_sales.tours.new.label')"
|
||||
required
|
||||
:error="errors.label"
|
||||
/>
|
||||
<MalioDate
|
||||
v-model="form.tourDate"
|
||||
:label="t('field_sales.tours.new.date')"
|
||||
required
|
||||
:error="errors.tourDate"
|
||||
/>
|
||||
|
||||
<div class="mt-2 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
:label="t('field_sales.tours.new.cancel')"
|
||||
type="button"
|
||||
@click="cancel"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('field_sales.tours.new.create')"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import type { Tour } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Creation d'une tournee (draft). Formulaire minimal (nom + date) : le reste de
|
||||
* la planification (etapes, point de depart, heure) se fait sur l'ecran de
|
||||
* planification une fois la tournee creee. Validation inline 422 via useFormErrors.
|
||||
*/
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||
|
||||
useHead({ title: t('field_sales.tours.new.title') })
|
||||
|
||||
// Garde-fou : sans manage, on renvoie vers la liste (le back refuse de toute facon).
|
||||
if (!can('field_sales.tours.manage')) {
|
||||
router.replace('/tours')
|
||||
}
|
||||
|
||||
const form = reactive<{ label: string, tourDate: string | null }>({
|
||||
label: '',
|
||||
tourDate: null,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
clearErrors()
|
||||
submitting.value = true
|
||||
try {
|
||||
const tour = await api.post<Tour>('/tours', {
|
||||
label: form.label,
|
||||
tourDate: form.tourDate,
|
||||
}, { toast: false })
|
||||
|
||||
// Enchaine directement sur la planification de la tournee creee.
|
||||
router.push(`/tours/${tour.id}/plan`)
|
||||
}
|
||||
catch (e) {
|
||||
handleApiError(e, { fallbackMessage: t('field_sales.tours.new.error') })
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
router.push('/tours')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Types du module « Tournées » (field_sales, M6.5).
|
||||
*
|
||||
* Reflet des DTO exposes par l'API (groupes `tour:read` / `tour_stop:read` /
|
||||
* VisitableTier). Les dates/heures arrivent en chaines ISO 8601 ; le formatage
|
||||
* d'affichage (HH:MM, jj/mm/aaaa) est fait dans les ecrans.
|
||||
*/
|
||||
|
||||
/** Type de Tiers visitable cote front (aligne sur l'enum ouvert du back). */
|
||||
export type TierType = 'client' | 'supplier' | 'custom'
|
||||
|
||||
/** Cycle de vie d'une tournee (RG-6.02). */
|
||||
export type TourStatus = 'draft' | 'planned' | 'in_progress' | 'done'
|
||||
|
||||
/** Une etape de tournee (tour_stop:read). */
|
||||
export interface TourStop {
|
||||
id: number
|
||||
tierType: TierType
|
||||
tierId: number | null
|
||||
addressId: number | null
|
||||
customLabel: string | null
|
||||
customAddress: string | null
|
||||
customLatitude: string | null
|
||||
customLongitude: string | null
|
||||
position: number
|
||||
visitMinutes: number | null
|
||||
/** Distance depuis l'etape precedente (m), calculee (compute). */
|
||||
legDistanceM: number | null
|
||||
/** Duree de trajet depuis l'etape precedente (s), calculee. */
|
||||
legDurationS: number | null
|
||||
/** Heure d'arrivee estimee (ISO time), calculee (RG-6.11). */
|
||||
eta: string | null
|
||||
}
|
||||
|
||||
/** Une tournee (tour:read + stops embarquees en tour:item:read). */
|
||||
export interface Tour {
|
||||
id: number
|
||||
label: string
|
||||
/** Date de realisation (ISO date). */
|
||||
tourDate: string
|
||||
/** Heure de depart (ISO time). */
|
||||
departureTime: string
|
||||
startLatitude: string | null
|
||||
startLongitude: string | null
|
||||
startLabel: string | null
|
||||
defaultVisitMinutes: number
|
||||
status: TourStatus
|
||||
totalDistanceM: number | null
|
||||
totalDurationS: number | null
|
||||
stops?: TourStop[]
|
||||
}
|
||||
|
||||
/** Un pin de la carte = une adresse geolocalisee d'un Tiers (VisitableTier). */
|
||||
export interface VisitableTier {
|
||||
id: string
|
||||
tierType: Exclude<TierType, 'custom'>
|
||||
tierId: number
|
||||
addressId: number
|
||||
displayName: string
|
||||
address: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
/** Liens d'ouverture de navigation externe (« Y aller »). */
|
||||
export interface NavigationLinks {
|
||||
waze: string
|
||||
google: string
|
||||
apple: string
|
||||
}
|
||||
|
||||
/** Totaux d'une tournee recalcules cote front (feedback instantane). */
|
||||
export interface TourTotals {
|
||||
/** Distance cumulee des trajets (m). */
|
||||
totalDistanceM: number
|
||||
/** Duree totale = trajets + visites (s). */
|
||||
totalDurationS: number
|
||||
/** Duree de trajet seule (s). */
|
||||
travelDurationS: number
|
||||
/** Duree de visite cumulee (s). */
|
||||
visitDurationS: number
|
||||
/** Nombre de visites (etapes). */
|
||||
visitCount: number
|
||||
}
|
||||
Generated
+20
-1
@@ -18,7 +18,8 @@
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
@@ -15066,6 +15067,12 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sortablejs": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -17569,6 +17576,18 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vuedraggable": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sortablejs": "1.14.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
|
||||
@@ -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])));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ namespace App\Tests\Module\FieldSales\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
@@ -29,6 +31,7 @@ use DateTimeImmutable;
|
||||
abstract class AbstractFieldSalesApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string TEST_CLIENT_PREFIX = 'TEST_FS_CLIENT_';
|
||||
protected const string TEST_SUPPLIER_PREFIX = 'TEST_FS_SUPPLIER_';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@@ -73,6 +76,40 @@ abstract class AbstractFieldSalesApiTestCase extends AbstractApiTestCase
|
||||
return $address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un Fournisseur minimal (companyName uniquement).
|
||||
*/
|
||||
protected function seedSupplier(string $companyName): Supplier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName(self::TEST_SUPPLIER_PREFIX.mb_strtoupper($companyName, 'UTF-8'));
|
||||
$em->persist($supplier);
|
||||
$em->flush();
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede une adresse geolocalisee rattachee a $supplier (type PROSPECT).
|
||||
*/
|
||||
protected function seedSupplierAddress(Supplier $supplier, float $lat = 47.218, float $lng = -1.553): SupplierAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$address = new SupplierAddress();
|
||||
$address->setSupplier($supplier);
|
||||
$address->setAddressType('PROSPECT');
|
||||
$address->setPostalCode('44000');
|
||||
$address->setCity('NANTES');
|
||||
$address->setStreet('2 rue de Test');
|
||||
$address->setLatitude($lat);
|
||||
$address->setLongitude($lng);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede une tournee appartenant a $owner (sans passer par l'API).
|
||||
*/
|
||||
@@ -135,6 +172,16 @@ abstract class AbstractFieldSalesApiTestCase extends AbstractApiTestCase
|
||||
'DELETE FROM '.Client::class.' c WHERE c.companyName LIKE :prefix',
|
||||
)->setParameter('prefix', self::TEST_CLIENT_PREFIX.'%')->execute();
|
||||
|
||||
// Adresses puis fournisseurs de test (FK supplier_address.supplier_id CASCADE).
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.SupplierAddress::class.' a WHERE a.supplier IN ('
|
||||
.'SELECT s.id FROM '.Supplier::class.' s WHERE s.companyName LIKE :prefix)',
|
||||
)->setParameter('prefix', self::TEST_SUPPLIER_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Supplier::class.' s WHERE s.companyName LIKE :prefix',
|
||||
)->setParameter('prefix', self::TEST_SUPPLIER_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix',
|
||||
)->setParameter('prefix', 'testuser_%')->execute();
|
||||
|
||||
@@ -146,6 +146,49 @@ final class TourRouteApiTest extends AbstractFieldSalesApiTestCase
|
||||
self::assertArrayHasKey('tourDate', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* /reorder applique l'ordre fourni (drag & drop) en renumerotant les
|
||||
* positions sans heurter l'unique (tour_id, position), puis recompute.
|
||||
*/
|
||||
public function testReorderAppliesGivenOrderAndRecomputes(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
|
||||
$a = $this->seedCustomStop($tour, 0, 47.0, -1.0, 'A');
|
||||
$b = $this->seedCustomStop($tour, 1, 47.1, -1.0, 'B');
|
||||
$c = $this->seedCustomStop($tour, 2, 47.2, -1.0, 'C');
|
||||
|
||||
// Ordre inverse demande : C, B, A.
|
||||
$body = $client->request('POST', '/api/tours/'.$tour->getId().'/reorder', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['stopIds' => [$c->getId(), $b->getId(), $a->getId()]],
|
||||
])->toArray();
|
||||
|
||||
$stops = $this->stopsByPosition($body);
|
||||
self::assertSame([0, 1, 2], array_map(static fn (array $s) => $s['position'], $stops));
|
||||
self::assertSame(['C', 'B', 'A'], array_map(static fn (array $s) => $s['customLabel'], $stops));
|
||||
// Recompute applique : la 1re etape est le depart (leg nul).
|
||||
self::assertSame(0, $stops[0]['legDistanceM']);
|
||||
self::assertNotNull($body['totalDistanceM']);
|
||||
}
|
||||
|
||||
public function testReorderRequiresStopIds(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
|
||||
$response = $client->request('POST', '/api/tours/'.$tour->getId().'/reorder', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('stopIds', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testComputeRequiresManagePermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermissions(['field_sales.tours.view']);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\FieldSales\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'endpoint GET /api/visitable_tiers (M6.5 — pins de la
|
||||
* carte de planification).
|
||||
*
|
||||
* Couvre : exposition des adresses geolocalisees Client/Fournisseur, exclusion
|
||||
* des adresses sans coordonnees (RG-6.05) et des Tiers archives/supprimes,
|
||||
* filtres bbox / q / type, securite RBAC, echappatoire ?pagination=false.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class VisitableTierApiTest extends AbstractFieldSalesApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/** Isole les Tiers seedes par ces tests (prefixe commun client + supplier). */
|
||||
private const string ISOLATE_Q = 'TEST_FS_';
|
||||
|
||||
public function testExposesGeolocatedClientAndSupplierPins(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$tierClient = $this->seedClient('Ferme Pin');
|
||||
$this->seedClientAddress($tierClient, 47.218, -1.553);
|
||||
$tierSupplier = $this->seedSupplier('Negoce Pin');
|
||||
$this->seedSupplierAddress($tierSupplier, 47.220, -1.560);
|
||||
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame(2, $data['totalItems'], 'Une adresse geolocalisee Client + une Fournisseur = 2 pins.');
|
||||
|
||||
$byType = [];
|
||||
foreach ($data['member'] as $pin) {
|
||||
$byType[$pin['tierType']] = $pin;
|
||||
}
|
||||
self::assertArrayHasKey('client', $byType);
|
||||
self::assertArrayHasKey('supplier', $byType);
|
||||
self::assertSame((float) 47.218, $byType['client']['latitude']);
|
||||
self::assertSame($tierClient->getId(), $byType['client']['tierId']);
|
||||
self::assertStringContainsString('NANTES', $byType['client']['address']);
|
||||
self::assertSame('client-'.$byType['client']['addressId'], $byType['client']['id']);
|
||||
}
|
||||
|
||||
public function testExcludesAddressesWithoutCoordinates(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$tier = $this->seedClient('Sans Geo');
|
||||
// Adresse sans lat/lng (RG-6.05 : exclue du calcul/carte).
|
||||
$this->seedClientAddress($tier, 0, 0);
|
||||
$this->getEm()->getConnection()->executeStatement(
|
||||
"UPDATE client_address SET latitude = NULL, longitude = NULL WHERE city = 'NANTES' AND street = '1 rue de Test' AND client_id = :id",
|
||||
['id' => $tier->getId()],
|
||||
);
|
||||
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame(0, $data['totalItems'], 'Une adresse sans coordonnees ne produit aucun pin (RG-6.05).');
|
||||
}
|
||||
|
||||
public function testBboxRestrictsToVisibleArea(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$inside = $this->seedClient('Dedans');
|
||||
$this->seedClientAddress($inside, 47.0, -1.5);
|
||||
$outside = $this->seedClient('Dehors');
|
||||
$this->seedClientAddress($outside, 10.0, 10.0);
|
||||
|
||||
// bbox = minLng,minLat,maxLng,maxLat autour du point « dedans ».
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q, 'bbox' => '-2.0,46.5,-1.0,47.5'],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame(1, $data['totalItems'], 'Seul le pin dans la bbox est retourne.');
|
||||
self::assertSame($inside->getId(), $data['member'][0]['tierId']);
|
||||
}
|
||||
|
||||
public function testTypeFilterRestrictsTiers(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$this->seedClientAddress($this->seedClient('Client T'));
|
||||
$this->seedSupplierAddress($this->seedSupplier('Supplier T'));
|
||||
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q, 'type' => 'supplier'],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame(1, $data['totalItems'], 'type=supplier ne retourne que les fournisseurs.');
|
||||
self::assertSame('supplier', $data['member'][0]['tierType']);
|
||||
}
|
||||
|
||||
public function testInvalidTypeReturns400(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['type' => 'prestataire_inexistant'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400, 'Un type hors whitelist est rejete en 400.');
|
||||
}
|
||||
|
||||
public function testMalformedBboxReturns400(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['bbox' => '1,2,3'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400, 'Une bbox a 3 valeurs est rejetee en 400.');
|
||||
}
|
||||
|
||||
public function testExcludesArchivedTier(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$tier = $this->seedClient('Archive');
|
||||
$this->seedClientAddress($tier);
|
||||
$this->getEm()->getConnection()->executeStatement(
|
||||
'UPDATE client SET is_archived = TRUE WHERE id = :id',
|
||||
['id' => $tier->getId()],
|
||||
);
|
||||
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame(0, $data['totalItems'], 'Un Tiers archive ne produit aucun pin.');
|
||||
}
|
||||
|
||||
public function testRequiresViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', '/api/visitable_tiers', ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403, 'Sans field_sales.tours.view -> 403.');
|
||||
}
|
||||
|
||||
public function testPaginationFalseReturnsAllPins(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
for ($i = 0; $i < 12; ++$i) {
|
||||
$this->seedClientAddress($this->seedClient('Bulk '.$i), 47.0 + $i / 1000, -1.5);
|
||||
}
|
||||
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q, 'pagination' => 'false'],
|
||||
])->toArray();
|
||||
|
||||
self::assertCount(12, $data['member'], 'pagination=false retourne tous les pins de la zone (carte).');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user