feat(field_sales) : entités et API Tournée + Étape (Tour/TourStop) (ERP-124)
Modèle et API CRUD du module Tournées (M6.3, scope réduit V0.2 : pas de
rapport de visite, donc TourStop sans report_id ni check-in).
- Entités Tour (table tour) + TourStop (table tour_stop) : #[Auditable],
Timestampable/Blamable, enum TourStatus (draft|planned|in_progress|done),
soft delete sur Tour.
- API Platform : GET/POST/GET/PATCH/DELETE /api/tours (DELETE = soft delete),
sous-ressource POST /api/tours/{tourId}/stops + PATCH/DELETE /api/tour_stops/{id}.
- RG-6.01 : tournée personnelle (TourProvider filtre owner ; admin/Bureau
voient tout). RG-6.03 : adresse appartient au Tiers (TourStopProcessor +
TierAddressResolver DBAL, sans import inter-module). RG-6.07 : pas d'unicité
tier_id. RG-6.12 : cohérence custom/Tiers (Assert\Callback).
- Migration racine : tables + COMMENT ON COLUMN FR + index unique
(tour_id, position) + FK CASCADE ; mirror dans ColumnCommentsCatalog.
- i18n audit (fieldsales_tour / _tourstop), mappings Doctrine + API Platform.
- Tests fonctionnels : owner, RG-6.03/6.07/6.12, pagination, unicité position,
soft delete, RBAC (17 tests).
Co-Authored-By: Matthieu <mtholot19@gmail.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\FieldSales\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Base des tests fonctionnels du module FieldSales (M6 — tournees).
|
||||
*
|
||||
* Mutualise :
|
||||
* - des factories EM (sans API) pour seeder vite un Client + une adresse
|
||||
* geolocalisee (cible d'etape), et une tournee appartenant a un user donne ;
|
||||
* - un helper d'indexation des violations 422 par propertyPath ;
|
||||
* - le cleanup des donnees jetables (tournees, clients de test, users/roles test_*).
|
||||
*
|
||||
* Les imports cross-module (Commercial / Core) sont autorises dans les TESTS
|
||||
* (la regle ABSOLUE n°1 vise le code de production).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractFieldSalesApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string TEST_CLIENT_PREFIX = 'TEST_FS_CLIENT_';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanupFieldSalesTestData();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un Client minimal (companyName uniquement — les categories sont une
|
||||
* contrainte Assert non rejouee hors API).
|
||||
*/
|
||||
protected function seedClient(string $companyName): Client
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$client = new Client();
|
||||
$client->setCompanyName(self::TEST_CLIENT_PREFIX.mb_strtoupper($companyName, 'UTF-8'));
|
||||
$em->persist($client);
|
||||
$em->flush();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede une adresse de prospection geolocalisee rattachee a $client. Les
|
||||
* sites/categories (Assert\Count min 1) ne sont pas rejoues hors API : on
|
||||
* persiste directement les colonnes NOT NULL + des coordonnees.
|
||||
*/
|
||||
protected function seedClientAddress(Client $client, float $lat = 47.218, float $lng = -1.553): ClientAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$address = new ClientAddress();
|
||||
$address->setClient($client);
|
||||
$address->setIsProspect(true);
|
||||
$address->setPostalCode('44000');
|
||||
$address->setCity('NANTES');
|
||||
$address->setStreet('1 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).
|
||||
*/
|
||||
protected function seedTour(User $owner, string $label = 'Tournée test'): Tour
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$tour = new Tour();
|
||||
$tour->setOwner($owner);
|
||||
$tour->setLabel($label);
|
||||
$tour->setTourDate(new DateTimeImmutable('2026-07-01'));
|
||||
$em->persist($tour);
|
||||
$em->flush();
|
||||
|
||||
return $tour;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere un User par username (ex: 'admin', ou un username jetable cree par
|
||||
* createUserWithPermission).
|
||||
*/
|
||||
protected function getUserByUsername(string $username): User
|
||||
{
|
||||
$user = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $username]);
|
||||
self::assertInstanceOf(User::class, $user, sprintf('User "%s" introuvable.', $username));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexe les violations d'un corps 422 par propertyPath.
|
||||
*
|
||||
* @param array<string, mixed> $body
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function violationsByPath(array $body): array
|
||||
{
|
||||
$byPath = [];
|
||||
foreach ($body['violations'] ?? [] as $v) {
|
||||
$byPath[$v['propertyPath']] = $v['message'];
|
||||
}
|
||||
|
||||
return $byPath;
|
||||
}
|
||||
|
||||
private function cleanupFieldSalesTestData(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Etapes purgees par CASCADE a la suppression des tournees.
|
||||
$em->createQuery('DELETE FROM '.Tour::class)->execute();
|
||||
|
||||
// Adresses puis clients de test (FK client_address.client_id CASCADE).
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.ClientAddress::class.' a WHERE a.client IN ('
|
||||
.'SELECT c.id FROM '.Client::class.' c WHERE c.companyName LIKE :prefix)',
|
||||
)->setParameter('prefix', self::TEST_CLIENT_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Client::class.' c WHERE c.companyName LIKE :prefix',
|
||||
)->setParameter('prefix', self::TEST_CLIENT_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix',
|
||||
)->setParameter('prefix', 'testuser_%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix',
|
||||
)->setParameter('prefix', 'test_%')->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\FieldSales\Api;
|
||||
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use App\Module\FieldSales\Domain\Entity\TourStop;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels du module FieldSales (M6.3 — tournees + etapes).
|
||||
*
|
||||
* Couvre : creation d'une tournee draft personnelle, pagination, filtre owner
|
||||
* (RG-6.01), securite RBAC, sous-ressource etapes, RG-6.03 (adresse hors Tiers),
|
||||
* RG-6.07 (deux etapes meme Tiers), RG-6.12 (coherence custom / Tiers) et
|
||||
* l'unicite (tour_id, position).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class TourApiTest extends AbstractFieldSalesApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/** Permissions du commercial type (voit + gere ses tournees). */
|
||||
private const array TOUR_PERMISSIONS = ['field_sales.tours.view', 'field_sales.tours.manage'];
|
||||
|
||||
// =================================================================
|
||||
// Tournee : creation, pagination, RBAC, filtre owner
|
||||
// =================================================================
|
||||
|
||||
public function testPostCreatesDraftTourOwnedByCurrentUser(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$response = $client->request('POST', '/api/tours', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Tournée Loire',
|
||||
'tourDate' => '2026-07-15',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$body = $response->toArray();
|
||||
self::assertSame('Tournée Loire', $body['label']);
|
||||
self::assertSame('draft', $body['status'], 'RG-6.02 : une tournee est creee en draft.');
|
||||
|
||||
// RG-6.01 : owner = utilisateur courant (admin), pose par le processor.
|
||||
$reloaded = $this->getEm()->getRepository(Tour::class)->find($body['id']);
|
||||
self::assertInstanceOf(Tour::class, $reloaded);
|
||||
self::assertSame('admin', $reloaded->getOwner()?->getUserIdentifier(), 'owner = utilisateur courant (RG-6.01).');
|
||||
}
|
||||
|
||||
public function testCollectionIsPaginated(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
|
||||
for ($i = 0; $i < 12; ++$i) {
|
||||
$this->seedTour($admin, 'Tournée '.$i);
|
||||
}
|
||||
|
||||
$data = $client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertSame(12, $data['totalItems'], 'Les 12 tournees sont comptees.');
|
||||
self::assertCount(10, $data['member'], 'Page par defaut = 10 items (regle ABSOLUE n°13).');
|
||||
self::assertArrayHasKey('view', $data, 'Enveloppe Hydra paginee (view present).');
|
||||
}
|
||||
|
||||
public function testListRequiresViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403, 'Sans field_sales.tours.view -> 403.');
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.01 : la Commerciale ne voit que ses propres tournees.
|
||||
*/
|
||||
public function testOwnerFilterHidesOthersTours(): void
|
||||
{
|
||||
$credsA = $this->createUserWithPermissions(self::TOUR_PERMISSIONS);
|
||||
$credsB = $this->createUserWithPermissions(self::TOUR_PERMISSIONS);
|
||||
|
||||
$client = $this->authenticatedClient($credsA['username'], $credsA['password']);
|
||||
$userA = $this->getUserByUsername($credsA['username']);
|
||||
$userB = $this->getUserByUsername($credsB['username']);
|
||||
|
||||
$this->seedTour($userA, 'À moi');
|
||||
$this->seedTour($userB, "À l'autre");
|
||||
|
||||
$data = $client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertSame(1, $data['totalItems'], 'A ne voit que sa tournee (RG-6.01).');
|
||||
self::assertSame('À moi', $data['member'][0]['label']);
|
||||
}
|
||||
|
||||
public function testAdminSeesAllTours(): void
|
||||
{
|
||||
$credsA = $this->createUserWithPermissions(self::TOUR_PERMISSIONS);
|
||||
$userA = $this->getUserByUsername($credsA['username']);
|
||||
$this->seedTour($userA, 'Tournée de A');
|
||||
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$data = $client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertSame(1, $data['totalItems'], "L'admin voit toutes les tournees, y compris celles d'autrui.");
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.01 : une Commerciale ne peut pas acceder a la tournee d'un autre
|
||||
* commercial (404 via le provider).
|
||||
*/
|
||||
public function testCommercialeCannotAccessOthersTour(): void
|
||||
{
|
||||
$credsA = $this->createUserWithPermissions(self::TOUR_PERMISSIONS);
|
||||
$credsB = $this->createUserWithPermissions(self::TOUR_PERMISSIONS);
|
||||
|
||||
$client = $this->authenticatedClient($credsA['username'], $credsA['password']);
|
||||
$userB = $this->getUserByUsername($credsB['username']);
|
||||
$tourB = $this->seedTour($userB, 'Privée B');
|
||||
|
||||
$client->request('GET', '/api/tours/'.$tourB->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testDeleteSoftDeletesTour(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin, 'À supprimer');
|
||||
$tourId = $tour->getId();
|
||||
|
||||
$client->request('DELETE', '/api/tours/'.$tourId, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
// Plus accessible via l'API...
|
||||
$client->request('GET', '/api/tours/'.$tourId, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(404, 'DELETE = soft delete -> 404 ensuite.');
|
||||
|
||||
// ...mais la ligne existe toujours avec deletedAt pose (soft delete).
|
||||
$em = $this->getEm();
|
||||
$reloaded = $em->getRepository(Tour::class)->find($tourId);
|
||||
self::assertInstanceOf(Tour::class, $reloaded);
|
||||
self::assertNotNull($reloaded->getDeletedAt(), 'deletedAt doit etre pose (pas de suppression physique).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Etapes : sous-ressource + regles de gestion
|
||||
// =================================================================
|
||||
|
||||
public function testValidTierStopIsCreated(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
$tier = $this->seedClient('Ferme A');
|
||||
$address = $this->seedClientAddress($tier);
|
||||
|
||||
$client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'tierType' => 'client',
|
||||
'tierId' => $tier->getId(),
|
||||
'addressId' => $address->getId(),
|
||||
'position' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testValidCustomStopIsCreated(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
|
||||
$client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'tierType' => 'custom',
|
||||
'customLabel' => 'RDV prospect',
|
||||
'customAddress' => '5 place du Marché, 44000 Nantes',
|
||||
'customLatitude' => '47.2184000',
|
||||
'customLongitude' => '-1.5536000',
|
||||
'position' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.12 : un point custom exige un libelle (et des coordonnees).
|
||||
*/
|
||||
public function testCustomStopRequiresLabel(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
|
||||
$response = $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'tierType' => 'custom',
|
||||
'customLatitude' => '47.2184000',
|
||||
'customLongitude' => '-1.5536000',
|
||||
'position' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('customLabel', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.12 : une etape sur Tiers exige une adresse precise.
|
||||
*/
|
||||
public function testTierStopRequiresAddress(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
$tier = $this->seedClient('Ferme B');
|
||||
|
||||
$response = $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'tierType' => 'client',
|
||||
'tierId' => $tier->getId(),
|
||||
'position' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('addressId', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.03 : l'adresse d'une etape doit appartenir au Tiers vise -> 422 sinon.
|
||||
*/
|
||||
public function testAddressMustBelongToTier(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
|
||||
$tierA = $this->seedClient('Ferme A');
|
||||
$tierB = $this->seedClient('Ferme B');
|
||||
$addressB = $this->seedClientAddress($tierB);
|
||||
|
||||
// tier = A mais adresse = celle de B -> incoherent (RG-6.03).
|
||||
$response = $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'tierType' => 'client',
|
||||
'tierId' => $tierA->getId(),
|
||||
'addressId' => $addressB->getId(),
|
||||
'position' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('addressId', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.07 : deux etapes peuvent viser le meme Tiers (positions distinctes).
|
||||
*/
|
||||
public function testTwoStopsSameTierAccepted(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
$tier = $this->seedClient('Ferme A');
|
||||
$address = $this->seedClientAddress($tier);
|
||||
|
||||
foreach ([0, 1] as $position) {
|
||||
$client->request('POST', '/api/tours/'.$tour->getId().'/stops', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'tierType' => 'client',
|
||||
'tierId' => $tier->getId(),
|
||||
'addressId' => $address->getId(),
|
||||
'position' => $position,
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201, 'RG-6.07 : meme Tiers accepte sur deux etapes.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unicite (tour_id, position) : deux etapes au meme rang sont refusees par
|
||||
* l'index unique. Teste au niveau DBAL (sans casser l'EM de l'ORM).
|
||||
*/
|
||||
public function testPositionUniquenessIsEnforced(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
|
||||
$stop = new TourStop();
|
||||
$stop->setTour($tour);
|
||||
$stop->setTierType(TourStop::TIER_TYPE_CUSTOM);
|
||||
$stop->setCustomLabel('Étape 1');
|
||||
$stop->setCustomLatitude(47.2184);
|
||||
$stop->setCustomLongitude(-1.5536);
|
||||
$stop->setPosition(0);
|
||||
$em->persist($stop);
|
||||
$em->flush();
|
||||
|
||||
// Insertion brute d'une 2e etape au meme (tour_id, position) -> viole
|
||||
// uq_tour_stop_position. Passage par DBAL pour ne pas fermer l'EM ORM.
|
||||
$now = (new DateTimeImmutable())->format('Y-m-d H:i:s');
|
||||
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
$em->getConnection()->insert('tour_stop', [
|
||||
'tour_id' => $tour->getId(),
|
||||
'tier_type' => TourStop::TIER_TYPE_CUSTOM,
|
||||
'position' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user