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

- API visitable_tiers (provider DBAL bbox/q/type, paginé) pour les pins de la carte
- POST /tours/{id}/reorder (drag & drop) : renumérotation atomique + recompute
- Layer front field-sales : TourMap (pins, popup, polyline, sélection rectangle),
  liste d'étapes draggable (vuedraggable), composable de planification + Vitest
- Pages /tours, /tours/new, /tours/[id]/plan (split responsive, point custom géocodé)
- i18n FR, deep links Waze/Google/Apple, état 100% local
This commit is contained in:
Matthieu
2026-06-11 17:38:40 +02:00
parent f8f7571cc0
commit f8793ab359
23 changed files with 2721 additions and 2 deletions
+3
View File
@@ -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']
+96
View File
@@ -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: '&copy; <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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// 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
}
+20 -1
View File
@@ -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",
+2 -1
View File
@@ -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 {}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\FieldSales\Application\Route\TourRouteCalculator;
use App\Module\FieldSales\Domain\Entity\Tour;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use function assert;
/**
* Processor de l'operation POST /api/tours/{id}/reorder (M6 § 5, drag & drop).
*
* La tournee est chargee par TourProvider (RG-6.01). Le corps porte l'ordre
* souhaite des etapes (`stopIds` : liste d'ids dans le nouvel ordre). On delegue
* la renumerotation atomique (deux flushes, anti-collision unique (tour_id,
* position)) + le recalcul a {@see TourRouteCalculator::reorder()}, puis on
* retourne la tournee recalculee (200).
*
* Operation deserialize:false : on lit `stopIds` manuellement et on leve une 422
* (propertyPath `stopIds`) si absente ou invalide — consommable par useFormErrors.
*
* @implements ProcessorInterface<Tour, Tour>
*/
final class TourReorderProcessor implements ProcessorInterface
{
public function __construct(
private readonly TourRouteCalculator $calculator,
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
{
assert($data instanceof Tour);
$this->calculator->reorder($data, $this->readStopIds());
$this->em->flush();
return $data;
}
/**
* Lit et valide `stopIds` depuis le corps JSON : liste non vide d'entiers.
* Leve une 422 portee sur `stopIds` si absente ou malformee.
*
* @return list<int>
*/
private function readStopIds(): array
{
$request = $this->requestStack->getCurrentRequest();
$payload = null !== $request ? json_decode($request->getContent(), true) : null;
$raw = is_array($payload) ? ($payload['stopIds'] ?? null) : null;
if (!is_array($raw) || [] === $raw) {
$this->throwViolation('La liste ordonnée des étapes (stopIds) est obligatoire.');
}
$ids = [];
foreach ($raw as $value) {
if (!is_int($value) && !(is_string($value) && ctype_digit($value))) {
$this->throwViolation('La liste des étapes doit ne contenir que des identifiants entiers.');
}
$ids[] = (int) $value;
}
return $ids;
}
/**
* @return never
*/
private function throwViolation(string $message): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation($message, null, [], null, 'stopIds', null));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
use App\Module\FieldSales\Application\DTO\VisitableTierOutput;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Provider de la resource VisitableTier (M6 § 5, pins de la carte de tournee).
*
* Lit en DBAL pur le schema PARTAGE (client_address + supplier_address jointes a
* client/supplier) — aucune classe du module Commercial n'est importee (regle
* ABSOLUE n°1). Les types visitables sont une whitelist de constantes
* (self::SOURCES) ; seuls les Tiers actifs (non archives, non soft-deletes) avec
* une adresse geolocalisee (latitude ET longitude non nulles, RG-6.05) sont
* exposes.
*
* Collection : DbalPaginator (hydra:view auto) ou, sur `?pagination=false`,
* la liste complete bornee par la bbox (la carte affiche TOUS les pins de la
* zone visible — la bbox limite le volume, pas la pagination).
*
* Extensible : ajouter un type Visitable = une entree dans self::SOURCES.
*
* @implements ProviderInterface<VisitableTierOutput>
*/
final readonly class VisitableTierProvider implements ProviderInterface
{
/**
* Mapping tierType -> tables/colonnes du schema partage. Identifiants issus
* d'une whitelist de constantes (jamais de l'entree utilisateur) -> aucun
* risque d'injection ; seules les valeurs (bbox, q) sont parametrees.
*
* @var array<string, array{addressTable: string, ownerColumn: string, tierTable: string}>
*/
private const array SOURCES = [
'client' => ['addressTable' => 'client_address', 'ownerColumn' => 'client_id', 'tierTable' => 'client'],
'supplier' => ['addressTable' => 'supplier_address', 'ownerColumn' => 'supplier_id', 'tierTable' => 'supplier'],
];
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
private Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|DbalPaginator|VisitableTierOutput|null
{
if (!$operation instanceof CollectionOperationInterface) {
return $this->provideItem((string) ($uriVariables['id'] ?? ''));
}
return $this->provideCollection($operation, $context);
}
private function provideItem(string $id): ?VisitableTierOutput
{
// id synthetique « {type}-{addressId} » (cf. VisitableTierOutput::$id).
if (1 !== preg_match('/^([a-z_]+)-([0-9]+)$/', $id, $m)) {
return null;
}
$type = $m[1];
$addressId = (int) $m[2];
$source = self::SOURCES[$type] ?? null;
if (null === $source) {
return null;
}
$sql = $this->buildSelect($source, $type).' AND a.id = :addressId';
/** @var array<string, mixed>|false $row */
$row = $this->connection->fetchAssociative($sql, ['addressId' => $addressId]);
return false === $row ? null : $this->hydrate($row);
}
/**
* @param array<string, mixed> $context
*
* @return DbalPaginator|list<VisitableTierOutput>
*/
private function provideCollection(Operation $operation, array $context): array|DbalPaginator
{
$filters = $context['filters'] ?? [];
$types = $this->extractTypes($filters);
$bbox = $this->extractBbox($filters);
$search = $this->extractSearch($filters);
// Aucun type resoluble demande -> collection vide.
if ([] === $types) {
return $this->pagination->isEnabled($operation, $context)
? new DbalPaginator([], 1, $this->pagination->getLimit($operation, $context), 0)
: [];
}
[$unionSql, $params] = $this->buildUnion($types, $bbox, $search);
// Echappatoire ?pagination=false (convention ERP-72) : la carte charge
// tous les pins de la bbox d'un coup (volume borne par la zone visible).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<array<string, mixed>> $rows */
$rows = $this->connection->fetchAllAssociative(
sprintf('SELECT * FROM (%s) sub ORDER BY display_name ASC, address_id ASC', $unionSql),
$params,
);
return array_map($this->hydrate(...), $rows);
}
$page = max(1, $this->pagination->getPage($context));
$itemsPerPage = $this->pagination->getLimit($operation, $context);
$offset = ($page - 1) * $itemsPerPage;
/** @var list<array<string, mixed>> $rows */
$rows = $this->connection->fetchAllAssociative(
sprintf(
'SELECT * FROM (%s) sub ORDER BY display_name ASC, address_id ASC LIMIT :limit OFFSET :offset',
$unionSql,
),
[...$params, 'limit' => $itemsPerPage, 'offset' => $offset],
);
$totalItems = (int) $this->connection->fetchOne(
sprintf('SELECT COUNT(*) FROM (%s) sub', $unionSql),
$params,
);
$items = array_map($this->hydrate(...), $rows);
return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
}
/**
* Construit l'UNION ALL des SELECT par type demande, en partageant les memes
* parametres nommes (bbox/q) sur chaque moitie.
*
* @param list<string> $types
* @param null|array{minLng: float, minLat: float, maxLng: float, maxLat: float} $bbox
*
* @return array{0: string, 1: array<string, mixed>}
*/
private function buildUnion(array $types, ?array $bbox, ?string $search): array
{
$halves = [];
foreach ($types as $type) {
$halves[] = $this->buildSelect(self::SOURCES[$type], $type, $bbox, null !== $search);
}
$params = [];
if (null !== $bbox) {
$params += $bbox;
}
if (null !== $search) {
// Echappe %, _ et \ pour un ILIKE « contient » litteral.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $search);
$params['q'] = '%'.$escaped.'%';
}
return [implode(' UNION ALL ', $halves), $params];
}
/**
* SELECT d'une source (table d'adresses + Tiers). Filtre toujours sur Tiers
* actif + adresse geolocalisee ; ajoute bbox/q selon les arguments.
*
* @param array{addressTable: string, ownerColumn: string, tierTable: string} $source
* @param null|array{minLng: float, minLat: float, maxLng: float, maxLat: float} $bbox
*/
private function buildSelect(array $source, string $type, ?array $bbox = null, bool $withSearch = false): string
{
$sql = sprintf(
"SELECT '%s' AS tier_type, a.%s AS tier_id, a.id AS address_id, "
.'t.company_name AS display_name, a.street, a.street_complement, a.postal_code, a.city, '
.'a.latitude, a.longitude '
.'FROM %s a JOIN %s t ON t.id = a.%s '
.'WHERE a.latitude IS NOT NULL AND a.longitude IS NOT NULL '
.'AND t.is_archived = FALSE AND t.deleted_at IS NULL',
$type,
$source['ownerColumn'],
$source['addressTable'],
$source['tierTable'],
$source['ownerColumn'],
);
if (null !== $bbox) {
$sql .= ' AND a.latitude BETWEEN :minLat AND :maxLat AND a.longitude BETWEEN :minLng AND :maxLng';
}
if ($withSearch) {
$sql .= ' AND (t.company_name ILIKE :q OR a.city ILIKE :q)';
}
return $sql;
}
/**
* Types demandes (?type=client,supplier), intersectes avec la whitelist.
* Defaut = tous les types resolubles. Un type inconnu -> 400 explicite.
*
* @param array<string, mixed> $filters
*
* @return list<string>
*/
private function extractTypes(array $filters): array
{
$raw = $filters['type'] ?? null;
if (null === $raw || '' === $raw) {
return array_keys(self::SOURCES);
}
$requested = is_array($raw) ? $raw : explode(',', (string) $raw);
$types = [];
foreach ($requested as $t) {
$t = trim((string) $t);
if ('' === $t) {
continue;
}
if (!isset(self::SOURCES[$t])) {
throw new BadRequestHttpException(sprintf(
'Filtre "type" invalide : "%s". Valeurs autorisees : %s.',
$t,
implode(', ', array_keys(self::SOURCES)),
));
}
$types[$t] = true;
}
return array_keys($types);
}
/**
* Parse ?bbox=minLng,minLat,maxLng,maxLat (format Leaflet
* getBounds().toBBoxString() = west,south,east,north). Absent -> null (pas de
* filtre geo). Malforme -> 400.
*
* @param array<string, mixed> $filters
*
* @return null|array{minLng: float, minLat: float, maxLng: float, maxLat: float}
*/
private function extractBbox(array $filters): ?array
{
$raw = $filters['bbox'] ?? null;
if (null === $raw || '' === $raw) {
return null;
}
$parts = explode(',', (string) $raw);
if (4 !== count($parts)) {
throw new BadRequestHttpException('Filtre "bbox" invalide : 4 valeurs attendues (minLng,minLat,maxLng,maxLat).');
}
foreach ($parts as $p) {
if (!is_numeric(trim($p))) {
throw new BadRequestHttpException('Filtre "bbox" invalide : coordonnees numeriques attendues.');
}
}
return [
'minLng' => (float) $parts[0],
'minLat' => (float) $parts[1],
'maxLng' => (float) $parts[2],
'maxLat' => (float) $parts[3],
];
}
/**
* @param array<string, mixed> $filters
*/
private function extractSearch(array $filters): ?string
{
$raw = $filters['q'] ?? null;
return is_string($raw) && '' !== trim($raw) ? trim($raw) : null;
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): VisitableTierOutput
{
$type = (string) $row['tier_type'];
$addressId = (int) $row['address_id'];
return new VisitableTierOutput(
id: sprintf('%s-%d', $type, $addressId),
tierType: $type,
tierId: (int) $row['tier_id'],
addressId: $addressId,
displayName: (string) $row['display_name'],
address: $this->formatAddress($row),
latitude: (float) $row['latitude'],
longitude: (float) $row['longitude'],
);
}
/**
* Adresse sur une ligne : « rue [complement], CP ville ».
*
* @param array<string, mixed> $row
*/
private function formatAddress(array $row): string
{
$street = trim(implode(' ', array_filter([
null !== $row['street'] ? (string) $row['street'] : null,
null !== $row['street_complement'] ? (string) $row['street_complement'] : null,
])));
$cityLine = trim(implode(' ', array_filter([
null !== $row['postal_code'] ? (string) $row['postal_code'] : null,
null !== $row['city'] ? (string) $row['city'] : null,
])));
return trim(implode(', ', array_filter([$street, $cityLine])));
}
}
@@ -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).');
}
}