authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin); // 2 points geolocalises alignes (0,2° de latitude ≈ 22,2 km) + 1 etape // sur Tiers dont l'adresse n'a pas de coordonnees (exclue, RG-6.05). $this->seedCustomStop($tour, 0, 47.0, -1.0); $this->seedCustomStop($tour, 1, 47.2, -1.0); $tier = $this->seedClient('Sans coords'); $address = $this->seedClientAddressWithoutCoords($tier); $this->seedTierStop($tour, $tier, $address, 2); $body = $client->request('POST', '/api/tours/'.$tour->getId().'/compute', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], ])->toArray(); $stops = $this->stopsByPosition($body); // 1re etape = depart (aucun start_* sur la tournee) -> leg nul, eta = 08:00. self::assertSame(0, $stops[0]['legDistanceM']); self::assertStringContainsString('08:00:00', (string) $stops[0]['eta']); // 2e etape : ~22,2 km, eta posterieure au depart (RG-6.11). self::assertEqualsWithDelta(22_240, $stops[1]['legDistanceM'], 600); self::assertNotNull($stops[1]['eta']); self::assertStringContainsString('08:', (string) $stops[1]['eta']); self::assertGreaterThan($stops[0]['eta'], $stops[1]['eta'], 'ETA croissante le long de la tournee.'); // 3e etape exclue (RG-6.05) : legs + eta restent null. self::assertNull($stops[2]['legDistanceM'], 'Etape sans coords exclue (RG-6.05).'); self::assertNull($stops[2]['eta']); // Totaux : seules les etapes geolocalisees comptent. self::assertEqualsWithDelta(22_240, $body['totalDistanceM'], 600); self::assertGreaterThan(0, $body['totalDurationS']); } /** * /optimize reordonne les etapes selon le plus proche voisin depuis la 1re * etape (pas de start_*) puis recompute. */ public function testOptimizeReordersStopsByNearestNeighbour(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin); // Depart = 1re etape (47.0). Fournies en desordre : la plus eloignee (47.3) // en position 1, puis 47.2, puis la plus proche (47.1). $this->seedCustomStop($tour, 0, 47.0, -1.0, 'Départ'); $this->seedCustomStop($tour, 1, 47.3, -1.0, 'Loin'); $this->seedCustomStop($tour, 2, 47.2, -1.0, 'Milieu'); $this->seedCustomStop($tour, 3, 47.1, -1.0, 'Proche'); $body = $client->request('POST', '/api/tours/'.$tour->getId().'/optimize', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], ])->toArray(); $labels = array_map( static fn (array $s) => $s['customLabel'], $this->stopsByPosition($body), ); self::assertSame(['Départ', 'Proche', 'Milieu', 'Loin'], $labels, 'Ordre plus proche voisin depuis le depart.'); } /** * RG-6.13 : la duplication copie depart + etapes a une nouvelle date, en * draft, SANS les calculs (eta / legs recalcules ensuite). */ public function testDuplicateCopiesStopsWithoutComputedValues(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin, 'Tournée à dupliquer'); $this->seedCustomStop($tour, 0, 47.0, -1.0, 'A'); $this->seedCustomStop($tour, 1, 47.2, -1.0, 'B'); // On calcule d'abord la source pour s'assurer qu'elle porte des eta/legs. $client->request('POST', '/api/tours/'.$tour->getId().'/compute', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], ]); $body = $client->request('POST', '/api/tours/'.$tour->getId().'/duplicate', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => ['tourDate' => '2026-09-01'], ])->toArray(); self::assertResponseStatusCodeSame(201); self::assertNotSame($tour->getId(), $body['id'], 'Une nouvelle tournee est creee.'); self::assertSame('draft', $body['status'], 'La copie repart en draft.'); self::assertStringStartsWith('2026-09-01', $body['tourDate']); self::assertNull($body['totalDistanceM'], 'RG-6.13 : pas de totaux copies.'); $stops = $this->stopsByPosition($body); self::assertCount(2, $stops); self::assertSame(['A', 'B'], array_map(static fn (array $s) => $s['customLabel'], $stops)); self::assertNull($stops[0]['eta'], 'RG-6.13 : eta non copiee (recalculee ensuite).'); self::assertNull($stops[0]['legDistanceM'], 'RG-6.13 : legs non copies.'); } public function testDuplicateRequiresTourDate(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin); $response = $client->request('POST', '/api/tours/'.$tour->getId().'/duplicate', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => [], ]); self::assertResponseStatusCodeSame(422); self::assertArrayHasKey('tourDate', $this->violationsByPath($response->toArray(false))); } public function testComputeRequiresManagePermission(): void { $creds = $this->createUserWithPermissions(['field_sales.tours.view']); $client = $this->authenticatedClient($creds['username'], $creds['password']); $user = $this->getUserByUsername($creds['username']); $tour = $this->seedTour($user); $client->request('POST', '/api/tours/'.$tour->getId().'/compute', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], ]); self::assertResponseStatusCodeSame(403, 'compute exige field_sales.tours.manage.'); } // ================================================================= // Helpers de seed specifiques au calcul de trajet // ================================================================= private function seedCustomStop(Tour $tour, int $position, float $lat, float $lng, string $label = 'Point libre'): TourStop { $em = $this->getEm(); $stop = new TourStop(); $stop->setTour($tour); $stop->setTierType(TourStop::TIER_TYPE_CUSTOM); $stop->setCustomLabel($label); $stop->setCustomLatitude($lat); $stop->setCustomLongitude($lng); $stop->setPosition($position); $em->persist($stop); $em->flush(); return $stop; } private function seedTierStop(Tour $tour, Client $tier, ClientAddress $address, int $position): TourStop { $em = $this->getEm(); $stop = new TourStop(); $stop->setTour($tour); $stop->setTierType('client'); $stop->setTierId($tier->getId()); $stop->setAddressId($address->getId()); $stop->setPosition($position); $em->persist($stop); $em->flush(); return $stop; } private function seedClientAddressWithoutCoords(Client $client): ClientAddress { $em = $this->getEm(); $address = new ClientAddress(); $address->setClient($client); $address->setIsProspect(true); $address->setPostalCode('44000'); $address->setCity('NANTES'); $address->setStreet('1 rue Sans Coords'); $em->persist($address); $em->flush(); return $address; } /** * Etapes de la reponse (item Tour) indexees par position croissante. * * @param array $body * * @return list> */ private function stopsByPosition(array $body): array { $stops = $body['stops'] ?? []; usort($stops, static fn (array $a, array $b) => $a['position'] <=> $b['position']); return array_values($stops); } }