diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 5689fb5..e5ad43b 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -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'] diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index f40749a..47d7555 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -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 diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index ed29235..21da97c 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -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", diff --git a/migrations/Version20260611140000.php b/migrations/Version20260611140000.php new file mode 100644 index 0000000..efaa3af --- /dev/null +++ b/migrations/Version20260611140000.php @@ -0,0 +1,218 @@ + `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, + )); + } +} diff --git a/src/Module/FieldSales/Domain/Entity/Tour.php b/src/Module/FieldSales/Domain/Entity/Tour.php new file mode 100644 index 0000000..23244c0 --- /dev/null +++ b/src/Module/FieldSales/Domain/Entity/Tour.php @@ -0,0 +1,359 @@ + 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 */ + #[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 */ + #[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; + } +} diff --git a/src/Module/FieldSales/Domain/Entity/TourStop.php b/src/Module/FieldSales/Domain/Entity/TourStop.php new file mode 100644 index 0000000..3bbf5db --- /dev/null +++ b/src/Module/FieldSales/Domain/Entity/TourStop.php @@ -0,0 +1,406 @@ + 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; + } +} diff --git a/src/Module/FieldSales/Domain/Enum/TourStatus.php b/src/Module/FieldSales/Domain/Enum/TourStatus.php new file mode 100644 index 0000000..b4a326b --- /dev/null +++ b/src/Module/FieldSales/Domain/Enum/TourStatus.php @@ -0,0 +1,40 @@ + + */ + public static function values(): array + { + return array_map(static fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/src/Module/FieldSales/Domain/Repository/TourRepositoryInterface.php b/src/Module/FieldSales/Domain/Repository/TourRepositoryInterface.php new file mode 100644 index 0000000..d7247ce --- /dev/null +++ b/src/Module/FieldSales/Domain/Repository/TourRepositoryInterface.php @@ -0,0 +1,28 @@ + + */ +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); + } +} diff --git a/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourStopProcessor.php b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourStopProcessor.php new file mode 100644 index 0000000..a8a1458 --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourStopProcessor.php @@ -0,0 +1,129 @@ + + */ +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); + } +} diff --git a/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Provider/TourProvider.php b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Provider/TourProvider.php new file mode 100644 index 0000000..80e6b8c --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Provider/TourProvider.php @@ -0,0 +1,112 @@ + + */ +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 $context + * + * @return list|Paginator + */ + 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 $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 $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); + } +} diff --git a/src/Module/FieldSales/Infrastructure/Doctrine/DoctrineTourRepository.php b/src/Module/FieldSales/Infrastructure/Doctrine/DoctrineTourRepository.php new file mode 100644 index 0000000..ca8cbd6 --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/Doctrine/DoctrineTourRepository.php @@ -0,0 +1,51 @@ + + */ +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; + } +} diff --git a/src/Module/FieldSales/Infrastructure/Doctrine/DoctrineTourStopRepository.php b/src/Module/FieldSales/Infrastructure/Doctrine/DoctrineTourStopRepository.php new file mode 100644 index 0000000..1a50ccf --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/Doctrine/DoctrineTourStopRepository.php @@ -0,0 +1,20 @@ + + */ +class DoctrineTourStopRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TourStop::class); + } +} diff --git a/src/Module/FieldSales/Infrastructure/Tier/TierAddressResolver.php b/src/Module/FieldSales/Infrastructure/Tier/TierAddressResolver.php new file mode 100644 index 0000000..0ad16d2 --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/Tier/TierAddressResolver.php @@ -0,0 +1,76 @@ + 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 + */ + 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]); + } +} diff --git a/src/Shared/Domain/Security/BusinessRoles.php b/src/Shared/Domain/Security/BusinessRoles.php index 4270878..4cc8e75 100644 --- a/src/Shared/Domain/Security/BusinessRoles.php +++ b/src/Shared/Domain/Security/BusinessRoles.php @@ -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. diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 6a7d740..68e57b3 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -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(), ]; } diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index 9e3d2eb..505c5df 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -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.', ]; /** diff --git a/tests/Module/FieldSales/Api/AbstractFieldSalesApiTestCase.php b/tests/Module/FieldSales/Api/AbstractFieldSalesApiTestCase.php new file mode 100644 index 0000000..0ae2573 --- /dev/null +++ b/tests/Module/FieldSales/Api/AbstractFieldSalesApiTestCase.php @@ -0,0 +1,146 @@ +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 $body + * + * @return array + */ + 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(); + } +} diff --git a/tests/Module/FieldSales/Api/TourApiTest.php b/tests/Module/FieldSales/Api/TourApiTest.php new file mode 100644 index 0000000..8a0882b --- /dev/null +++ b/tests/Module/FieldSales/Api/TourApiTest.php @@ -0,0 +1,332 @@ +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, + ]); + } +}