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,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture des tournees (M6 § 5).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST : pose l'owner = utilisateur courant (RG-6.01, tournee personnelle).
|
||||
* L'owner n'est jamais accepte dans le payload (pas de groupe d'ecriture).
|
||||
* - PATCH : aucune reaffectation d'owner.
|
||||
* - DELETE : soft delete (pose deletedAt) au lieu d'une suppression physique.
|
||||
*
|
||||
* La security (field_sales.tours.manage) et la validation Symfony sont deja
|
||||
* appliquees en amont par API Platform.
|
||||
*
|
||||
* @implements ProcessorInterface<Tour, null|Tour>
|
||||
*/
|
||||
final class TourProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Tour) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// DELETE = soft delete : on pose deletedAt et on re-persiste (pas de
|
||||
// suppression physique) — le TourProvider exclut ensuite la tournee.
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
$data->setDeletedAt(new DateTimeImmutable());
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// POST : la tournee est personnelle -> owner = utilisateur courant.
|
||||
if (null === $data->getOwner()) {
|
||||
$data->setOwner($this->security->getUser());
|
||||
}
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use App\Module\FieldSales\Domain\Entity\TourStop;
|
||||
use App\Module\FieldSales\Infrastructure\Tier\TierAddressResolver;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Etape d'une tournee (M6 § 5).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST : rattache l'etape a la tournee parente (Link toProperty 'tour' non
|
||||
* peuple en ecriture, cf. pattern ClientAddressProcessor::linkParent), puis
|
||||
* verifie RG-6.03.
|
||||
* - PATCH : revalide RG-6.03 si la cible/adresse change.
|
||||
* - DELETE : suppression physique de l'etape.
|
||||
*
|
||||
* RG-6.03 (l'adresse appartient au Tiers) : non verifiable par une Assert sur
|
||||
* l'entite (acces BDD requis). Le TierAddressResolver interroge le schema partage
|
||||
* en lecture seule (sans import Commercial) ; en cas d'incoherence on leve une
|
||||
* ValidationException (422) portee sur `addressId`, consommable par useFormErrors.
|
||||
*
|
||||
* @implements ProcessorInterface<TourStop, null|TourStop>
|
||||
*/
|
||||
final class TourStopProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly TierAddressResolver $tierAddressResolver,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof TourStop) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->validateAddressBelongsToTier($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache l'etape a la tournee parente de la sous-ressource POST
|
||||
* (/tours/{tourId}/stops). Sur PATCH, no-op (la tournee est deja resolue).
|
||||
*/
|
||||
private function linkParent(TourStop $stop, array $uriVariables): void
|
||||
{
|
||||
if (null !== $stop->getTour()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tourId = $uriVariables['tourId'] ?? null;
|
||||
if (null === $tourId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tour = $tourId instanceof Tour
|
||||
? $tourId
|
||||
: $this->em->getRepository(Tour::class)->find($tourId);
|
||||
|
||||
// read:false sur le POST : un parent introuvable n'est plus intercepte en
|
||||
// amont -> 404 explicite (sinon 500 au persist sur tour_id NOT NULL).
|
||||
if (!$tour instanceof Tour || null !== $tour->getDeletedAt()) {
|
||||
throw new NotFoundHttpException('Tournée introuvable.');
|
||||
}
|
||||
|
||||
$stop->setTour($tour);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.03 : pour une etape sur Tiers referentiel (tierType != custom), si une
|
||||
* adresse est ciblee, elle doit appartenir au Tiers. Le point libre (custom)
|
||||
* n'a pas d'adresse referentielle -> non concerne (l'entite a deja garanti
|
||||
* addressId null en custom via le callback).
|
||||
*/
|
||||
private function validateAddressBelongsToTier(TourStop $stop): void
|
||||
{
|
||||
$tierType = $stop->getTierType();
|
||||
$tierId = $stop->getTierId();
|
||||
$addressId = $stop->getAddressId();
|
||||
|
||||
// Hors perimetre RG-6.03 : custom, ou champs incomplets (deja couverts par
|
||||
// le callback RG-6.12), ou type non resoluble en table d'adresses.
|
||||
if (null === $tierType
|
||||
|| null === $tierId
|
||||
|| null === $addressId
|
||||
|| !$this->tierAddressResolver->isResolvableTierType($tierType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->tierAddressResolver->addressBelongsToTier($tierType, $tierId, $addressId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'L\'adresse sélectionnée n\'appartient pas au Tiers de l\'étape.',
|
||||
null,
|
||||
[],
|
||||
$stop,
|
||||
'addressId',
|
||||
$addressId,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user