feat(field_sales) : entités et API Tournée + Étape (Tour/TourStop) (ERP-124)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 29m14s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 11s

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:
Matthieu
2026-06-11 15:54:10 +02:00
parent be9204eca7
commit 0052eab1fe
19 changed files with 2041 additions and 1 deletions
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Repository\TourRepositoryInterface;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider des tournees (M6 § 5). Applique RG-6.01 (tournee personnelle) :
* - Collection (GET /api/tours) : filtree sur l'owner courant, sauf admin ou
* role metier Bureau qui voient toutes les tournees. Toujours paginee.
* - Item (GET / PATCH / DELETE /api/tours/{id}) : 404 si soft-deletee, et 404
* si la tournee appartient a un autre commercial (sauf admin / Bureau).
*
* @implements ProviderInterface<Tour>
*/
final class TourProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourRepository')]
private readonly TourRepositoryInterface $repository,
private readonly Pagination $pagination,
private readonly Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Tour|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Tour>|Paginator<Tour>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
// RG-6.01 : la Commerciale ne voit que ses tournees ; admin / Bureau tout.
$ownerFilter = $this->canSeeAll() ? null : $this->security->getUser();
$qb = $this->repository->createListQueryBuilder($ownerFilter);
// Echappatoire ?pagination=false (convention ERP-72).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<Tour> $tours */
return $qb->getQuery()->getResult();
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Tour
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$tour = $this->repository->findById((int) $id);
if (null === $tour || null !== $tour->getDeletedAt()) {
return null;
}
// RG-6.01 : une Commerciale ne peut pas acceder a la tournee d'autrui.
if (!$this->canSeeAll() && $tour->getOwner() !== $this->security->getUser()) {
return null;
}
return $tour;
}
/**
* Vrai si l'utilisateur courant voit/edite toutes les tournees : admin
* (ROLE_ADMIN) ou role metier Bureau (RG-6.01).
*/
private function canSeeAll(): bool
{
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::BUREAU);
}
}