diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index e5ad43b..f7ce2ec 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -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'] diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 21da97c..ddd16c2 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -50,6 +50,102 @@ "title": "Tableau de bord", "welcome": "Bienvenue sur Starseed" }, + "field_sales": { + "tours": { + "title": "Tournées", + "add": "Nouvelle tournée", + "empty": "Aucune tournée pour l'instant.", + "column": { + "label": "Nom", + "date": "Date", + "status": "Statut", + "stops": "Étapes", + "distance": "Distance", + "duration": "Durée" + }, + "status": { + "draft": "Brouillon", + "planned": "Planifiée", + "in_progress": "En cours", + "done": "Terminée" + }, + "new": { + "title": "Nouvelle tournée", + "label": "Nom de la tournée", + "date": "Date", + "create": "Créer la tournée", + "cancel": "Annuler", + "error": "Impossible de créer la tournée." + } + }, + "plan": { + "title": "Planification", + "back": "Retour aux tournées", + "panel": { + "title": "Tournée", + "label": "Nom de la tournée", + "date": "Date", + "departureTime": "Heure de départ", + "startLabel": "Point de départ", + "defaultVisitMinutes": "Durée de visite (min)", + "stops": "Étapes", + "noStops": "Aucune étape. Sélectionnez des Tiers sur la carte ou ajoutez un point libre.", + "distance": "Distance", + "duration": "Durée totale", + "visits": "Visites" + }, + "actions": { + "compute": "Trajet logique", + "optimize": "Optimiser", + "duplicate": "Dupliquer", + "pdf": "PDF", + "save": "Enregistrer" + }, + "stop": { + "eta": "Arrivée", + "fromPrevious": "depuis l'étape précédente", + "toGeolocate": "À géolocaliser", + "goThere": "Y aller", + "viewTier": "Voir le Tiers", + "remove": "Supprimer l'étape", + "waze": "Waze", + "google": "Google Maps", + "apple": "Plan (Apple)" + }, + "custom": { + "add": "Ajouter un point libre", + "title": "Point libre", + "label": "Libellé", + "address": "Adresse", + "confirm": "Ajouter le point", + "cancel": "Annuler", + "geocodeFailed": "Adresse introuvable — ajustez le pin sur la carte.", + "hint": "Saisissez une adresse, elle sera géolocalisée automatiquement." + }, + "map": { + "typeClient": "Clients", + "typeSupplier": "Fournisseurs", + "search": "Rechercher un Tiers", + "add": "Ajouter", + "lassoHint": "Maintenez Maj et dessinez un rectangle pour sélectionner plusieurs Tiers." + }, + "duplicateModal": { + "title": "Dupliquer la tournée", + "date": "Date de la nouvelle tournée", + "confirm": "Dupliquer", + "cancel": "Annuler" + }, + "toast": { + "computeError": "Le calcul du trajet a échoué.", + "optimizeError": "L'optimisation a échoué.", + "duplicateError": "La duplication a échoué.", + "saveError": "L'enregistrement a échoué.", + "loadError": "Impossible de charger la tournée.", + "stopError": "L'opération sur l'étape a échoué.", + "duplicated": "Tournée dupliquée." + } + } + }, "commercial": { "title": "Commercial", "welcome": "Module Commercial", diff --git a/frontend/modules/field-sales/components/TourMap.vue b/frontend/modules/field-sales/components/TourMap.vue new file mode 100644 index 0000000..2a59186 --- /dev/null +++ b/frontend/modules/field-sales/components/TourMap.vue @@ -0,0 +1,353 @@ + + + diff --git a/frontend/modules/field-sales/components/TourStopList.vue b/frontend/modules/field-sales/components/TourStopList.vue new file mode 100644 index 0000000..1fa0321 --- /dev/null +++ b/frontend/modules/field-sales/components/TourStopList.vue @@ -0,0 +1,148 @@ + + + diff --git a/frontend/modules/field-sales/composables/__tests__/useTourPlanning.test.ts b/frontend/modules/field-sales/composables/__tests__/useTourPlanning.test.ts new file mode 100644 index 0000000..ef9a315 --- /dev/null +++ b/frontend/modules/field-sales/composables/__tests__/useTourPlanning.test.ts @@ -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 { + 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('—') + }) +}) diff --git a/frontend/modules/field-sales/composables/useTourPlanning.ts b/frontend/modules/field-sales/composables/useTourPlanning.ts new file mode 100644 index 0000000..49340ce --- /dev/null +++ b/frontend/modules/field-sales/composables/useTourPlanning.ts @@ -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(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): 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, + } +} diff --git a/frontend/modules/field-sales/composables/useToursRepository.ts b/frontend/modules/field-sales/composables/useToursRepository.ts new file mode 100644 index 0000000..daa4213 --- /dev/null +++ b/frontend/modules/field-sales/composables/useToursRepository.ts @@ -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({ + url: '/tours', + defaultSort: { field: 'tourDate', direction: 'desc' }, + }) +} diff --git a/frontend/modules/field-sales/nuxt.config.ts b/frontend/modules/field-sales/nuxt.config.ts new file mode 100644 index 0000000..02f11ea --- /dev/null +++ b/frontend/modules/field-sales/nuxt.config.ts @@ -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({}) diff --git a/frontend/modules/field-sales/pages/tours/[id]/plan.vue b/frontend/modules/field-sales/pages/tours/[id]/plan.vue new file mode 100644 index 0000000..4200295 --- /dev/null +++ b/frontend/modules/field-sales/pages/tours/[id]/plan.vue @@ -0,0 +1,648 @@ + + + diff --git a/frontend/modules/field-sales/pages/tours/index.vue b/frontend/modules/field-sales/pages/tours/index.vue new file mode 100644 index 0000000..5819adc --- /dev/null +++ b/frontend/modules/field-sales/pages/tours/index.vue @@ -0,0 +1,124 @@ + + + diff --git a/frontend/modules/field-sales/pages/tours/new.vue b/frontend/modules/field-sales/pages/tours/new.vue new file mode 100644 index 0000000..24d689a --- /dev/null +++ b/frontend/modules/field-sales/pages/tours/new.vue @@ -0,0 +1,96 @@ + + + diff --git a/frontend/modules/field-sales/types/tour.ts b/frontend/modules/field-sales/types/tour.ts new file mode 100644 index 0000000..a51deeb --- /dev/null +++ b/frontend/modules/field-sales/types/tour.ts @@ -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 + 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 +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 72037a3..ed2e54a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index c9d2155..e44806d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/src/Module/FieldSales/Application/DTO/VisitableTierOutput.php b/src/Module/FieldSales/Application/DTO/VisitableTierOutput.php new file mode 100644 index 0000000..9f9f677 --- /dev/null +++ b/src/Module/FieldSales/Application/DTO/VisitableTierOutput.php @@ -0,0 +1,36 @@ +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 $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). diff --git a/src/Module/FieldSales/Domain/Entity/Tour.php b/src/Module/FieldSales/Domain/Entity/Tour.php index 1621bd8..8a0af29 100644 --- a/src/Module/FieldSales/Domain/Entity/Tour.php +++ b/src/Module/FieldSales/Domain/Entity/Tour.php @@ -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', diff --git a/src/Module/FieldSales/Infrastructure/ApiPlatform/Resource/VisitableTierResource.php b/src/Module/FieldSales/Infrastructure/ApiPlatform/Resource/VisitableTierResource.php new file mode 100644 index 0000000..671e6b2 --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/ApiPlatform/Resource/VisitableTierResource.php @@ -0,0 +1,53 @@ + '[a-z_]+-[0-9]+'], + security: "is_granted('field_sales.tours.view')", + provider: VisitableTierProvider::class, + ), + ], + output: VisitableTierOutput::class, +)] +final class VisitableTierResource {} diff --git a/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourReorderProcessor.php b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourReorderProcessor.php new file mode 100644 index 0000000..1749cda --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourReorderProcessor.php @@ -0,0 +1,88 @@ + + */ +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 + */ + 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); + } +} diff --git a/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Provider/VisitableTierProvider.php b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Provider/VisitableTierProvider.php new file mode 100644 index 0000000..93b9242 --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Provider/VisitableTierProvider.php @@ -0,0 +1,322 @@ + + */ +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 + */ + 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|false $row */ + $row = $this->connection->fetchAssociative($sql, ['addressId' => $addressId]); + + return false === $row ? null : $this->hydrate($row); + } + + /** + * @param array $context + * + * @return DbalPaginator|list + */ + 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> $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> $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 $types + * @param null|array{minLng: float, minLat: float, maxLng: float, maxLat: float} $bbox + * + * @return array{0: string, 1: array} + */ + 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 $filters + * + * @return list + */ + 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 $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 $filters + */ + private function extractSearch(array $filters): ?string + { + $raw = $filters['q'] ?? null; + + return is_string($raw) && '' !== trim($raw) ? trim($raw) : null; + } + + /** + * @param array $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 $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]))); + } +} diff --git a/tests/Module/FieldSales/Api/AbstractFieldSalesApiTestCase.php b/tests/Module/FieldSales/Api/AbstractFieldSalesApiTestCase.php index 0ae2573..6b05061 100644 --- a/tests/Module/FieldSales/Api/AbstractFieldSalesApiTestCase.php +++ b/tests/Module/FieldSales/Api/AbstractFieldSalesApiTestCase.php @@ -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(); diff --git a/tests/Module/FieldSales/Api/TourRouteApiTest.php b/tests/Module/FieldSales/Api/TourRouteApiTest.php index c35da7d..9546c1e 100644 --- a/tests/Module/FieldSales/Api/TourRouteApiTest.php +++ b/tests/Module/FieldSales/Api/TourRouteApiTest.php @@ -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']); diff --git a/tests/Module/FieldSales/Api/VisitableTierApiTest.php b/tests/Module/FieldSales/Api/VisitableTierApiTest.php new file mode 100644 index 0000000..5e5f707 --- /dev/null +++ b/tests/Module/FieldSales/Api/VisitableTierApiTest.php @@ -0,0 +1,175 @@ +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).'); + } +}