f8793ab359
- 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
179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
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,
|
|
}
|
|
}
|