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:
@@ -12,6 +12,8 @@ api_platform:
|
||||
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
||||
# en dehors de Domain/Entity : AuditLogResource, etc.
|
||||
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
||||
# Module FieldSales (M6) : entites ApiResource Tour / TourStop.
|
||||
- '%kernel.project_dir%/src/Module/FieldSales/Domain/Entity'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
|
||||
@@ -87,6 +87,16 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
|
||||
prefix: 'App\Module\Commercial\Domain\Entity'
|
||||
alias: Commercial
|
||||
# Mapping inconditionnel du module FieldSales (M6 — meme logique que
|
||||
# Commercial) : les tables tour / tour_stop creees par la migration
|
||||
# M6.3 (Version20260611140000) doivent etre connues de l'ORM.
|
||||
# L'activation fonctionnelle passe par config/modules.php.
|
||||
FieldSales:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/FieldSales/Domain/Entity'
|
||||
prefix: 'App\Module\FieldSales\Domain\Entity'
|
||||
alias: FieldSales
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -425,7 +425,9 @@
|
||||
"commercial_supplier": "Fournisseur",
|
||||
"commercial_supplieraddress": "Adresse fournisseur",
|
||||
"commercial_suppliercontact": "Contact fournisseur",
|
||||
"commercial_supplierrib": "RIB fournisseur"
|
||||
"commercial_supplierrib": "RIB fournisseur",
|
||||
"fieldsales_tour": "Tournée",
|
||||
"fieldsales_tourstop": "Étape de tournée"
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M6.3 (ERP-124) — Tournees commerciales terrain : creation des tables `tour`
|
||||
* (tournee) et `tour_stop` (etape) du module FieldSales.
|
||||
*
|
||||
* SCOPE REDUIT (V0.2) : pas de rapport de visite -> `tour_stop` SANS report_id ni
|
||||
* arrived_at / check-in.
|
||||
*
|
||||
* Particularites de modelisation :
|
||||
* - tour.owner_id : FK -> "user".id, ON DELETE RESTRICT (tournee personnelle,
|
||||
* RG-6.01 ; un user proprietaire d'une tournee ne peut etre supprime).
|
||||
* - tour_stop.tier_id / address_id : entiers SANS FK. La cible d'une etape est
|
||||
* polymorphe (Client M1 / Fournisseur M2 / point custom) resolue via
|
||||
* tier_type ; aucune FK unique possible (RG-6.07 : pas d'unicite sur tier_id).
|
||||
* - Unicite (tour_id, position) : un seul ordre par tournee (uq_tour_stop_position).
|
||||
* - tour_stop.tour_id : FK -> tour.id, ON DELETE CASCADE.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
|
||||
* modulaire : la migration cree des FK cross-module (vers "user"). Avec plusieurs
|
||||
* migrations_paths, Doctrine Migrations 3.x trie par FQCN alphabetique — un
|
||||
* namespace modulaire s'executerait avant la creation de "user" sur base vide.
|
||||
* Le namespace racine garantit l'ordre par timestamp.
|
||||
*
|
||||
* Style DDL aligne sur M1/M2 (INT GENERATED BY DEFAULT AS IDENTITY,
|
||||
* TIMESTAMP(0) WITHOUT TIME ZONE car le trait T/B mappe datetime_immutable),
|
||||
* pour que `schema:update` reste un no-op. Chaque colonne porte son
|
||||
* `COMMENT ON COLUMN` (regle ABSOLUE n°12) ; les 4 colonnes T/B via le catalogue
|
||||
* partage. Les tables sont egalement mirorees dans ColumnCommentsCatalog pour
|
||||
* que `app:apply-column-comments` rejoue les COMMENT apres le schema:update du
|
||||
* setup de test (qui les drope sur les tables mappees par l'ORM).
|
||||
*/
|
||||
final class Version20260611140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-124 (M6.3) : tables tour + tour_stop (module FieldSales), sans rapport de visite (scope reduit V0.2).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->createTourTable();
|
||||
$this->createTourStopTable();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse des dependances FK : tour_stop (FK -> tour) puis tour.
|
||||
$this->addSql('DROP TABLE IF EXISTS tour_stop');
|
||||
$this->addSql('DROP TABLE IF EXISTS tour');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table `tour`
|
||||
// =================================================================
|
||||
|
||||
private function createTourTable(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE tour (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
owner_id INT NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
tour_date DATE NOT NULL,
|
||||
departure_time TIME(0) WITHOUT TIME ZONE NOT NULL,
|
||||
start_latitude NUMERIC(10, 7) DEFAULT NULL,
|
||||
start_longitude NUMERIC(10, 7) DEFAULT NULL,
|
||||
start_label VARCHAR(180) DEFAULT NULL,
|
||||
default_visit_minutes SMALLINT DEFAULT 30 NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'draft' NOT NULL,
|
||||
total_distance_m INT DEFAULT NULL,
|
||||
total_duration_s INT DEFAULT NULL,
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_tour_owner
|
||||
FOREIGN KEY (owner_id) REFERENCES "user" (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_tour_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tour_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_tour_owner ON tour (owner_id)');
|
||||
$this->addSql('CREATE INDEX idx_tour_status ON tour (status)');
|
||||
$this->addSql('CREATE INDEX idx_tour_deleted_at ON tour (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_tour_created_by ON tour (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_tour_updated_by ON tour (updated_by)');
|
||||
|
||||
$this->comment('tour', '_table', 'Tournees commerciales terrain (M6 FieldSales) — personnelles (owner), soft-deletables (deleted_at).');
|
||||
$this->comment('tour', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('tour', 'owner_id', 'Commercial proprietaire de la tournee (RG-6.01, personnelle) — FK -> "user".id, ON DELETE RESTRICT. Pose au POST par le TourProcessor.');
|
||||
$this->comment('tour', 'label', 'Nom libre de la tournee (NotBlank, <= 120 caracteres).');
|
||||
$this->comment('tour', 'tour_date', 'Date de realisation de la tournee (NotNull).');
|
||||
$this->comment('tour', 'departure_time', 'Heure de depart, alimente les ETA (RG-6.11). Defaut applicatif 08:00 (constructeur).');
|
||||
$this->comment('tour', 'start_latitude', 'Latitude WGS84 du point de depart (site commercial ou adresse libre). NULL -> depart = 1re etape.');
|
||||
$this->comment('tour', 'start_longitude', 'Longitude WGS84 du point de depart. NULL -> depart = 1re etape.');
|
||||
$this->comment('tour', 'start_label', 'Libelle affichable du point de depart (<= 180 caracteres). Optionnel.');
|
||||
$this->comment('tour', 'default_visit_minutes', 'Duree de visite par defaut d une etape, en minutes (defaut 30) — utilisee si l etape ne fixe pas sa propre duree.');
|
||||
$this->comment('tour', 'status', 'Cycle de vie (RG-6.02) : draft | planned | in_progress | done (enum TourStatus). Transitions libres en V1. Defaut draft.');
|
||||
$this->comment('tour', 'total_distance_m', 'Cache d affichage : derniere distance totale calculee, en metres (RG-6.11). Lecture seule API, alimente par le moteur de trajet (M6.4).');
|
||||
$this->comment('tour', 'total_duration_s', 'Cache d affichage : derniere duree totale calculee, en secondes (RG-6.11). Lecture seule API.');
|
||||
$this->comment('tour', 'deleted_at', 'Horodatage du soft-delete — pose par le DELETE API. Null = tournee active.');
|
||||
$this->addTimestampableBlamableComments('tour');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table `tour_stop`
|
||||
// =================================================================
|
||||
|
||||
private function createTourStopTable(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE tour_stop (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
tour_id INT NOT NULL,
|
||||
tier_type VARCHAR(30) NOT NULL,
|
||||
tier_id INT DEFAULT NULL,
|
||||
address_id INT DEFAULT NULL,
|
||||
custom_label VARCHAR(180) DEFAULT NULL,
|
||||
custom_address VARCHAR(255) DEFAULT NULL,
|
||||
custom_latitude NUMERIC(10, 7) DEFAULT NULL,
|
||||
custom_longitude NUMERIC(10, 7) DEFAULT NULL,
|
||||
position SMALLINT NOT NULL,
|
||||
visit_minutes SMALLINT DEFAULT NULL,
|
||||
leg_distance_m INT DEFAULT NULL,
|
||||
leg_duration_s INT DEFAULT NULL,
|
||||
eta TIME(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_tour_stop_tour
|
||||
FOREIGN KEY (tour_id) REFERENCES tour (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tour_stop_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tour_stop_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_tour_stop_tour ON tour_stop (tour_id)');
|
||||
$this->addSql('CREATE INDEX idx_tour_stop_created_by ON tour_stop (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_tour_stop_updated_by ON tour_stop (updated_by)');
|
||||
|
||||
// RG-6.07 : pas d unicite sur tier_id (deux etapes peuvent viser le meme
|
||||
// Tiers). Unicite uniquement sur l ordre dans la tournee.
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_tour_stop_position ON tour_stop (tour_id, position)');
|
||||
|
||||
$this->comment('tour_stop', '_table', 'Etapes ordonnees d une tournee (M6) — cible polymorphe (Tiers referentiel ou point custom). Pas de rapport (scope reduit V0.2).');
|
||||
$this->comment('tour_stop', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('tour_stop', 'tour_id', 'FK -> tour.id, ON DELETE CASCADE — tournee proprietaire de l etape.');
|
||||
$this->comment('tour_stop', 'tier_type', 'Type de cible : client | supplier | ... | custom (point libre). Resolu via VisitableInterface. Chaine ouverte (Assert\\Choice).');
|
||||
$this->comment('tour_stop', 'tier_id', 'Identifiant du Tiers referentiel cible (NULL si custom). Sans FK (cible polymorphe). RG-6.07 : aucune unicite.');
|
||||
$this->comment('tour_stop', 'address_id', 'Adresse precise visitee chez le Tiers (NULL si custom). Sans FK (client_address OU supplier_address). RG-6.03 : doit appartenir au Tiers.');
|
||||
$this->comment('tour_stop', 'custom_label', 'Libelle du point libre — obligatoire ssi tier_type = custom (RG-6.12), sinon NULL.');
|
||||
$this->comment('tour_stop', 'custom_address', 'Adresse texte du point libre (geocodee) — renseignee uniquement si custom.');
|
||||
$this->comment('tour_stop', 'custom_latitude', 'Latitude WGS84 du point libre (pin ajustable) — obligatoire ssi custom (RG-6.12).');
|
||||
$this->comment('tour_stop', 'custom_longitude', 'Longitude WGS84 du point libre — obligatoire ssi custom (RG-6.12).');
|
||||
$this->comment('tour_stop', 'position', 'Ordre de l etape dans la tournee (drag & drop). Unique par tournee (uq_tour_stop_position).');
|
||||
$this->comment('tour_stop', 'visit_minutes', 'Duree de visite specifique a l etape, en minutes — sinon tour.default_visit_minutes.');
|
||||
$this->comment('tour_stop', 'leg_distance_m', 'Cache : distance depuis l etape precedente, en metres (calcule). Lecture seule API (M6.4).');
|
||||
$this->comment('tour_stop', 'leg_duration_s', 'Cache : temps depuis l etape precedente, en secondes (calcule). Lecture seule API (M6.4).');
|
||||
$this->comment('tour_stop', 'eta', 'Heure d arrivee estimee a l etape (RG-6.11, calculee). Lecture seule API.');
|
||||
$this->addTimestampableBlamableComments('tour_stop');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique).
|
||||
*/
|
||||
private function addTimestampableBlamableComments(string $table): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||
$this->comment($table, $column, $description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
|
||||
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
|
||||
* tout echappement d apostrophe.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\FieldSales\Domain\Enum\TourStatus;
|
||||
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourProcessor;
|
||||
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider\TourProvider;
|
||||
use App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Tournee commerciale terrain (M6 § 4.2). Entite racine du module FieldSales :
|
||||
* porte le point de depart, l'heure de depart, la duree de visite par defaut,
|
||||
* le statut (cycle de vie RG-6.02) et la liste ordonnee d'etapes (TourStop).
|
||||
*
|
||||
* Decisions structurantes :
|
||||
* - Tournee PERSONNELLE (RG-6.01) : `owner` = commercial proprietaire, pose par
|
||||
* le TourProcessor au POST (jamais ecrit par le client). Le TourProvider filtre
|
||||
* la collection sur l'owner courant (admin / Bureau voient tout).
|
||||
* - owner reference l'utilisateur via UserInterface + resolve_target_entities
|
||||
* (-> User du module Core), comme le createdBy du trait Blamable : aucun import
|
||||
* direct du module Core (regle ABSOLUE n°1).
|
||||
* - status : enum PHP TourStatus stocke en chaine (Assert\Choice sur les valeurs
|
||||
* de l'enum -> 422 FR si valeur invalide). Defaut Draft a la creation.
|
||||
* - Soft delete (`deletedAt`) : le DELETE API pose deletedAt (TourProcessor),
|
||||
* le TourProvider exclut les tournees supprimees.
|
||||
* - total_distance_m / total_duration_s : cache d'affichage des derniers totaux
|
||||
* calcules (RG-6.11, lecture seule cote API ; alimente par le moteur de trajet
|
||||
* au ticket M6.4).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable.
|
||||
*
|
||||
* @phpstan-ignore-next-line owner est resolu en User (getId()) via resolve_target_entities
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'Tour',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('field_sales.tours.view')",
|
||||
normalizationContext: ['groups' => ['tour:read', 'default:read']],
|
||||
provider: TourProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('field_sales.tours.view')",
|
||||
// Detail : la tournee + ses etapes embarquees (tour:item:read porte
|
||||
// getStops(), tour_stop:read le contenu de chaque etape).
|
||||
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
|
||||
provider: TourProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('field_sales.tours.manage')",
|
||||
normalizationContext: ['groups' => ['tour:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['tour:write']],
|
||||
processor: TourProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('field_sales.tours.manage')",
|
||||
normalizationContext: ['groups' => ['tour:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['tour:write']],
|
||||
provider: TourProvider::class,
|
||||
processor: TourProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
// DELETE = soft delete (pose deletedAt) — cf. TourProcessor.
|
||||
security: "is_granted('field_sales.tours.manage')",
|
||||
provider: TourProvider::class,
|
||||
processor: TourProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineTourRepository::class)]
|
||||
#[ORM\Table(name: 'tour')]
|
||||
#[ORM\Index(name: 'idx_tour_owner', columns: ['owner_id'])]
|
||||
#[ORM\Index(name: 'idx_tour_status', columns: ['status'])]
|
||||
#[ORM\Index(name: 'idx_tour_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_tour_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_tour_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Tour implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['tour:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// Commercial proprietaire (RG-6.01). Pose par le TourProcessor au POST, donc
|
||||
// PAS de groupe d'ecriture et PAS d'Assert\NotNull (la validation s'execute
|
||||
// avant le processor) — la colonne NOT NULL en base est le garde-fou final.
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'owner_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Groups(['tour:read'])]
|
||||
private ?UserInterface $owner = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'Le nom de la tournée est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom de la tournée ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['tour:read', 'tour:write'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(name: 'tour_date', type: 'date_immutable')]
|
||||
#[Assert\NotNull(message: 'La date de la tournée est obligatoire.')]
|
||||
#[Groups(['tour:read', 'tour:write'])]
|
||||
private ?DateTimeImmutable $tourDate = null;
|
||||
|
||||
// Heure de depart (alimente les ETA, RG-6.11). Defaut 08:00 (pose dans le
|
||||
// constructeur). Colonne TIME -> DateTimeImmutable (partie date 1970 ignoree).
|
||||
#[ORM\Column(name: 'departure_time', type: 'time_immutable')]
|
||||
#[Groups(['tour:read', 'tour:write'])]
|
||||
private DateTimeImmutable $departureTime;
|
||||
|
||||
// Point de depart (site commercial ou adresse libre). NULL -> depart = 1re etape.
|
||||
#[ORM\Column(name: 'start_latitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
|
||||
#[Assert\Range(notInRangeMessage: 'La latitude doit être comprise entre {{ min }} et {{ max }}.', min: -90, max: 90)]
|
||||
#[Groups(['tour:read', 'tour:write'])]
|
||||
private ?string $startLatitude = null;
|
||||
|
||||
#[ORM\Column(name: 'start_longitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
|
||||
#[Assert\Range(notInRangeMessage: 'La longitude doit être comprise entre {{ min }} et {{ max }}.', min: -180, max: 180)]
|
||||
#[Groups(['tour:read', 'tour:write'])]
|
||||
private ?string $startLongitude = null;
|
||||
|
||||
#[ORM\Column(name: 'start_label', length: 180, nullable: true)]
|
||||
#[Assert\Length(max: 180, maxMessage: 'Le libellé du point de départ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['tour:read', 'tour:write'])]
|
||||
private ?string $startLabel = null;
|
||||
|
||||
#[ORM\Column(name: 'default_visit_minutes', type: 'smallint', options: ['default' => 30])]
|
||||
#[Assert\Positive(message: 'La durée de visite par défaut doit être un nombre positif.')]
|
||||
#[Groups(['tour:read', 'tour:write'])]
|
||||
private int $defaultVisitMinutes = 30;
|
||||
|
||||
// Statut stocke en chaine ; valeurs bornees a l'enum TourStatus (RG-6.02).
|
||||
#[ORM\Column(length: 20, options: ['default' => TourStatus::Draft->value])]
|
||||
#[Assert\Choice(callback: [TourStatus::class, 'values'], message: 'Le statut de la tournée est invalide.')]
|
||||
#[Groups(['tour:read', 'tour:write'])]
|
||||
private string $status = TourStatus::Draft->value;
|
||||
|
||||
// Derniers totaux calcules (cache d'affichage, RG-6.11). Lecture seule cote
|
||||
// API : alimentes par le moteur de trajet (M6.4), jamais ecrits par le client.
|
||||
#[ORM\Column(name: 'total_distance_m', nullable: true)]
|
||||
#[Groups(['tour:read'])]
|
||||
private ?int $totalDistanceM = null;
|
||||
|
||||
#[ORM\Column(name: 'total_duration_s', nullable: true)]
|
||||
#[Groups(['tour:read'])]
|
||||
private ?int $totalDurationS = null;
|
||||
|
||||
/** @var Collection<int, TourStop> */
|
||||
#[ORM\OneToMany(mappedBy: 'tour', targetEntity: TourStop::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $stops;
|
||||
|
||||
// Soft delete : pose par le TourProcessor sur DELETE, jamais expose en
|
||||
// ecriture. Le TourProvider exclut les tournees dont deletedAt n'est pas null.
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->stops = new ArrayCollection();
|
||||
$this->departureTime = new DateTimeImmutable('1970-01-01 08:00:00');
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getOwner(): ?UserInterface
|
||||
{
|
||||
return $this->owner;
|
||||
}
|
||||
|
||||
public function setOwner(?UserInterface $owner): static
|
||||
{
|
||||
$this->owner = $owner;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(?string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTourDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->tourDate;
|
||||
}
|
||||
|
||||
public function setTourDate(?DateTimeImmutable $tourDate): static
|
||||
{
|
||||
$this->tourDate = $tourDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDepartureTime(): DateTimeImmutable
|
||||
{
|
||||
return $this->departureTime;
|
||||
}
|
||||
|
||||
public function setDepartureTime(DateTimeImmutable $departureTime): static
|
||||
{
|
||||
$this->departureTime = $departureTime;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStartLatitude(): ?string
|
||||
{
|
||||
return $this->startLatitude;
|
||||
}
|
||||
|
||||
public function setStartLatitude(float|string|null $startLatitude): static
|
||||
{
|
||||
$this->startLatitude = null === $startLatitude ? null : (string) $startLatitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStartLongitude(): ?string
|
||||
{
|
||||
return $this->startLongitude;
|
||||
}
|
||||
|
||||
public function setStartLongitude(float|string|null $startLongitude): static
|
||||
{
|
||||
$this->startLongitude = null === $startLongitude ? null : (string) $startLongitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStartLabel(): ?string
|
||||
{
|
||||
return $this->startLabel;
|
||||
}
|
||||
|
||||
public function setStartLabel(?string $startLabel): static
|
||||
{
|
||||
$this->startLabel = $startLabel;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDefaultVisitMinutes(): int
|
||||
{
|
||||
return $this->defaultVisitMinutes;
|
||||
}
|
||||
|
||||
public function setDefaultVisitMinutes(int $defaultVisitMinutes): static
|
||||
{
|
||||
$this->defaultVisitMinutes = $defaultVisitMinutes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(string $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalDistanceM(): ?int
|
||||
{
|
||||
return $this->totalDistanceM;
|
||||
}
|
||||
|
||||
public function setTotalDistanceM(?int $totalDistanceM): static
|
||||
{
|
||||
$this->totalDistanceM = $totalDistanceM;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalDurationS(): ?int
|
||||
{
|
||||
return $this->totalDurationS;
|
||||
}
|
||||
|
||||
public function setTotalDurationS(?int $totalDurationS): static
|
||||
{
|
||||
$this->totalDurationS = $totalDurationS;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, TourStop> */
|
||||
#[Groups(['tour:item:read'])]
|
||||
public function getStops(): Collection
|
||||
{
|
||||
return $this->stops;
|
||||
}
|
||||
|
||||
public function addStop(TourStop $stop): static
|
||||
{
|
||||
if (!$this->stops->contains($stop)) {
|
||||
$this->stops->add($stop);
|
||||
$stop->setTour($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeStop(TourStop $stop): static
|
||||
{
|
||||
if ($this->stops->removeElement($stop) && $stop->getTour() === $this) {
|
||||
$stop->setTour(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourStopProcessor;
|
||||
use App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourStopRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Etape d'une tournee (M6 § 4.3). Une etape vise soit un Tiers du referentiel
|
||||
* (Client M1, Fournisseur M2, futur Prestataire) resolu de facon polymorphe via
|
||||
* le couple (`tierType`, `tierId`), soit un point libre `custom` (prospect / RDV
|
||||
* sans fiche : libelle + adresse + coordonnees saisis a la main).
|
||||
*
|
||||
* Choix de modelisation (spec § 3.1.bis) :
|
||||
* - PAS d'association Doctrine vers le Tiers ni vers l'adresse : la cible est
|
||||
* polymorphe (client_address OU supplier_address selon tierType), donc tierId
|
||||
* et addressId sont de simples entiers. La coherence « l'adresse appartient au
|
||||
* Tiers » (RG-6.03) est verifiee cote TourStopProcessor (acces lecture seule au
|
||||
* schema partage, sans import inter-module — regle ABSOLUE n°1).
|
||||
* - tierType est une chaine OUVERTE (Assert\Choice = types Visitable connus +
|
||||
* `custom`), extensible aux futurs Tiers sans toucher au module FieldSales.
|
||||
*
|
||||
* SCOPE REDUIT (V0.2) : pas de rapport de visite -> PAS de report_id, PAS de
|
||||
* arrived_at / check-in.
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable. Unicite (tour_id, position).
|
||||
*
|
||||
* Sous-ressource API (spec § 5, pattern ClientAddress) :
|
||||
* - POST /api/tours/{tourId}/stops : creation rattachee a la tournee parente
|
||||
* (Link toProperty 'tour', read:false), security field_sales.tours.manage.
|
||||
* - PATCH /api/tour_stops/{id} : edition (dont position = drag & drop).
|
||||
* - DELETE /api/tour_stops/{id} : suppression d'une etape.
|
||||
* - GET /api/tour_stops/{id} : lecture unitaire (security view). La lecture
|
||||
* courante des etapes passe par le detail de la tournee parente.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'TourStop',
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('field_sales.tours.view')",
|
||||
normalizationContext: ['groups' => ['tour_stop:read']],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/tours/{tourId}/stops',
|
||||
uriVariables: [
|
||||
'tourId' => new Link(fromClass: Tour::class, toProperty: 'tour'),
|
||||
],
|
||||
// read:false : comme ClientAddress, le Link toProperty resoudrait
|
||||
// l'enfant (SELECT WHERE tour = :id) et casserait en NonUniqueResult
|
||||
// des >= 2 etapes. La tournee parente est rattachee manuellement par
|
||||
// TourStopProcessor::linkParent (404 si absente).
|
||||
read: false,
|
||||
security: "is_granted('field_sales.tours.manage')",
|
||||
normalizationContext: ['groups' => ['tour_stop:read']],
|
||||
denormalizationContext: ['groups' => ['tour_stop:write']],
|
||||
processor: TourStopProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('field_sales.tours.manage')",
|
||||
normalizationContext: ['groups' => ['tour_stop:read']],
|
||||
denormalizationContext: ['groups' => ['tour_stop:write']],
|
||||
processor: TourStopProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('field_sales.tours.manage')",
|
||||
processor: TourStopProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineTourStopRepository::class)]
|
||||
#[ORM\Table(name: 'tour_stop')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_tour_stop_position', columns: ['tour_id', 'position'])]
|
||||
#[ORM\Index(name: 'idx_tour_stop_tour', columns: ['tour_id'])]
|
||||
#[ORM\Index(name: 'idx_tour_stop_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_tour_stop_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class TourStop implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/** Point libre (prospect / RDV sans fiche Tiers) — cf. RG-6.12. */
|
||||
public const string TIER_TYPE_CUSTOM = 'custom';
|
||||
|
||||
/**
|
||||
* Valeurs autorisees de `tierType` : types Visitable connus du referentiel
|
||||
* (client, supplier) + le point libre `custom`. Liste OUVERTE par nature
|
||||
* (de simples chaines, aucun import de classe d'un autre module) : un futur
|
||||
* Tiers (prestataire...) s'ajoute ici sans autre changement.
|
||||
*/
|
||||
public const array TIER_TYPES = ['client', 'supplier', self::TIER_TYPE_CUSTOM];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['tour_stop:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Tour::class, inversedBy: 'stops')]
|
||||
#[ORM\JoinColumn(name: 'tour_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Tour $tour = null;
|
||||
|
||||
#[ORM\Column(name: 'tier_type', length: 30)]
|
||||
#[Assert\NotBlank(message: 'Le type de cible de l\'étape est obligatoire.')]
|
||||
#[Assert\Choice(choices: self::TIER_TYPES, message: 'Le type de cible de l\'étape est invalide.')]
|
||||
#[Groups(['tour_stop:read', 'tour_stop:write'])]
|
||||
private ?string $tierType = null;
|
||||
|
||||
// Identifiant du Tiers referentiel (NULL si custom). Pas de FK : cible
|
||||
// polymorphe resolue via tierType (RG-6.07 : aucune unicite sur tierId).
|
||||
#[ORM\Column(name: 'tier_id', nullable: true)]
|
||||
#[Groups(['tour_stop:read', 'tour_stop:write'])]
|
||||
private ?int $tierId = null;
|
||||
|
||||
// Adresse precise visitee chez le Tiers (NULL si custom). Pas de FK : cible
|
||||
// polymorphe (client_address OU supplier_address). RG-6.03 : doit appartenir
|
||||
// au Tiers (verifie par le TourStopProcessor).
|
||||
#[ORM\Column(name: 'address_id', nullable: true)]
|
||||
#[Groups(['tour_stop:read', 'tour_stop:write'])]
|
||||
private ?int $addressId = null;
|
||||
|
||||
#[ORM\Column(name: 'custom_label', length: 180, nullable: true)]
|
||||
#[Assert\Length(max: 180, maxMessage: 'Le libellé du point libre ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['tour_stop:read', 'tour_stop:write'])]
|
||||
private ?string $customLabel = null;
|
||||
|
||||
#[ORM\Column(name: 'custom_address', length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'L\'adresse du point libre ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['tour_stop:read', 'tour_stop:write'])]
|
||||
private ?string $customAddress = null;
|
||||
|
||||
#[ORM\Column(name: 'custom_latitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
|
||||
#[Assert\Range(notInRangeMessage: 'La latitude doit être comprise entre {{ min }} et {{ max }}.', min: -90, max: 90)]
|
||||
#[Groups(['tour_stop:read', 'tour_stop:write'])]
|
||||
private ?string $customLatitude = null;
|
||||
|
||||
#[ORM\Column(name: 'custom_longitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
|
||||
#[Assert\Range(notInRangeMessage: 'La longitude doit être comprise entre {{ min }} et {{ max }}.', min: -180, max: 180)]
|
||||
#[Groups(['tour_stop:read', 'tour_stop:write'])]
|
||||
private ?string $customLongitude = null;
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
#[Assert\PositiveOrZero(message: 'La position de l\'étape doit être un nombre positif ou nul.')]
|
||||
#[Groups(['tour_stop:read', 'tour_stop:write'])]
|
||||
private int $position = 0;
|
||||
|
||||
// Duree de visite specifique (sinon tour.default_visit_minutes).
|
||||
#[ORM\Column(name: 'visit_minutes', type: 'smallint', nullable: true)]
|
||||
#[Assert\Positive(message: 'La durée de visite doit être un nombre positif.')]
|
||||
#[Groups(['tour_stop:read', 'tour_stop:write'])]
|
||||
private ?int $visitMinutes = null;
|
||||
|
||||
// Distance / temps depuis l'etape precedente (calcules — lecture seule API,
|
||||
// alimentes par le moteur de trajet au M6.4).
|
||||
#[ORM\Column(name: 'leg_distance_m', nullable: true)]
|
||||
#[Groups(['tour_stop:read'])]
|
||||
private ?int $legDistanceM = null;
|
||||
|
||||
#[ORM\Column(name: 'leg_duration_s', nullable: true)]
|
||||
#[Groups(['tour_stop:read'])]
|
||||
private ?int $legDurationS = null;
|
||||
|
||||
// Heure d'arrivee estimee (calculee, RG-6.11). Lecture seule API.
|
||||
#[ORM\Column(name: 'eta', type: 'time_immutable', nullable: true)]
|
||||
#[Groups(['tour_stop:read'])]
|
||||
private ?DateTimeImmutable $eta = null;
|
||||
|
||||
/**
|
||||
* RG-6.12 : coherence du point libre vs Tiers referentiel.
|
||||
* - `custom` : tierId / addressId doivent etre NULL ; customLabel et les
|
||||
* coordonnees (customLatitude / customLongitude) sont obligatoires.
|
||||
* - non-`custom` : tierId est obligatoire (cible du referentiel) et les
|
||||
* champs custom_* n'ont pas de sens (doivent rester NULL).
|
||||
*
|
||||
* Note : la coherence « l'adresse appartient au Tiers » (RG-6.03) n'est PAS
|
||||
* verifiable ici (acces BDD requis) -> portee par le TourStopProcessor.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCustomCoherence(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (self::TIER_TYPE_CUSTOM === $this->tierType) {
|
||||
if (null !== $this->tierId) {
|
||||
$context->buildViolation('Un point libre ne peut pas référencer un Tiers.')
|
||||
->atPath('tierId')->addViolation()
|
||||
;
|
||||
}
|
||||
if (null !== $this->addressId) {
|
||||
$context->buildViolation('Un point libre ne peut pas référencer une adresse.')
|
||||
->atPath('addressId')->addViolation()
|
||||
;
|
||||
}
|
||||
if (null === $this->customLabel || '' === trim($this->customLabel)) {
|
||||
$context->buildViolation('Le libellé du point libre est obligatoire.')
|
||||
->atPath('customLabel')->addViolation()
|
||||
;
|
||||
}
|
||||
if (null === $this->customLatitude) {
|
||||
$context->buildViolation('La latitude du point libre est obligatoire.')
|
||||
->atPath('customLatitude')->addViolation()
|
||||
;
|
||||
}
|
||||
if (null === $this->customLongitude) {
|
||||
$context->buildViolation('La longitude du point libre est obligatoire.')
|
||||
->atPath('customLongitude')->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Etape sur Tiers referentiel : tierId + addressId obligatoires (l'etape
|
||||
// vise une adresse precise du Tiers, RG-6.03), champs custom interdits.
|
||||
if (null === $this->tierId) {
|
||||
$context->buildViolation('Le Tiers de l\'étape est obligatoire.')
|
||||
->atPath('tierId')->addViolation()
|
||||
;
|
||||
}
|
||||
if (null === $this->addressId) {
|
||||
$context->buildViolation('L\'adresse de l\'étape est obligatoire.')
|
||||
->atPath('addressId')->addViolation()
|
||||
;
|
||||
}
|
||||
if (null !== $this->customLabel && '' !== trim($this->customLabel)) {
|
||||
$context->buildViolation('Un libellé de point libre n\'est autorisé que sur une étape « custom ».')
|
||||
->atPath('customLabel')->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTour(): ?Tour
|
||||
{
|
||||
return $this->tour;
|
||||
}
|
||||
|
||||
public function setTour(?Tour $tour): static
|
||||
{
|
||||
$this->tour = $tour;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTierType(): ?string
|
||||
{
|
||||
return $this->tierType;
|
||||
}
|
||||
|
||||
public function setTierType(?string $tierType): static
|
||||
{
|
||||
$this->tierType = $tierType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTierId(): ?int
|
||||
{
|
||||
return $this->tierId;
|
||||
}
|
||||
|
||||
public function setTierId(?int $tierId): static
|
||||
{
|
||||
$this->tierId = $tierId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAddressId(): ?int
|
||||
{
|
||||
return $this->addressId;
|
||||
}
|
||||
|
||||
public function setAddressId(?int $addressId): static
|
||||
{
|
||||
$this->addressId = $addressId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCustomLabel(): ?string
|
||||
{
|
||||
return $this->customLabel;
|
||||
}
|
||||
|
||||
public function setCustomLabel(?string $customLabel): static
|
||||
{
|
||||
$this->customLabel = $customLabel;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCustomAddress(): ?string
|
||||
{
|
||||
return $this->customAddress;
|
||||
}
|
||||
|
||||
public function setCustomAddress(?string $customAddress): static
|
||||
{
|
||||
$this->customAddress = $customAddress;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCustomLatitude(): ?string
|
||||
{
|
||||
return $this->customLatitude;
|
||||
}
|
||||
|
||||
public function setCustomLatitude(float|string|null $customLatitude): static
|
||||
{
|
||||
$this->customLatitude = null === $customLatitude ? null : (string) $customLatitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCustomLongitude(): ?string
|
||||
{
|
||||
return $this->customLongitude;
|
||||
}
|
||||
|
||||
public function setCustomLongitude(float|string|null $customLongitude): static
|
||||
{
|
||||
$this->customLongitude = null === $customLongitude ? null : (string) $customLongitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVisitMinutes(): ?int
|
||||
{
|
||||
return $this->visitMinutes;
|
||||
}
|
||||
|
||||
public function setVisitMinutes(?int $visitMinutes): static
|
||||
{
|
||||
$this->visitMinutes = $visitMinutes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLegDistanceM(): ?int
|
||||
{
|
||||
return $this->legDistanceM;
|
||||
}
|
||||
|
||||
public function setLegDistanceM(?int $legDistanceM): static
|
||||
{
|
||||
$this->legDistanceM = $legDistanceM;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLegDurationS(): ?int
|
||||
{
|
||||
return $this->legDurationS;
|
||||
}
|
||||
|
||||
public function setLegDurationS(?int $legDurationS): static
|
||||
{
|
||||
$this->legDurationS = $legDurationS;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEta(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->eta;
|
||||
}
|
||||
|
||||
public function setEta(?DateTimeImmutable $eta): static
|
||||
{
|
||||
$this->eta = $eta;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Domain\Enum;
|
||||
|
||||
/**
|
||||
* Cycle de vie d'une tournee (M6 § 4.2, RG-6.02). Transitions libres en V1
|
||||
* (aucune machine a etats imposee) : la valeur est simplement contrainte a
|
||||
* l'une des quatre etapes ci-dessous.
|
||||
*
|
||||
* Enum backed string : sert a la fois de source de verite des valeurs
|
||||
* autorisees (cf. values(), consommee par l'Assert\Choice de Tour::$status) et
|
||||
* de constantes typees pour le code metier (defaut Draft a la creation).
|
||||
*/
|
||||
enum TourStatus: string
|
||||
{
|
||||
/** Brouillon — tournee en cours de construction (etat initial au POST). */
|
||||
case Draft = 'draft';
|
||||
|
||||
/** Planifiee — etapes posees, prete a etre realisee. */
|
||||
case Planned = 'planned';
|
||||
|
||||
/** En cours — la tournee est en train d'etre effectuee. */
|
||||
case InProgress = 'in_progress';
|
||||
|
||||
/** Terminee — tournee realisee. */
|
||||
case Done = 'done';
|
||||
|
||||
/**
|
||||
* Liste des valeurs autorisees (cle stockee en base), pour l'Assert\Choice
|
||||
* de l'entite Tour. Source unique : ajouter un case suffit.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Domain\Repository;
|
||||
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Contrat du repository des tournees (M6). L'implementation Doctrine vit dans
|
||||
* Infrastructure/Doctrine (DoctrineTourRepository).
|
||||
*/
|
||||
interface TourRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Tour;
|
||||
|
||||
public function save(Tour $tour): void;
|
||||
|
||||
/**
|
||||
* QueryBuilder de liste des tournees actives (deletedAt IS NULL), triees par
|
||||
* date decroissante puis libelle. Si $owner est fourni, filtre sur le
|
||||
* proprietaire (RG-6.01 : la Commerciale ne voit que les siennes) ; null =
|
||||
* toutes les tournees (admin / Bureau).
|
||||
*/
|
||||
public function createListQueryBuilder(?UserInterface $owner = null): QueryBuilder;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
use App\Module\FieldSales\Domain\Repository\TourRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Tour>
|
||||
*/
|
||||
class DoctrineTourRepository extends ServiceEntityRepository implements TourRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Tour::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Tour
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(Tour $tour): void
|
||||
{
|
||||
$this->getEntityManager()->persist($tour);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(?UserInterface $owner = null): QueryBuilder
|
||||
{
|
||||
// Exclut toujours les tournees soft-deletees (RG : deletedAt IS NULL).
|
||||
$qb = $this->createQueryBuilder('t')
|
||||
->andWhere('t.deletedAt IS NULL')
|
||||
->orderBy('t.tourDate', 'DESC')
|
||||
->addOrderBy('t.label', 'ASC')
|
||||
;
|
||||
|
||||
// RG-6.01 : filtre proprietaire pour la Commerciale (owner non null).
|
||||
if (null !== $owner) {
|
||||
$qb->andWhere('t.owner = :owner')->setParameter('owner', $owner);
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\FieldSales\Domain\Entity\TourStop;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TourStop>
|
||||
*/
|
||||
class DoctrineTourStopRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TourStop::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales\Infrastructure\Tier;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Verifie qu'une adresse appartient bien a un Tiers du referentiel (RG-6.03),
|
||||
* sans importer aucune classe des modules Commercial (Client / Supplier) — regle
|
||||
* ABSOLUE n°1.
|
||||
*
|
||||
* Approche : lecture seule du schema PARTAGE par nom de table. Une etape de
|
||||
* tournee cible un Tiers polymorphe (tierType -> table d'adresses + colonne FK
|
||||
* du proprietaire). On interroge la table d'adresses correspondante en DBAL pur :
|
||||
* aucune dependance de code vers Commercial, seulement une lecture du schema
|
||||
* commun (integration « shared database » assumee du monolithe modulaire).
|
||||
*
|
||||
* Extensible : ajouter un type Visitable (ex: prestataire) = une entree dans
|
||||
* self::ADDRESS_TABLES.
|
||||
*/
|
||||
final class TierAddressResolver
|
||||
{
|
||||
/**
|
||||
* Mapping tierType -> [table d'adresses, colonne FK du Tiers proprietaire].
|
||||
* Aligne sur les tables M1 (client_address.client_id) et M2
|
||||
* (supplier_address.supplier_id). Les identifiants sont des constantes
|
||||
* statiques (jamais d'entree utilisateur) -> pas de risque d'injection.
|
||||
*
|
||||
* @var array<string, array{table: string, ownerColumn: string}>
|
||||
*/
|
||||
private const array ADDRESS_TABLES = [
|
||||
'client' => ['table' => 'client_address', 'ownerColumn' => 'client_id'],
|
||||
'supplier' => ['table' => 'supplier_address', 'ownerColumn' => 'supplier_id'],
|
||||
];
|
||||
|
||||
public function __construct(private readonly Connection $connection) {}
|
||||
|
||||
/**
|
||||
* Vrai si l'adresse $addressId existe ET appartient au Tiers ($tierType,
|
||||
* $tierId). Faux si l'adresse n'existe pas, appartient a un autre Tiers, ou
|
||||
* si le type n'est pas resoluble en table d'adresses (ex: custom).
|
||||
*/
|
||||
public function addressBelongsToTier(string $tierType, int $tierId, int $addressId): bool
|
||||
{
|
||||
$mapping = self::ADDRESS_TABLES[$tierType] ?? null;
|
||||
if (null === $mapping) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Noms de table/colonne issus d'une whitelist de constantes (jamais de
|
||||
// l'entree utilisateur) ; seuls les ids sont parametres.
|
||||
$sql = sprintf(
|
||||
'SELECT 1 FROM %s WHERE id = :addressId AND %s = :tierId',
|
||||
$mapping['table'],
|
||||
$mapping['ownerColumn'],
|
||||
);
|
||||
|
||||
$found = $this->connection->fetchOne($sql, [
|
||||
'addressId' => $addressId,
|
||||
'tierId' => $tierId,
|
||||
]);
|
||||
|
||||
return false !== $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le tierType cible un Tiers du referentiel adressable (par
|
||||
* opposition au point libre `custom`, qui n'a pas de table d'adresses).
|
||||
*/
|
||||
public function isResolvableTierType(string $tierType): bool
|
||||
{
|
||||
return isset(self::ADDRESS_TABLES[$tierType]);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,15 @@ final class BusinessRoles
|
||||
*/
|
||||
public const string COMMERCIALE = 'commerciale';
|
||||
|
||||
/**
|
||||
* Role metier « Bureau » — code de Role RBAC. Utilise par FieldSales (M6,
|
||||
* RG-6.01) : le Bureau voit TOUTES les tournees en lecture (comme l'admin),
|
||||
* la Commerciale ne voit que les siennes. Reference ici (Shared) pour que le
|
||||
* TourProvider raisonne sur le role metier via BusinessRoleAwareInterface
|
||||
* sans importer le RbacSeeder du module Core (regle ABSOLUE n°1).
|
||||
*/
|
||||
public const string BUREAU = 'bureau';
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
// Classe de constantes : non instanciable.
|
||||
|
||||
@@ -369,6 +369,44 @@ final class ColumnCommentsCatalog
|
||||
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
||||
'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
// === M6.3 FieldSales (ERP-124) — miroir des COMMENT de la migration
|
||||
// Version20260611140000 pour le chemin schema:update (dev/test). ===
|
||||
|
||||
'tour' => [
|
||||
'_table' => 'Tournees commerciales terrain (M6 FieldSales) — personnelles (owner), soft-deletables (deleted_at).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'owner_id' => 'Commercial proprietaire de la tournee (RG-6.01, personnelle) — FK -> "user".id, ON DELETE RESTRICT. Pose au POST par le TourProcessor.',
|
||||
'label' => 'Nom libre de la tournee (NotBlank, <= 120 caracteres).',
|
||||
'tour_date' => 'Date de realisation de la tournee (NotNull).',
|
||||
'departure_time' => 'Heure de depart, alimente les ETA (RG-6.11). Defaut applicatif 08:00 (constructeur).',
|
||||
'start_latitude' => 'Latitude WGS84 du point de depart (site commercial ou adresse libre). NULL -> depart = 1re etape.',
|
||||
'start_longitude' => 'Longitude WGS84 du point de depart. NULL -> depart = 1re etape.',
|
||||
'start_label' => 'Libelle affichable du point de depart (<= 180 caracteres). Optionnel.',
|
||||
'default_visit_minutes' => 'Duree de visite par defaut d une etape, en minutes (defaut 30) — utilisee si l etape ne fixe pas sa propre duree.',
|
||||
'status' => 'Cycle de vie (RG-6.02) : draft | planned | in_progress | done (enum TourStatus). Transitions libres en V1. Defaut draft.',
|
||||
'total_distance_m' => 'Cache d affichage : derniere distance totale calculee, en metres (RG-6.11). Lecture seule API, alimente par le moteur de trajet (M6.4).',
|
||||
'total_duration_s' => 'Cache d affichage : derniere duree totale calculee, en secondes (RG-6.11). Lecture seule API.',
|
||||
'deleted_at' => 'Horodatage du soft-delete — pose par le DELETE API. Null = tournee active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'tour_stop' => [
|
||||
'_table' => 'Etapes ordonnees d une tournee (M6) — cible polymorphe (Tiers referentiel ou point custom). Pas de rapport (scope reduit V0.2).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'tour_id' => 'FK -> tour.id, ON DELETE CASCADE — tournee proprietaire de l etape.',
|
||||
'tier_type' => 'Type de cible : client | supplier | ... | custom (point libre). Resolu via VisitableInterface. Chaine ouverte (Assert\Choice).',
|
||||
'tier_id' => 'Identifiant du Tiers referentiel cible (NULL si custom). Sans FK (cible polymorphe). RG-6.07 : aucune unicite.',
|
||||
'address_id' => 'Adresse precise visitee chez le Tiers (NULL si custom). Sans FK (client_address OU supplier_address). RG-6.03 : doit appartenir au Tiers.',
|
||||
'custom_label' => 'Libelle du point libre — obligatoire ssi tier_type = custom (RG-6.12), sinon NULL.',
|
||||
'custom_address' => 'Adresse texte du point libre (geocodee) — renseignee uniquement si custom.',
|
||||
'custom_latitude' => 'Latitude WGS84 du point libre (pin ajustable) — obligatoire ssi custom (RG-6.12).',
|
||||
'custom_longitude' => 'Longitude WGS84 du point libre — obligatoire ssi custom (RG-6.12).',
|
||||
'position' => 'Ordre de l etape dans la tournee (drag & drop). Unique par tournee (uq_tour_stop_position).',
|
||||
'visit_minutes' => 'Duree de visite specifique a l etape, en minutes — sinon tour.default_visit_minutes.',
|
||||
'leg_distance_m' => 'Cache : distance depuis l etape precedente, en metres (calcule). Lecture seule API (M6.4).',
|
||||
'leg_duration_s' => 'Cache : temps depuis l etape precedente, en secondes (calcule). Lecture seule API (M6.4).',
|
||||
'eta' => 'Heure d arrivee estimee a l etape (RG-6.11, calculee). Lecture seule API.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
|
||||
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
|
||||
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
|
||||
// Le Choice (valeurs de l'enum TourStatus) borne les valeurs (<= 11 < 20).
|
||||
'Tour::status' => 'Choice (cas TourStatus) borne deja les valeurs.',
|
||||
// Le Choice {client,supplier,custom} borne les valeurs (<= 8 < 30).
|
||||
'TourStop::tierType' => 'Choice {client,supplier,custom} borne deja les valeurs.',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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