Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0052eab1fe | |||
| be9204eca7 | |||
| de4aaa1d64 |
+2
-2
@@ -24,6 +24,7 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/intl": "8.0.*",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
@@ -95,7 +96,6 @@
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/http-client": "8.0.*"
|
||||
"symfony/browser-kit": "8.0.*"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+175
-175
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
|
||||
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -5412,6 +5412,180 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.13",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
|
||||
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-24T09:58:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v8.0.8",
|
||||
@@ -11785,180 +11959,6 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/process",
|
||||
"version": "v8.0.8",
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
use App\Module\Catalog\CatalogModule;
|
||||
use App\Module\Commercial\CommercialModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\FieldSales\FieldSalesModule;
|
||||
use App\Module\Sites\SitesModule;
|
||||
|
||||
return [
|
||||
@@ -11,4 +12,5 @@ return [
|
||||
CommercialModule::class,
|
||||
SitesModule::class,
|
||||
CatalogModule::class,
|
||||
FieldSalesModule::class,
|
||||
];
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -41,6 +41,13 @@ doctrine:
|
||||
# Permet au module Commercial de referencer une Category via le contrat
|
||||
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||
# NOTE (M6 / VisitableInterface) : VisitableInterface n'apparait PAS ici.
|
||||
# resolve_target_entities mappe un contrat -> UNE seule classe concrete,
|
||||
# or ce contrat a plusieurs implementations (Client M1, Supplier M2, et
|
||||
# Prestataire a venir). FieldSales ne reference donc pas un Tiers via une
|
||||
# association Doctrine mais via le couple polymorphe (tier_type, tier_id)
|
||||
# de tour_stop, resolu par un service a partir de getVisitableType()
|
||||
# (ERP-124). Aucune ligne resolve_target_entities n'est requise/possible.
|
||||
mappings:
|
||||
Core:
|
||||
type: attribute
|
||||
@@ -80,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
|
||||
|
||||
|
||||
@@ -33,3 +33,16 @@ services:
|
||||
|
||||
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
||||
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
||||
|
||||
# Geocodage des adresses Tiers (M6.1) : BAN api-adresse.data.gouv.fr.
|
||||
App\Shared\Domain\Contract\GeocoderInterface:
|
||||
alias: App\Shared\Infrastructure\Geocoding\BanGeocoder
|
||||
|
||||
# En test : geocodeur en memoire, deterministe et sans reseau (les tests
|
||||
# fonctionnels d'adresse ne doivent jamais appeler la BAN reelle).
|
||||
when@test:
|
||||
services:
|
||||
App\Tests\Fixtures\Geocoding\InMemoryGeocoder: ~
|
||||
|
||||
App\Shared\Domain\Contract\GeocoderInterface:
|
||||
alias: App\Tests\Fixtures\Geocoding\InMemoryGeocoder
|
||||
|
||||
@@ -61,6 +61,23 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Tournées" (module field_sales, M6) : planification de tournees
|
||||
// commerciales terrain. Transverse Clients/Fournisseurs. Masquee si le module
|
||||
// field_sales est desactivee (cle `module`) ou si l'user n'a pas la
|
||||
// permission field_sales.tours.view.
|
||||
[
|
||||
'label' => 'sidebar.field_sales.section',
|
||||
'icon' => 'mdi:map-marker-path',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.field_sales.tours',
|
||||
'to' => '/tours',
|
||||
'icon' => 'mdi:map-marker-path',
|
||||
'module' => 'field_sales',
|
||||
'permission' => 'field_sales.tours.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Administration" : regroupe toutes les pages de configuration
|
||||
// applicative (RBAC, users, sites, audit log).
|
||||
//
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M6
|
||||
nom: "Tournées commerciales terrain"
|
||||
ecran: tournees-terrain
|
||||
owner_spec: Matthieu
|
||||
backup_spec: ""
|
||||
version: V0.2
|
||||
# Historique :
|
||||
# V0.2 (2026-06-11) — RÉDUCTION DE SCOPE : suppression du volet « rapport de visite »
|
||||
# (entité VisitReport, fichiers, offres de prix, note /5, saisie vocale, historique des
|
||||
# visites) et du mode terrain mobile dédié. Périmètre recentré sur : géolocalisation,
|
||||
# carte interactive, planification de tournées, et onglet « Carte » dans les fiches Tiers.
|
||||
# V0.1 (2026-06-11) — Rédaction initiale (inspirée de Badger Maps, SPOTIO, Portatour, Nomadia).
|
||||
date_redaction: 2026-06-11
|
||||
|
||||
# === LIENS ===
|
||||
spec_front: ./spec-front.md
|
||||
maquette_figma: ""
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 28 # M6 — Tournées commerciales terrain (projet STARSEED #6)
|
||||
lesstime_project_id: 6
|
||||
statut_global: en_dev
|
||||
|
||||
# === DÉPENDANCES AMONT ===
|
||||
depend_de:
|
||||
- M1-clients # Client / ClientAddress (cible de visite + onglet Carte)
|
||||
- M2-suppliers # Supplier / SupplierAddress (cible de visite + onglet Carte)
|
||||
- Sites # rattachement site d'une adresse (déjà en place)
|
||||
- Core # User (commercial), Role, Permission, JWT
|
||||
- Shared # TimestampableBlamableTrait + contrats inter-modules
|
||||
---
|
||||
|
||||
# Spec — Module 6 : Tournées commerciales terrain (`field_sales`)
|
||||
|
||||
> **Périmètre V0.2 (réduit)** : géolocalisation des adresses Tiers, carte interactive, planification
|
||||
> de tournées (étapes, optimisation, navigation Waze/Maps, feuille de route PDF) et onglet « Carte »
|
||||
> dans les fiches Client/Fournisseur. **Hors scope : tout rapport de visite** (compte-rendu, note,
|
||||
> offres de prix, fichiers, saisie vocale) et le mode terrain mobile dédié.
|
||||
|
||||
## 1. Contexte & objectif
|
||||
|
||||
Donner aux commerciaux terrain (technico-commerciaux agricoles : visites d'exploitations, coopératives,
|
||||
négoces) un outil de **planification de tournées** intégré à Starseed, reposant sur le référentiel Tiers
|
||||
existant (Clients M1 + Fournisseurs M2). Fonctionne sur **desktop et mobile/tablette** (responsive, pas
|
||||
d'offline en V1).
|
||||
|
||||
Le commercial doit pouvoir :
|
||||
|
||||
1. Voir ses Tiers sur une **carte interactive** (pins colorés par type : client / fournisseur / prospect / custom).
|
||||
2. **Construire une tournée lui-même** : ajouter des étapes (une étape = une adresse précise d'un Tiers ou un
|
||||
point libre), les **réordonner en drag & drop**, fixer une **heure de départ**.
|
||||
3. Obtenir le **temps total** et le **temps entre chaque étape** (calcul auto), avec heure d'arrivée estimée.
|
||||
4. Cliquer **« Trajet logique »** (V1, heuristique gratuite) puis **« Optimiser »** (V2, routier réel) pour
|
||||
ordonner les étapes au mieux.
|
||||
5. **Lancer la navigation** (Waze / Google Maps / Plan) vers une étape en un tap.
|
||||
6. **Dupliquer** une tournée et **exporter une feuille de route PDF**.
|
||||
7. Consulter un **onglet « Carte »** dans la fiche Client/Fournisseur affichant les adresses géolocalisées du Tiers.
|
||||
|
||||
## 2. Inspiration — logiciels de tournée de référence
|
||||
|
||||
| Logiciel | Pattern repris | Application Starseed |
|
||||
|---|---|---|
|
||||
| **Badger Maps** | *Lasso tool* : on entoure des Tiers sur la carte → route optimisée auto. Pins colorés par type. | Sélection lasso/rectangle sur la carte pour bâtir la tournée. Pins par type. |
|
||||
| **SPOTIO** | Réordonnancement **drag & drop**, lieu de départ + bouton *Optimize*. | Liste d'étapes draggable, point de départ paramétrable, boutons « Trajet logique » / « Optimiser ». |
|
||||
| **Portatour** | Optimisation en quelques secondes, temps entre RDV. | Durée de visite paramétrable par étape, intégrée au temps total. |
|
||||
| **Nomadia Field Sales** | Carte + tournée dans un outil mobile responsive. | Écran de planification responsive desktop + mobile. |
|
||||
|
||||
## 3. Décisions d'architecture
|
||||
|
||||
### 3.1 Nouveau module `field_sales`
|
||||
|
||||
La tournée est **transverse** : elle vise aussi bien des Clients (M1) que des Fournisseurs (M2). Module dédié
|
||||
`src/Module/FieldSales/` (ID `field_sales`, label « Tournées »), `REQUIRED = false` (activable via
|
||||
`config/modules.php`).
|
||||
|
||||
**Règle ABSOLUE n°1 respectée** : `FieldSales` n'importe **aucune** classe de `Commercial`. Il référence les
|
||||
Tiers visités via un **contrat partagé** `App\Shared\Domain\Contract\VisitableInterface` (`getId()`,
|
||||
`getDisplayName()`, `getVisitableType()` = `client|supplier`) résolu par `resolve_target_entities`, comme
|
||||
`ClientAddress` référence `SiteInterface` / `CategoryInterface`.
|
||||
|
||||
### 3.1.bis Une étape vise tout Tiers — et même un point libre
|
||||
|
||||
Une étape n'est pas limitée à Client/Fournisseur : elle vise **tout type de Tiers** (Client, Fournisseur,
|
||||
**Prestataire** à venir) via `VisitableInterface` (extensible sans toucher au module FieldSales), **ou un point
|
||||
`custom`** (prospect/RDV sans fiche : libellé + adresse + coordonnées saisis à la main). L'enum `tier_type` est
|
||||
volontairement **ouvert** (string + Assert\Choice = types Visitable enregistrés + `custom`).
|
||||
|
||||
### 3.2 Géolocalisation portée par l'adresse Tiers — **FAIT (ticket M6.1 / ERP-122)**
|
||||
|
||||
`latitude` / `longitude` / `geo_manual` / `geocoded_at` sur `client_address` et `supplier_address`, géocodage
|
||||
api-adresse.data.gouv.fr + pin ajustable. Prérequis routage : une étape sans coordonnées reste utilisable mais
|
||||
**exclue du calcul de trajet** (badge « à géolocaliser »).
|
||||
|
||||
### 3.3 Carte interactive — Leaflet + OpenStreetMap (pas Google Maps JS)
|
||||
|
||||
Pour l'**affichage** carte/pins : **Leaflet** + tuiles **OpenStreetMap** (ou IGN). Gratuit, RGPD-friendly, pas
|
||||
de clé facturée pour le rendu. Composant carte encapsulé dans `frontend/modules/field-sales/` ; côté
|
||||
formulaire/filtre on reste sur les composants `Malio*`. La carte est une **exception documentée** à
|
||||
`@malio/layer-ui` (type non couvert). Le **routing réel** (matrice de temps) est un service distinct (§ 3.4).
|
||||
|
||||
### 3.4 Stratégie de calcul de trajet — phasée
|
||||
|
||||
| Phase | Bouton | Moteur | Coût |
|
||||
|---|---|---|---|
|
||||
| **V1** | « Trajet logique » | Heuristique maison **plus proche voisin** (Haversine), départ fixé. Temps estimé = distance × vitesse moyenne paramétrable. | 0 € |
|
||||
| **V2** | « Optimiser » | **Matrix API** (temps routiers réels) + optimisation TSP (OpenRouteService / OSRM / Mapbox). | Par appel (cache, debounce) |
|
||||
|
||||
Contrat `RouteEngineInterface` (`computeMatrix`, `optimizeOrder`, `estimateLegDurations`) posé dès la V1 avec
|
||||
`HaversineRouteEngine`. La V2 ajoute `OrsRouteEngine` sans toucher au front. **On n'écrit jamais l'algo routier
|
||||
— on branche un fournisseur.**
|
||||
|
||||
### 3.5 IDs entier auto-increment, Audit, Timestampable/Blamable
|
||||
|
||||
Cohérent avec M0/M1/M2. Toutes les entités métier : `#[Auditable]`, `implements TimestampableInterface,
|
||||
BlamableInterface` + `use TimestampableBlamableTrait`. Entités auditées : `Tour`, `TourStop`.
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
### 4.1 Adresses Tiers (M1 + M2) — **FAIT (ERP-122)**
|
||||
|
||||
Colonnes `latitude` NUMERIC(10,7), `longitude` NUMERIC(10,7), `geo_manual` BOOLEAN, `geocoded_at` TIMESTAMPTZ
|
||||
sur `client_address` et `supplier_address` ; contrat `GeolocatableAddressInterface` côté `Shared`.
|
||||
|
||||
### 4.2 `Tour` (tournée) — `tour`
|
||||
|
||||
| Champ | Type | Règle |
|
||||
|---|---|---|
|
||||
| `id` | int PK | |
|
||||
| `owner_id` | FK User | Commercial propriétaire. Tournée **personnelle** (RG-6.01). |
|
||||
| `label` | varchar(120) | Nom libre. NotBlank. |
|
||||
| `tour_date` | date | Date de réalisation. NotBlank. |
|
||||
| `departure_time` | time | Heure de départ (alimente les ETA). Défaut 08:00. |
|
||||
| `start_latitude` / `start_longitude` | numeric null | Point de départ (site commercial ou adresse libre). NULL → départ = 1re étape. |
|
||||
| `start_label` | varchar(180) null | Libellé du point de départ. |
|
||||
| `default_visit_minutes` | smallint default 30 | Durée de visite par défaut (temps total). |
|
||||
| `status` | enum `draft\|planned\|in_progress\|done` | Cycle de vie (RG-6.02). |
|
||||
| `total_distance_m` / `total_duration_s` | int null | Derniers totaux calculés (cache d'affichage). |
|
||||
|
||||
`#[Auditable]`, Timestampable/Blamable, soft delete (`deleted_at`). `GetCollection` paginée, filtrée par owner.
|
||||
|
||||
### 4.3 `TourStop` (étape) — `tour_stop`
|
||||
|
||||
| Champ | Type | Règle |
|
||||
|---|---|---|
|
||||
| `id` | int PK | |
|
||||
| `tour_id` | FK Tour | onDelete CASCADE. |
|
||||
| `tier_type` | string `client\|supplier\|…\|custom` | Cible (résolue via `VisitableInterface`). `custom` = point libre. |
|
||||
| `tier_id` | int null | ID du Tiers référentiel. NULL si `custom`. |
|
||||
| `address_id` | int null | Adresse précise visitée (un Tiers a plusieurs adresses — RG-6.03). NULL si `custom`. |
|
||||
| `custom_label` | varchar(180) null | Libellé du point libre (obligatoire ssi `custom`). |
|
||||
| `custom_address` | varchar(255) null | Adresse texte du point libre (ssi `custom`), géocodée. |
|
||||
| `custom_latitude` / `custom_longitude` | numeric null | Coordonnées du point libre (pin ajustable). |
|
||||
| `position` | smallint | Ordre dans la tournée (drag & drop). |
|
||||
| `visit_minutes` | smallint null | Durée de visite spécifique (sinon `tour.default_visit_minutes`). |
|
||||
| `leg_distance_m` / `leg_duration_s` | int null | Distance/temps **depuis l'étape précédente** (calculés). |
|
||||
| `eta` | time null | Heure d'arrivée estimée. |
|
||||
|
||||
`#[Auditable]`, Timestampable/Blamable. Unicité `(tour_id, position)`. **Pas** de rapport rattaché (scope réduit).
|
||||
|
||||
> Deux étapes peuvent viser le même Tiers (RG-6.07) — pas d'unicité sur `tier_id`.
|
||||
|
||||
## 5. API (API Platform — providers/processors, jamais de controller)
|
||||
|
||||
| Méthode | Endpoint | Sécurité | Note |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/tours` | `field_sales.tours.view` | Paginé, filtré sur `owner` courant (admin/bureau voient tout). |
|
||||
| POST | `/api/tours` | `field_sales.tours.manage` | Crée une tournée draft. |
|
||||
| GET/PATCH/DELETE | `/api/tours/{id}` | view / manage | DELETE = soft delete. |
|
||||
| POST | `/api/tours/{tourId}/stops` | `field_sales.tours.manage` | Sous-ressource (Link toProperty `tour`, pattern ClientAddress). |
|
||||
| PATCH/DELETE | `/api/tour_stops/{id}` | `field_sales.tours.manage` | PATCH `position` = drag & drop. |
|
||||
| POST | `/api/tours/{id}/compute` | `field_sales.tours.manage` | Recalcule legs + ETA + totaux (`HaversineRouteEngine`). |
|
||||
| POST | `/api/tours/{id}/optimize` | `field_sales.tours.manage` | Réordonne via `optimizeOrder()` puis recompute. |
|
||||
| POST | `/api/tours/{id}/duplicate` | `field_sales.tours.manage` | Duplique étapes + départ à une nouvelle `tourDate` (RG-6.13). |
|
||||
| GET | `/api/tours/{id}/roadbook.pdf` | `field_sales.tours.view` | Feuille de route PDF (skill `pdf`). |
|
||||
| GET | `/api/visitable_tiers?bbox=...&q=...&type=client,supplier` | `field_sales.tours.view` | Pins dans la zone visible (carte). Paginé / `?pagination=false`. |
|
||||
|
||||
Toutes les collections sont **paginées** (règle ABSOLUE n°13). `/api/visitable_tiers` retourne un Paginator,
|
||||
borné par `bbox`.
|
||||
|
||||
## 6. Écrans
|
||||
|
||||
### 6.1 Planification de tournée (carte interactive — responsive desktop + mobile)
|
||||
|
||||
Layout **split** inspiré de Badger/SPOTIO :
|
||||
|
||||
- **Carte interactive Leaflet** : pins des Tiers de la zone (couleur par type, filtrables). Sélection
|
||||
**lasso/rectangle** → ajoute les Tiers entourés comme étapes. Clic pin → popup (nom, adresse, « + Ajouter »).
|
||||
Tracé de la tournée dessiné par-dessus (polyline numérotée).
|
||||
- **Panneau tournée** : nom, date, **heure de départ**, point de départ, liste d'**étapes draggable**
|
||||
(n° + nom + adresse + ETA + temps depuis étape précédente), totaux (distance / durée / nb visites).
|
||||
Boutons **« Trajet logique »**, **« Optimiser »**, **« Dupliquer »**, **« PDF »**.
|
||||
- Chaque étape : menu **« Y aller »** (Waze / Google Maps / Plan via deep links), « Voir le Tiers ».
|
||||
- Ajout d'un **point libre `custom`** (libellé + adresse + pin).
|
||||
- En mobile, layout empilé : la navigation se fait via le bouton « Y aller » de chaque étape (pas de mode
|
||||
terrain dédié).
|
||||
|
||||
### 6.2 Onglet « Carte » dans la fiche Client / Fournisseur
|
||||
|
||||
Nouvel onglet **« Carte »** dans la fiche **Client (M1)** et **Fournisseur (M2)** : **mini-carte Leaflet**
|
||||
affichant **toutes les adresses géolocalisées du Tiers** (un marqueur par adresse, popup avec le libellé de
|
||||
l'adresse). Vue d'ensemble des implantations du Tiers. Le **pin reste ajustable** par adresse (réutilise le
|
||||
composant de l'onglet Adresse, déjà livré en ERP-122). Adresses sans coordonnées listées comme
|
||||
« à géolocaliser ». Onglet visible sous `field_sales.tours.view` ; masqué si le module `field_sales` est désactivé.
|
||||
|
||||
### 6.3 Ajustement du pin (fiche adresse) — **FAIT (ERP-122)**
|
||||
|
||||
Mini-carte Leaflet avec marqueur déplaçable dans le bloc adresse M1/M2 ; drag → `latitude/longitude` +
|
||||
`geo_manual = true` ; bouton « Re-géocoder depuis l'adresse ».
|
||||
|
||||
## 7. Géocodage des adresses — **FAIT (ERP-122)**
|
||||
|
||||
Service `GeocoderInterface` / `BanGeocoder` (api-adresse.data.gouv.fr). Correction manuelle systématique via le
|
||||
pin (`geo_manual = true` fige — RG-6.08).
|
||||
|
||||
## 8. RBAC — 3 miroirs obligatoires
|
||||
|
||||
Permissions du module `field_sales` (méthode `permissions()` de `FieldSalesModule.php`) — **uniquement les
|
||||
tournées** (plus de permissions `reports.*` depuis la réduction de scope) :
|
||||
|
||||
| Permission | Sens | Admin | Commerciale | Bureau |
|
||||
|---|---|---|---|---|
|
||||
| `field_sales.tours.view` | Voir les tournées + l'onglet Carte. | ✅ (toutes) | ✅ (les siennes) | ✅ (consultation) |
|
||||
| `field_sales.tours.manage` | Créer/éditer/optimiser/dupliquer/supprimer une tournée. | ✅ | ✅ | ❌ |
|
||||
|
||||
Attribution : **Commerciale + Admin** = manage ; **Bureau** = view ; Compta exclue. À synchroniser dans les
|
||||
**3 miroirs** (règle ABSOLUE n°8) : `config/sidebar.php` (section « Tournées » : item `tours` + i18n
|
||||
`sidebar.field_sales.*`), `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`. Sync :
|
||||
`app:sync-permissions`.
|
||||
|
||||
## 9. Conventions & garde-fous (rappel)
|
||||
|
||||
- `declare(strict_types=1);` partout ; commentaires FR, code EN.
|
||||
- Entités métier : `#[Auditable]` + Timestampable/Blamable. Libellés i18n `audit.entity.field_sales_tour` /
|
||||
`_tourstop` dans `fr.json` (sinon `AuditableEntitiesHaveI18nLabelTest` casse `make test`).
|
||||
- Migration modulaire : `COMMENT ON COLUMN` sur **chaque** colonne (FR ≤ 200 car.) + helper
|
||||
`addStandardTimestampableBlamableComments()`.
|
||||
- Toute collection paginée (`CollectionsArePaginatedTest`).
|
||||
- Front : `useApi()` uniquement, composants `Malio*`, `MalioDataTable` + `usePaginatedList` pour les listes,
|
||||
**pas d'état de tableau dans l'URL**. Carte Leaflet = exception documentée.
|
||||
|
||||
## 10. Règles de gestion (RG)
|
||||
|
||||
| RG | Règle | Garde-fou |
|
||||
|---|---|---|
|
||||
| **RG-6.01** | Tournée **personnelle** (`owner`). Commerciale ne voit/édite que les siennes ; Admin/Bureau voient tout en lecture. | Filtre Provider sur `owner` + RBAC. |
|
||||
| **RG-6.02** | Cycle de vie `draft → planned → in_progress → done` (transitions libres en V1). | Enum + Assert\Choice. |
|
||||
| **RG-6.03** | Une étape sur Tiers référentiel vise une adresse de ce Tiers (qui en a plusieurs). Ne s'applique pas aux `custom`. | Assert\Callback. |
|
||||
| **RG-6.05** | Une étape n'entre dans le calcul que si son adresse a `latitude` ET `longitude`. Sinon « à géolocaliser », exclue des totaux. | `RouteEngine` + signalement front. |
|
||||
| **RG-6.07** | Deux étapes peuvent viser le même Tiers (repasser plus tard). Unicité uniquement sur `(tour_id, position)`. | Index unique partiel. |
|
||||
| **RG-6.08** | `geo_manual = true` fige les coordonnées (le géocodage auto ne réécrit plus). | Garde dans le géocodeur (FAIT). |
|
||||
| **RG-6.11** | `eta` = `departure_time` + Σ(trajets précédents) + Σ(durées de visite précédentes). | `RouteEngine::estimateLegDurations()`. |
|
||||
| **RG-6.12** | Une étape vise tout Tiers ou un point `custom`. Si `custom` : `tier_id`/`address_id` NULL, `custom_label` + coordonnées obligatoires. | Assert\Choice + Assert\Callback. |
|
||||
| **RG-6.13** | Dupliquer copie départ + étapes (ordre/adresses/durées) à une nouvelle date ; ne copie pas les calculs (ETA/legs recalculés). | Service `TourDuplicator`. |
|
||||
|
||||
## 11. Tests à automatiser
|
||||
|
||||
- **Architecture (cassent `make test`)** : `ColumnsHaveSqlCommentTest`, `AuditableEntitiesHaveI18nLabelTest`
|
||||
(`audit.entity.field_sales_tour` / `_tourstop`), `EntitiesAreTimestampableBlamableTest` (Tour, TourStop),
|
||||
`CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
|
||||
- **Back (PHPUnit)** : RG-6.03 (adresse hors Tiers → 422), RG-6.05 (étape sans coord exclue), RG-6.07 (doublon
|
||||
Tiers accepté), RG-6.11 (ETA), RG-6.12 (custom cohérent), RG-6.13 (duplication sans calculs), filtre `owner`,
|
||||
`HaversineRouteEngine` (ordre plus proche voisin sur un jeu de coordonnées connu).
|
||||
- **Front (Vitest)** : `usePaginatedList` sur tournées, composable de planification (réordonnancement, totaux,
|
||||
deep links), onglet Carte (marqueurs des adresses). **Pas de E2E** (règle d'or).
|
||||
|
||||
## 12. Hors-périmètre (HP)
|
||||
|
||||
- **HP-M6-1** : **rapport de visite** (compte-rendu, note /5, offres de prix, fichiers, catégorie, saisie
|
||||
vocale, historique des visites) — **retiré du scope (V0.2)**, à réintroduire dans un module/lot ultérieur si besoin.
|
||||
- **HP-M6-2** : mode terrain mobile dédié (vue du jour + check-in) — retiré ; navigation via l'écran de
|
||||
planification responsive.
|
||||
- **HP-M6-3** : routing routier réel + optimisation TSP (Matrix API) — V2 (V1 = heuristique Haversine).
|
||||
- **HP-M6-4** : suggestion automatique des Tiers « à visiter » (façon Portatour) — V2.
|
||||
- **HP-M6-5** : offline réel (PWA + synchro) — V3.
|
||||
- **HP-M6-6** : partage / affectation de tournées entre commerciaux, planning d'équipe — V3.
|
||||
- **HP-M6-7** : navigation multi-étapes poussée dans Waze (impossible techniquement) — navigation étape par étape.
|
||||
|
||||
## 13. Phasage
|
||||
|
||||
- **V1 (livrable)** : géoloc adresses + pin (FAIT) ; carte interactive + lasso ; tournée (création, drag & drop,
|
||||
heure de départ, point de départ) sur tout Tiers + point `custom` ; **« Trajet logique »** + ETA + totaux ;
|
||||
deep links Waze/Maps ; **duplication** ; **feuille de route PDF** ; **onglet Carte** dans les fiches
|
||||
Client/Fournisseur ; responsive desktop + mobile.
|
||||
- **V2** : bouton **« Optimiser »** (routing routier réel ORS/OSRM), temps trafic, suggestion des Tiers proches.
|
||||
- **V3** : offline réel, partage/affectation de tournées.
|
||||
|
||||
## 14. Risques / points ouverts
|
||||
|
||||
- **Coût/quota routing en V2** : multi-tenant → cache, debounce, plafonds par tenant.
|
||||
- **Limite Waze multi-étapes** : Waze ne prend qu'une destination → navigation étape par étape (assumé).
|
||||
- **Reste à cadrer techniquement** : périmètre de visibilité Bureau (toutes les tournées vs les siennes).
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime (scope réduit V0.2)
|
||||
|
||||
TaskGroup Lesstime : **#28 — M6 Tournées commerciales terrain** (projet STARSEED #6). Tickets gros grain, chacun
|
||||
avec un **prompt Fable** prêt à coller (consigne « adapte-toi à la config actuelle » incluse).
|
||||
|
||||
| # | Réf | Ticket | Effort | Tag | État |
|
||||
|---|---|---|---|---|---|
|
||||
| M6.1 | ERP-122 | Géolocaliser les adresses Tiers (lat/lng + pin) | L | Back+Front | ✅ Fait |
|
||||
| M6.2 | ERP-123 | Fondations module field_sales + VisitableInterface + RBAC (tournées) | M | Back | Prêt à dev |
|
||||
| M6.3 | ERP-124 | Entités & API Tournée + Étape | L | Back | Prêt à dev |
|
||||
| M6.4 | ERP-125 | Calcul trajet, optimisation, duplication & roadbook PDF | L | Back | Prêt à dev |
|
||||
| M6.5 | ERP-127 | Carte interactive + écran planification (responsive) | L | Front | Prêt à dev |
|
||||
| M6.6 | ERP-129 | Onglet « Carte » dans les fiches Client & Fournisseur | M | Front | Prêt à dev |
|
||||
| M6.7 | ERP-130 | Vérification : garde-fous archi, tests RG & golden path | M | Back+Front | Prêt à dev |
|
||||
|
||||
Supprimés à la réduction de scope : **ERP-126** (rapport de visite) et **ERP-128** (mode terrain mobile + formulaire rapport).
|
||||
|
||||
Ordre d'exécution : M6.2 → M6.3 → M6.4 → M6.5 → M6.6 → M6.7.
|
||||
|
||||
---
|
||||
|
||||
### Sources d'inspiration (logiciels de référence)
|
||||
- Badger Maps — *Lasso* + carte : https://www.badgermapping.com/features/
|
||||
- SPOTIO — drag & drop des étapes + optimize : https://support.spotio.com/hc/en-us/articles/360061370754-Routing-How-to-Build-and-Manage-Routes
|
||||
- Portatour — multi-stop + recalcul auto : https://www.portatour.com/features/en
|
||||
- Nomadia Field Sales — carte + tournée mobile : https://www.nomadia.com/ressources/blog/logiciel-commerciaux-itinerants/
|
||||
@@ -40,6 +40,10 @@
|
||||
},
|
||||
"catalog": {
|
||||
"categories": "Gestion des catégories"
|
||||
},
|
||||
"field_sales": {
|
||||
"section": "Tournées",
|
||||
"tours": "Tournées"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -49,6 +53,14 @@
|
||||
"commercial": {
|
||||
"title": "Commercial",
|
||||
"welcome": "Module Commercial",
|
||||
"geo": {
|
||||
"title": "Position géographique",
|
||||
"toGeolocate": "À géolocaliser",
|
||||
"manualPin": "Pin ajusté manuellement",
|
||||
"dragHint": "Déplacez le marqueur pour ajuster la position exacte (lieu-dit, entrée de site...).",
|
||||
"regeocode": "Re-géocoder depuis l'adresse",
|
||||
"regeocodeFailed": "Adresse introuvable — position inchangée."
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Répertoire fournisseurs",
|
||||
"add": "Ajouter",
|
||||
@@ -413,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,216 @@
|
||||
<template>
|
||||
<div data-testid="geo-pin">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">{{ t('commercial.geo.title') }}</span>
|
||||
<!-- Badge « a geolocaliser » : adresse valide mais sans coordonnees
|
||||
(spec M6 § 3.2 — exclue du calcul de tournee, RG-6.05). -->
|
||||
<span
|
||||
v-if="!hasCoords"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
|
||||
data-testid="geo-badge-missing"
|
||||
>
|
||||
{{ t('commercial.geo.toGeolocate') }}
|
||||
</span>
|
||||
<!-- Pin fige a la main (RG-6.08) : informatif. -->
|
||||
<span
|
||||
v-else-if="geoManual"
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
||||
data-testid="geo-badge-manual"
|
||||
>
|
||||
{{ t('commercial.geo.manualPin') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Mini-carte Leaflet (exception documentee a @malio/layer-ui : carte
|
||||
interactive, type non couvert par la lib — cf. frontend.md
|
||||
§ Composants formulaires). TODO : migrer si la lib couvre un jour
|
||||
les cartes. -->
|
||||
<div
|
||||
v-if="hasCoords"
|
||||
ref="mapEl"
|
||||
class="h-48 w-full rounded border border-gray-200"
|
||||
data-testid="geo-map"
|
||||
/>
|
||||
<p v-if="hasCoords && !readonly" class="mt-1 text-xs text-gray-500">
|
||||
{{ t('commercial.geo.dragHint') }}
|
||||
</p>
|
||||
|
||||
<div v-if="!readonly" class="mt-2 flex items-center gap-4">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
:label="t('commercial.geo.regeocode')"
|
||||
:disabled="regeocoding || !canRegeocode"
|
||||
data-testid="geo-regeocode"
|
||||
@click="regeocode"
|
||||
/>
|
||||
<span v-if="regeocodeFailed" class="text-xs text-red-600" data-testid="geo-regeocode-failed">
|
||||
{{ t('commercial.geo.regeocodeFailed') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map as LeafletMap, Marker } from 'leaflet'
|
||||
import { useAddressAutocomplete } from '~/shared/composables/useAddressAutocomplete'
|
||||
|
||||
/**
|
||||
* Mini-carte d'ajustement du pin d'une adresse Tiers (M6.1, spec § 8.3).
|
||||
*
|
||||
* - Marqueur deplacable : au drag, emet les coordonnees corrigees avec
|
||||
* geoManual = true (RG-6.08 : le geocodage auto ne reecrira plus). Le parent
|
||||
* met a jour le brouillon ; la persistance suit le submit du formulaire
|
||||
* (POST/PATCH de l'adresse), comme tous les champs du bloc.
|
||||
* - « Re-geocoder depuis l'adresse » : previsualise la position BAN cote front
|
||||
* et emet geoManual = false — au save, le back (BanGeocoder) refait autorite
|
||||
* et pose geocodedAt.
|
||||
* - Sans coordonnees : pas de carte, badge « a geolocaliser ».
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** Latitude WGS84 (chaine decimale) ou null si non geolocalisee. */
|
||||
latitude: string | null
|
||||
/** Longitude WGS84 (chaine decimale) ou null si non geolocalisee. */
|
||||
longitude: string | null
|
||||
/** RG-6.08 : pin deja corrige a la main. */
|
||||
geoManual: boolean
|
||||
/** Adresse postale a re-geocoder (« rue, code postal ville »). */
|
||||
geocodeQuery: string | null
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Nouveau positionnement du pin (drag manuel ou re-geocodage previsualise). */
|
||||
'update:coords': [value: { latitude: string, longitude: string, geoManual: boolean }]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
const regeocoding = ref(false)
|
||||
const regeocodeFailed = ref(false)
|
||||
|
||||
const hasCoords = computed(() =>
|
||||
props.latitude !== null && props.latitude !== ''
|
||||
&& props.longitude !== null && props.longitude !== '',
|
||||
)
|
||||
|
||||
const canRegeocode = computed(() => (props.geocodeQuery ?? '').trim().length >= 3)
|
||||
|
||||
// Instances Leaflet (hors reactivite Vue : un proxy sur la Map casse Leaflet).
|
||||
let map: LeafletMap | null = null
|
||||
let marker: Marker | null = null
|
||||
|
||||
/** Zoom d'affichage du pin (niveau rue). */
|
||||
const PIN_ZOOM = 16
|
||||
|
||||
/**
|
||||
* Monte la carte Leaflet dans le conteneur (import dynamique : la lib n'est
|
||||
* chargee que si l'adresse a des coordonnees).
|
||||
*/
|
||||
async function ensureMap(): Promise<void> {
|
||||
if (map !== null || mapEl.value === null || !hasCoords.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const mod = await import('leaflet')
|
||||
const L = mod.default ?? mod
|
||||
await import('leaflet/dist/leaflet.css')
|
||||
|
||||
// Le conteneur peut avoir disparu pendant le chargement async (v-if).
|
||||
if (mapEl.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const position: [number, number] = [Number(props.latitude), Number(props.longitude)]
|
||||
|
||||
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(position, PIN_ZOOM)
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
|
||||
// divIcon SVG inline : evite les assets PNG de Leaflet (chemins casses par
|
||||
// le bundler Vite sans configuration dediee).
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="40" fill="#2563eb" stroke="#1e40af" stroke-width="0.5"><path d="M12 0C7 0 3 4 3 9c0 6.6 9 15 9 15s9-8.4 9-15c0-5-4-9-9-9zm0 12.5A3.5 3.5 0 1 1 12 5.5a3.5 3.5 0 0 1 0 7z"/></svg>',
|
||||
iconSize: [28, 40],
|
||||
iconAnchor: [14, 40],
|
||||
})
|
||||
|
||||
marker = L.marker(position, { icon, draggable: !props.readonly }).addTo(map)
|
||||
marker.on('dragend', onMarkerDragEnd)
|
||||
}
|
||||
|
||||
/** Drag du pin -> coordonnees corrigees + geoManual (RG-6.08). */
|
||||
function onMarkerDragEnd(): void {
|
||||
if (marker === null) {
|
||||
return
|
||||
}
|
||||
const position = marker.getLatLng()
|
||||
emit('update:coords', {
|
||||
latitude: position.lat.toFixed(7),
|
||||
longitude: position.lng.toFixed(7),
|
||||
geoManual: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* « Re-geocoder depuis l'adresse » : previsualisation BAN cote front. Emet
|
||||
* geoManual = false — le geocodage serveur refait autorite au save.
|
||||
*/
|
||||
async function regeocode(): Promise<void> {
|
||||
regeocodeFailed.value = false
|
||||
const query = (props.geocodeQuery ?? '').trim()
|
||||
if (query.length < 3) {
|
||||
regeocodeFailed.value = true
|
||||
return
|
||||
}
|
||||
|
||||
regeocoding.value = true
|
||||
try {
|
||||
const coords = await autocomplete.geocode(query)
|
||||
if (coords === null) {
|
||||
regeocodeFailed.value = true
|
||||
return
|
||||
}
|
||||
emit('update:coords', { ...coords, geoManual: false })
|
||||
}
|
||||
catch {
|
||||
// BAN indisponible : position inchangee, message inline.
|
||||
regeocodeFailed.value = true
|
||||
}
|
||||
finally {
|
||||
regeocoding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Coordonnees modifiees par le parent (drag deja applique, re-geocodage,
|
||||
// rechargement) : recale le marqueur, ou monte la carte si elle n'existe pas
|
||||
// encore (premieres coordonnees d'une adresse « a geolocaliser »).
|
||||
watch(
|
||||
() => [props.latitude, props.longitude] as const,
|
||||
async () => {
|
||||
if (!hasCoords.value) {
|
||||
return
|
||||
}
|
||||
if (map === null) {
|
||||
await nextTick()
|
||||
await ensureMap()
|
||||
return
|
||||
}
|
||||
const position: [number, number] = [Number(props.latitude), Number(props.longitude)]
|
||||
marker?.setLatLng(position)
|
||||
map.panTo(position)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(ensureMap)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
map?.remove()
|
||||
map = null
|
||||
marker = null
|
||||
})
|
||||
</script>
|
||||
@@ -178,6 +178,19 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pin geographique de l'adresse (M6.1, spec § 8.3) : mini-carte avec
|
||||
marqueur ajustable, persiste au submit comme le reste du bloc. -->
|
||||
<div class="col-span-4">
|
||||
<AddressGeoPin
|
||||
:latitude="model.latitude"
|
||||
:longitude="model.longitude"
|
||||
:geo-manual="model.geoManual"
|
||||
:geocode-query="geocodeQuery"
|
||||
:readonly="readonly"
|
||||
@update:coords="onCoordsUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -289,6 +302,24 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
// Adresse postale a re-geocoder (« rue, code postal ville ») — miroir du
|
||||
// getDisplayLabel() serveur (le complement bruite le geocodage, exclu).
|
||||
const geocodeQuery = computed<string | null>(() => {
|
||||
const locality = [model.value.postalCode, model.value.city].filter(Boolean).join(' ')
|
||||
const parts = [model.value.street, locality].filter(part => part && String(part).trim() !== '')
|
||||
return parts.length > 0 ? parts.join(', ') : null
|
||||
})
|
||||
|
||||
/** Pin deplace / re-geocode : repercute coordonnees + drapeau manuel (RG-6.08). */
|
||||
function onCoordsUpdate(coords: { latitude: string, longitude: string, geoManual: boolean }): void {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
geoManual: coords.geoManual,
|
||||
})
|
||||
}
|
||||
|
||||
/** Revele le 2e champ email de facturation (clic sur le « + »). */
|
||||
function revealSecondaryBillingEmail(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
|
||||
|
||||
@@ -162,6 +162,19 @@
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => update('triageProvider', v)"
|
||||
/>
|
||||
|
||||
<!-- Pin geographique de l'adresse (M6.1, spec § 8.3) : mini-carte avec
|
||||
marqueur ajustable, persiste au submit comme le reste du bloc. -->
|
||||
<div class="col-span-4">
|
||||
<AddressGeoPin
|
||||
:latitude="model.latitude"
|
||||
:longitude="model.longitude"
|
||||
:geo-manual="model.geoManual"
|
||||
:geocode-query="geocodeQuery"
|
||||
:readonly="readonly"
|
||||
@update:coords="onCoordsUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -243,6 +256,24 @@ function update<K extends keyof SupplierAddressFormDraft>(field: K, value: Suppl
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
// Adresse postale a re-geocoder (« rue, code postal ville ») — miroir du
|
||||
// getDisplayLabel() serveur (le complement bruite le geocodage, exclu).
|
||||
const geocodeQuery = computed<string | null>(() => {
|
||||
const locality = [model.value.postalCode, model.value.city].filter(Boolean).join(' ')
|
||||
const parts = [model.value.street, locality].filter(part => part && String(part).trim() !== '')
|
||||
return parts.length > 0 ? parts.join(', ') : null
|
||||
})
|
||||
|
||||
/** Pin deplace / re-geocode : repercute coordonnees + drapeau manuel (RG-6.08). */
|
||||
function onCoordsUpdate(coords: { latitude: string, longitude: string, geoManual: boolean }): void {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
geoManual: coords.geoManual,
|
||||
})
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import AddressGeoPin from '../AddressGeoPin.vue'
|
||||
|
||||
// Mock Leaflet (hoisted) : capture le handler `dragend` et pilote la position
|
||||
// renvoyee par getLatLng — permet de simuler un drag du marqueur sans DOM reel.
|
||||
const leafletState = vi.hoisted(() => ({
|
||||
dragendHandler: null as (() => void) | null,
|
||||
markerPosition: { lat: 0, lng: 0 },
|
||||
}))
|
||||
|
||||
vi.mock('leaflet', () => {
|
||||
const marker = {
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'dragend') {
|
||||
leafletState.dragendHandler = handler
|
||||
}
|
||||
}),
|
||||
getLatLng: vi.fn(() => leafletState.markerPosition),
|
||||
setLatLng: vi.fn(),
|
||||
}
|
||||
const map = {
|
||||
setView: vi.fn().mockReturnThis(),
|
||||
panTo: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}
|
||||
const L = {
|
||||
map: vi.fn(() => map),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
divIcon: vi.fn(() => ({})),
|
||||
marker: vi.fn(() => marker),
|
||||
}
|
||||
return { default: L, ...L }
|
||||
})
|
||||
vi.mock('leaflet/dist/leaflet.css', () => ({ default: {} }))
|
||||
|
||||
// Mock controlable du geocodage BAN (bouton « Re-geocoder »).
|
||||
const { geocodeMock } = vi.hoisted(() => ({ geocodeMock: vi.fn() }))
|
||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||
useAddressAutocomplete: () => ({ geocode: geocodeMock }),
|
||||
}))
|
||||
|
||||
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
vi.stubGlobal('watch', watch)
|
||||
vi.stubGlobal('nextTick', nextTick)
|
||||
vi.stubGlobal('onMounted', onMounted)
|
||||
vi.stubGlobal('onBeforeUnmount', onBeforeUnmount)
|
||||
|
||||
interface PinProps {
|
||||
latitude?: string | null
|
||||
longitude?: string | null
|
||||
geoManual?: boolean
|
||||
geocodeQuery?: string | null
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
function mountPin(props: PinProps = {}) {
|
||||
return mount(AddressGeoPin, {
|
||||
props: {
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
geoManual: false,
|
||||
geocodeQuery: '1 rue du Test, 86100 Châtellerault',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: { MalioButton: true },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
leafletState.dragendHandler = null
|
||||
geocodeMock.mockReset()
|
||||
})
|
||||
|
||||
describe('AddressGeoPin — adresse sans coordonnees', () => {
|
||||
it('affiche le badge « a geolocaliser » et aucune carte', () => {
|
||||
const wrapper = mountPin()
|
||||
|
||||
expect(wrapper.find('[data-testid="geo-badge-missing"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="geo-map"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddressGeoPin — drag du marqueur (RG-6.08)', () => {
|
||||
it('emet les coordonnees corrigees avec geoManual=true au dragend', async () => {
|
||||
const wrapper = mountPin({ latitude: '46.5802596', longitude: '0.3404333' })
|
||||
await flushPromises() // import dynamique de Leaflet + montage carte
|
||||
|
||||
expect(leafletState.dragendHandler).not.toBeNull()
|
||||
|
||||
// L'utilisateur depose le pin ailleurs (lieu-dit mal geocode).
|
||||
leafletState.markerPosition = { lat: 48.1234567, lng: -1.6543217 }
|
||||
leafletState.dragendHandler?.()
|
||||
|
||||
const emitted = wrapper.emitted('update:coords')
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted?.[0]?.[0]).toEqual({
|
||||
latitude: '48.1234567',
|
||||
longitude: '-1.6543217',
|
||||
geoManual: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('affiche le badge « pin manuel » quand geoManual est vrai', () => {
|
||||
const wrapper = mountPin({ latitude: '46.58', longitude: '0.34', geoManual: true })
|
||||
|
||||
expect(wrapper.find('[data-testid="geo-badge-manual"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="geo-badge-missing"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddressGeoPin — re-geocodage depuis l\'adresse', () => {
|
||||
it('emet la position BAN avec geoManual=false (le back refera autorite au save)', async () => {
|
||||
geocodeMock.mockResolvedValueOnce({ latitude: '46.5802596', longitude: '0.3404333' })
|
||||
const wrapper = mountPin()
|
||||
|
||||
await wrapper.find('[data-testid="geo-regeocode"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(geocodeMock).toHaveBeenCalledWith('1 rue du Test, 86100 Châtellerault')
|
||||
expect(wrapper.emitted('update:coords')?.[0]?.[0]).toEqual({
|
||||
latitude: '46.5802596',
|
||||
longitude: '0.3404333',
|
||||
geoManual: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('signale l\'echec sans emettre quand la BAN ne trouve rien', async () => {
|
||||
geocodeMock.mockResolvedValueOnce(null)
|
||||
const wrapper = mountPin()
|
||||
|
||||
await wrapper.find('[data-testid="geo-regeocode"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('update:coords')).toBeUndefined()
|
||||
expect(wrapper.find('[data-testid="geo-regeocode-failed"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('masque le bouton en lecture seule', () => {
|
||||
const wrapper = mountPin({ readonly: true })
|
||||
|
||||
expect(wrapper.find('[data-testid="geo-regeocode"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -65,6 +65,8 @@ function mountBlock(street: string | null) {
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputText: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
// Pin geographique (M6.1) : teste dans AddressGeoPin.spec.ts.
|
||||
AddressGeoPin: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -130,6 +132,8 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
MalioInputText: MalioInputTextProbe,
|
||||
// Pin geographique (M6.1) : teste dans AddressGeoPin.spec.ts.
|
||||
AddressGeoPin: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -51,6 +51,12 @@ export interface AddressFormDraft {
|
||||
billingEmailSecondary: string | null
|
||||
/** Drapeau UI : 2e champ email revele (comme hasSecondaryPhone). */
|
||||
hasSecondaryBillingEmail: boolean
|
||||
/** Latitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
|
||||
latitude: string | null
|
||||
/** Longitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
|
||||
longitude: string | null
|
||||
/** RG-6.08 : pin corrige a la main — le geocodage auto ne reecrit plus. */
|
||||
geoManual: boolean
|
||||
}
|
||||
|
||||
/** Un RIB du client (onglet Comptabilite). */
|
||||
@@ -96,6 +102,9 @@ export function emptyAddress(): AddressFormDraft {
|
||||
billingEmail: null,
|
||||
billingEmailSecondary: null,
|
||||
hasSecondaryBillingEmail: false,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
geoManual: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ export interface SupplierAddressFormDraft {
|
||||
bennes: string | null
|
||||
/** Prestation de triage (specifique fournisseur, portee par l'adresse — RG). */
|
||||
triageProvider: boolean
|
||||
/** Latitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
|
||||
latitude: string | null
|
||||
/** Longitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
|
||||
longitude: string | null
|
||||
/** RG-6.08 : pin corrige a la main — le geocodage auto ne reecrit plus. */
|
||||
geoManual: boolean
|
||||
}
|
||||
|
||||
/** Un RIB du fournisseur (onglet Comptabilite). */
|
||||
@@ -95,6 +101,9 @@ export function emptyAddress(): SupplierAddressFormDraft {
|
||||
contactIris: [],
|
||||
bennes: '0',
|
||||
triageProvider: false,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
geoManual: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ export interface AddressRead extends HydraRef {
|
||||
isBilling?: boolean
|
||||
isBroker?: boolean
|
||||
isDistributor?: boolean
|
||||
/** Geolocalisation (M6.1) : chaines decimales NUMERIC(10,7) cote API. */
|
||||
latitude?: string | null
|
||||
longitude?: string | null
|
||||
geoManual?: boolean
|
||||
sites?: SiteRead[]
|
||||
categories?: CategoryRead[]
|
||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||
@@ -225,6 +229,9 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
|
||||
billingEmail: address.billingEmail ?? null,
|
||||
billingEmailSecondary: address.billingEmailSecondary ?? null,
|
||||
hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '',
|
||||
latitude: address.latitude ?? null,
|
||||
longitude: address.longitude ?? null,
|
||||
geoManual: address.geoManual === true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,11 @@ export function buildAddressPayload(
|
||||
contacts: address.contactIris,
|
||||
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
|
||||
billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null,
|
||||
// Geolocalisation (M6.1) : pin manuel persiste avec geoManual=true ;
|
||||
// geoManual=false laisse le back regeocoder depuis l'adresse postale.
|
||||
latitude: address.latitude || null,
|
||||
longitude: address.longitude || null,
|
||||
geoManual: address.geoManual,
|
||||
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,10 @@ export interface AddressRead extends HydraRef {
|
||||
streetComplement?: string | null
|
||||
bennes?: number | null
|
||||
triageProvider?: boolean
|
||||
/** Geolocalisation (M6.1) : chaines decimales NUMERIC(10,7) cote API. */
|
||||
latitude?: string | null
|
||||
longitude?: string | null
|
||||
geoManual?: boolean
|
||||
sites?: SiteRead[]
|
||||
categories?: CategoryRead[]
|
||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||
@@ -200,6 +204,9 @@ export function mapAddressToDraft(address: AddressRead): SupplierAddressFormDraf
|
||||
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||
bennes: address.bennes != null ? String(address.bennes) : '0',
|
||||
triageProvider: address.triageProvider ?? false,
|
||||
latitude: address.latitude ?? null,
|
||||
longitude: address.longitude ?? null,
|
||||
geoManual: address.geoManual === true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,11 @@ export function buildAddressPayload(address: SupplierAddressFormDraft, options:
|
||||
contacts: address.contactIris,
|
||||
bennes: address.bennes !== null && address.bennes !== '' ? Number(address.bennes) : null,
|
||||
triageProvider: address.triageProvider,
|
||||
// Geolocalisation (M6.1) : pin manuel persiste avec geoManual=true ;
|
||||
// geoManual=false laisse le back regeocoder depuis l'adresse postale.
|
||||
latitude: address.latitude || null,
|
||||
longitude: address.longitude || null,
|
||||
geoManual: address.geoManual,
|
||||
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
|
||||
}
|
||||
|
||||
|
||||
Generated
+56
-22
@@ -12,6 +12,8 @@
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"leaflet": "^1.9.4",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
@@ -85,6 +87,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -582,27 +585,6 @@
|
||||
"integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
|
||||
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
|
||||
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
@@ -1303,6 +1285,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
@@ -2221,6 +2204,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz",
|
||||
"integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"c12": "^3.3.3",
|
||||
"consola": "^3.4.2",
|
||||
@@ -2323,6 +2307,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz",
|
||||
"integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "^3.5.30",
|
||||
"defu": "^6.1.4",
|
||||
@@ -4638,6 +4623,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.2.tgz",
|
||||
"integrity": "sha512-yjv2N7gaQMbIVfsSZHBMscLoybgetcTraXsSMrELAerl/jfRipg5S1dBXMFvgRy8Kh48+TGoH+5nqshxdOEGoQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -4886,6 +4872,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.2.tgz",
|
||||
"integrity": "sha512-tRbbjpOPrY4ApIHtn3ctnKIhkkioewMsZa5gJzqVB47LJFNyzLXLo/aID4sJRKTIMi1wd1fA9TiBKPe6KqczPA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -4991,6 +4978,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.23.2.tgz",
|
||||
"integrity": "sha512-K2o1gMwn09nrd5ewftSy08U6LMC1cW3Cmml5+vHT9P/VeMtYwkbNg+9Mt1uFh7VfAZmlkj8d3u7RYqfl8xMVJA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -5017,6 +5005,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.2.tgz",
|
||||
"integrity": "sha512-kRHQ3nSbAfkFdxj9FtDdr4hpREndGgWFA6ZEAwlLeGUxf8QYTpuF9zb2yxdBPBlTc5+JsbPcskNt+u1PazGKYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -5031,6 +5020,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.2.tgz",
|
||||
"integrity": "sha512-1kvsBqGNu2ZJ0P/lkxN0pAMqSyUcpkMIzE4xwGUIyAiD0pZV6dr+OCMwGWOTLllSyrn91xI5K7OLk3pYeCPKqA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
@@ -5140,12 +5130,27 @@
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
|
||||
@@ -5174,6 +5179,7 @@
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
@@ -5236,6 +5242,7 @@
|
||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
@@ -6015,6 +6022,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
|
||||
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.2",
|
||||
"@vue/compiler-core": "3.5.32",
|
||||
@@ -6258,6 +6266,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6645,6 +6654,7 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -6842,6 +6852,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -6956,6 +6967,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -7150,7 +7162,8 @@
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/clean-regexp": {
|
||||
"version": "1.0.0",
|
||||
@@ -8203,6 +8216,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -9361,6 +9375,7 @@
|
||||
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
@@ -10532,6 +10547,12 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -11807,6 +11828,7 @@
|
||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz",
|
||||
"integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dxup/nuxt": "^0.4.0",
|
||||
"@nuxt/cli": "^3.34.0",
|
||||
@@ -12865,6 +12887,7 @@
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
@@ -12922,6 +12945,7 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
||||
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.112.0"
|
||||
},
|
||||
@@ -13188,6 +13212,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
@@ -13313,6 +13338,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -13856,6 +13882,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -14646,6 +14673,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -15549,6 +15577,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -16228,6 +16257,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@@ -16494,6 +16524,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17412,6 +17443,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
|
||||
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.32",
|
||||
"@vue/compiler-sfc": "3.5.32",
|
||||
@@ -17456,6 +17488,7 @@
|
||||
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"eslint-scope": "^8.2.0 || ^9.0.0",
|
||||
@@ -17492,6 +17525,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.1.tgz",
|
||||
"integrity": "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.3.1",
|
||||
"@intlify/devtools-types": "11.3.1",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"leaflet": "^1.9.4",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
@@ -31,9 +31,21 @@ export interface AddressSuggestion {
|
||||
city: string
|
||||
}
|
||||
|
||||
/** Coordonnees WGS84 d'une adresse geocodee (chaines decimales, format API). */
|
||||
export interface GeocodedCoordinates {
|
||||
latitude: string
|
||||
longitude: string
|
||||
}
|
||||
|
||||
export interface AddressAutocomplete {
|
||||
searchCity(postalCode: string): Promise<CitySuggestion[]>
|
||||
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
|
||||
/**
|
||||
* Geocode une adresse complete en coordonnees (M6.1) — previsualisation du
|
||||
* pin cote front uniquement : la valeur persistee reste celle du geocodage
|
||||
* serveur (BanGeocoder) au save. `null` si la BAN ne trouve rien.
|
||||
*/
|
||||
geocode(query: string): Promise<GeocodedCoordinates | null>
|
||||
}
|
||||
|
||||
/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */
|
||||
@@ -57,7 +69,11 @@ interface BanFeatureProperties {
|
||||
|
||||
/** Reponse GeoJSON FeatureCollection de la BAN. */
|
||||
interface BanResponse {
|
||||
features?: { properties?: BanFeatureProperties }[]
|
||||
features?: {
|
||||
properties?: BanFeatureProperties
|
||||
/** GeoJSON : coordinates = [longitude, latitude]. */
|
||||
geometry?: { coordinates?: [number, number] }
|
||||
}[]
|
||||
}
|
||||
|
||||
export function useAddressAutocomplete(): AddressAutocomplete {
|
||||
@@ -113,5 +129,32 @@ export function useAddressAutocomplete(): AddressAutocomplete {
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async geocode(query: string): Promise<GeocodedCoordinates | null> {
|
||||
if (query.trim().length < 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
let res: BanResponse
|
||||
try {
|
||||
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
|
||||
query: { q: query, limit: '1' },
|
||||
})
|
||||
}
|
||||
catch {
|
||||
throw new AddressAutocompleteUnavailableError()
|
||||
}
|
||||
|
||||
const coordinates = res.features?.[0]?.geometry?.coordinates
|
||||
if (!coordinates || coordinates.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
// GeoJSON = [longitude, latitude] ; 7 decimales = format NUMERIC(10,7).
|
||||
return {
|
||||
latitude: coordinates[1].toFixed(7),
|
||||
longitude: coordinates[0].toFixed(7),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,12 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
'commercial.suppliers.archive',
|
||||
// FieldSales — Tournees (M6, ERP-123). Mappe sur le persona "tout",
|
||||
// pas de nouveau persona (regle ABSOLUE n°7). La section "Tournées"
|
||||
// n'est pas dans Administration, donc expectedAdminLinks inchange.
|
||||
// Miroir de SeedE2ECommand.php.
|
||||
'field_sales.tours.view',
|
||||
'field_sales.tours.manage',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Commercial — geolocalisation des adresses Tiers (M6.1 / ERP-122, spec M6
|
||||
* § 3.2 / § 4.1).
|
||||
*
|
||||
* Ajoute sur `client_address` ET `supplier_address` :
|
||||
* - latitude / longitude NUMERIC(10,7) null : coordonnees WGS84, alimentees
|
||||
* par le geocodage BAN automatique ou par le pin manuel ;
|
||||
* - geo_manual BOOLEAN default false : RG-6.08, un pin corrige a la main fige
|
||||
* les coordonnees (le geocodage auto ne reecrit plus) ;
|
||||
* - geocoded_at TIMESTAMPTZ null : date du dernier geocodage auto reussi.
|
||||
*
|
||||
* Migration au namespace racine `DoctrineMigrations` (pratique effective du
|
||||
* projet : le namespace modulaire Commercial n'est pas enregistre dans
|
||||
* doctrine_migrations.yaml et souffrirait du tri FQCN inter-namespaces sur
|
||||
* base vide — cf. regle ABSOLUE n°11 / architecture.md § Migrations).
|
||||
*/
|
||||
final class Version20260611130000 extends AbstractMigration
|
||||
{
|
||||
private const array TABLES = ['client_address', 'supplier_address'];
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Commercial : geolocalisation des adresses Tiers (latitude/longitude/geo_manual/geocoded_at sur client_address et supplier_address).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
foreach (self::TABLES as $table) {
|
||||
$this->addSql(sprintf('ALTER TABLE %s ADD COLUMN latitude NUMERIC(10, 7) DEFAULT NULL', $table));
|
||||
$this->addSql(sprintf('ALTER TABLE %s ADD COLUMN longitude NUMERIC(10, 7) DEFAULT NULL', $table));
|
||||
$this->addSql(sprintf('ALTER TABLE %s ADD COLUMN geo_manual BOOLEAN DEFAULT false NOT NULL', $table));
|
||||
$this->addSql(sprintf('ALTER TABLE %s ADD COLUMN geocoded_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL', $table));
|
||||
|
||||
$this->comment($table, 'latitude', 'Latitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee, exclue du calcul de tournee (RG-6.05).');
|
||||
$this->comment($table, 'longitude', 'Longitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee.');
|
||||
$this->comment($table, 'geo_manual', 'Pin positionne/corrige a la main : si vrai, le geocodage auto ne reecrit plus les coordonnees (RG-6.08). Faux par defaut.');
|
||||
$this->comment($table, 'geocoded_at', 'Date du dernier geocodage automatique reussi (NULL si jamais geocode ou pin 100% manuel).');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
foreach (self::TABLES as $table) {
|
||||
$this->addSql(sprintf('ALTER TABLE %s DROP COLUMN latitude', $table));
|
||||
$this->addSql(sprintf('ALTER TABLE %s DROP COLUMN longitude', $table));
|
||||
$this->addSql(sprintf('ALTER TABLE %s DROP COLUMN geo_manual', $table));
|
||||
$this->addSql(sprintf('ALTER TABLE %s DROP COLUMN geocoded_at', $table));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour
|
||||
* eviter tout echappement.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
'"'.str_replace('"', '""', $table).'"',
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Contract\VisitableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -135,7 +136,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Client implements TimestampableInterface, BlamableInterface
|
||||
class Client implements TimestampableInterface, BlamableInterface, VisitableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
@@ -321,6 +322,24 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelle affichable du Tiers pour le module FieldSales (carte/etapes).
|
||||
* La raison sociale est NotBlank (RG M1), le fallback chaine vide ne sert
|
||||
* qu'a honorer le type non-nullable du contrat VisitableInterface.
|
||||
*/
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return $this->companyName ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type stable porte par tour_stop.tier_type pour un Client (cf. M6 § 3.1).
|
||||
*/
|
||||
public function getVisitableType(): string
|
||||
{
|
||||
return 'client';
|
||||
}
|
||||
|
||||
public function getDistributor(): ?Client
|
||||
{
|
||||
return $this->distributor;
|
||||
|
||||
@@ -15,9 +15,11 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepositor
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\GeolocatableAddressInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
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;
|
||||
@@ -89,7 +91,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Table(name: 'client_address')]
|
||||
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
|
||||
#[Auditable]
|
||||
class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
class ClientAddress implements TimestampableInterface, BlamableInterface, GeolocatableAddressInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
@@ -191,6 +193,35 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private int $position = 0;
|
||||
|
||||
// Geolocalisation portee par l'adresse (M6.1, spec § 3.2 / § 4.1) :
|
||||
// coordonnees WGS84 alimentees par le geocodage BAN automatique
|
||||
// (AddressGeocoder, appele par le processor si geoManual = false) ou par le
|
||||
// pin manuel cote front (PATCH latitude/longitude + geoManual = true).
|
||||
// Doctrine decimal -> chaine PHP ; setter tolerant (le JSON porte un nombre).
|
||||
#[ORM\Column(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(['client_address:read', 'client_address:write'])]
|
||||
private ?string $latitude = null;
|
||||
|
||||
#[ORM\Column(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(['client_address:read', 'client_address:write'])]
|
||||
private ?string $longitude = null;
|
||||
|
||||
// RG-6.08 : pin corrige a la main -> le geocodage auto ne reecrit plus les
|
||||
// coordonnees. Groupe d'ECRITURE seul sur la propriete ; la LECTURE est
|
||||
// portee par le getter isGeoManual() + SerializedName (meme piege booleen
|
||||
// que isProspect : sans cela la cle serait droppee du JSON).
|
||||
#[ORM\Column(name: 'geo_manual', options: ['default' => false])]
|
||||
#[Groups(['client_address:write'])]
|
||||
private bool $geoManual = false;
|
||||
|
||||
// Date du dernier geocodage automatique reussi — posee par AddressGeocoder,
|
||||
// jamais ecrite par le client (lecture seule API).
|
||||
#[ORM\Column(name: 'geocoded_at', type: 'datetimetz_immutable', nullable: true)]
|
||||
#[Groups(['client_address:read'])]
|
||||
private ?DateTimeImmutable $geocodedAt = null;
|
||||
|
||||
// RG-1.10 : au moins un site rattache a chaque adresse.
|
||||
/** @var Collection<int, SiteInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
|
||||
@@ -540,6 +571,70 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLatitude(): ?string
|
||||
{
|
||||
return $this->latitude;
|
||||
}
|
||||
|
||||
public function setLatitude(float|string|null $latitude): static
|
||||
{
|
||||
$this->latitude = null === $latitude ? null : (string) $latitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLongitude(): ?string
|
||||
{
|
||||
return $this->longitude;
|
||||
}
|
||||
|
||||
public function setLongitude(float|string|null $longitude): static
|
||||
{
|
||||
$this->longitude = null === $longitude ? null : (string) $longitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
|
||||
// meme pattern que isProspect pour garantir la cle `geoManual` dans le JSON.
|
||||
#[Groups(['client_address:read'])]
|
||||
#[SerializedName('geoManual')]
|
||||
public function isGeoManual(): bool
|
||||
{
|
||||
return $this->geoManual;
|
||||
}
|
||||
|
||||
public function setGeoManual(bool $geoManual): static
|
||||
{
|
||||
$this->geoManual = $geoManual;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGeocodedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->geocodedAt;
|
||||
}
|
||||
|
||||
public function setGeocodedAt(?DateTimeImmutable $geocodedAt): static
|
||||
{
|
||||
$this->geocodedAt = $geocodedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse postale affichable / geocodable : « rue, code postal ville ». Le
|
||||
* complement (etage, batiment) est volontairement exclu — il bruite le
|
||||
* geocodage BAN (contrat GeolocatableAddressInterface, M6.1).
|
||||
*/
|
||||
public function getDisplayLabel(): string
|
||||
{
|
||||
$locality = trim(implode(' ', array_filter([$this->postalCode, $this->city])));
|
||||
|
||||
return implode(', ', array_filter([$this->street, '' !== $locality ? $locality : null]));
|
||||
}
|
||||
|
||||
/** @return Collection<int, SiteInterface> */
|
||||
public function getSites(): Collection
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Contract\VisitableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -130,7 +131,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Index(name: 'idx_supplier_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_supplier_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Supplier implements TimestampableInterface, BlamableInterface
|
||||
class Supplier implements TimestampableInterface, BlamableInterface, VisitableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
@@ -375,6 +376,24 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelle affichable du Tiers pour le module FieldSales (carte/etapes).
|
||||
* La raison sociale est NotBlank (RG M2), le fallback chaine vide ne sert
|
||||
* qu'a honorer le type non-nullable du contrat VisitableInterface.
|
||||
*/
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return $this->companyName ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type stable porte par tour_stop.tier_type pour un Fournisseur (M6 § 3.1).
|
||||
*/
|
||||
public function getVisitableType(): string
|
||||
{
|
||||
return 'supplier';
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
|
||||
@@ -15,9 +15,11 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierAddressReposit
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\GeolocatableAddressInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
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;
|
||||
@@ -96,7 +98,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Table(name: 'supplier_address')]
|
||||
#[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])]
|
||||
#[Auditable]
|
||||
class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
class SupplierAddress implements TimestampableInterface, BlamableInterface, GeolocatableAddressInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
@@ -181,6 +183,35 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
// Geolocalisation portee par l'adresse (M6.1, spec § 3.2 / § 4.1) :
|
||||
// coordonnees WGS84 alimentees par le geocodage BAN automatique
|
||||
// (AddressGeocoder, appele par le processor si geoManual = false) ou par le
|
||||
// pin manuel cote front (PATCH latitude/longitude + geoManual = true).
|
||||
// Doctrine decimal -> chaine PHP ; setter tolerant (le JSON porte un nombre).
|
||||
#[ORM\Column(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(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
private ?string $latitude = null;
|
||||
|
||||
#[ORM\Column(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(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
private ?string $longitude = null;
|
||||
|
||||
// RG-6.08 : pin corrige a la main -> le geocodage auto ne reecrit plus les
|
||||
// coordonnees. Groupe d'ECRITURE seul sur la propriete ; la LECTURE est
|
||||
// portee par le getter isGeoManual() + SerializedName (meme piege booleen
|
||||
// que triageProvider : sans cela la cle serait droppee du JSON).
|
||||
#[ORM\Column(name: 'geo_manual', options: ['default' => false])]
|
||||
#[Groups(['supplier:write:addresses'])]
|
||||
private bool $geoManual = false;
|
||||
|
||||
// Date du dernier geocodage automatique reussi — posee par AddressGeocoder,
|
||||
// jamais ecrite par le client (lecture seule API).
|
||||
#[ORM\Column(name: 'geocoded_at', type: 'datetimetz_immutable', nullable: true)]
|
||||
#[Groups(['supplier:item:read'])]
|
||||
private ?DateTimeImmutable $geocodedAt = null;
|
||||
|
||||
// RG-2.06 : au moins un site rattache a chaque adresse.
|
||||
/** @var Collection<int, SiteInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
|
||||
@@ -372,6 +403,70 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLatitude(): ?string
|
||||
{
|
||||
return $this->latitude;
|
||||
}
|
||||
|
||||
public function setLatitude(float|string|null $latitude): static
|
||||
{
|
||||
$this->latitude = null === $latitude ? null : (string) $latitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLongitude(): ?string
|
||||
{
|
||||
return $this->longitude;
|
||||
}
|
||||
|
||||
public function setLongitude(float|string|null $longitude): static
|
||||
{
|
||||
$this->longitude = null === $longitude ? null : (string) $longitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
|
||||
// meme pattern que triageProvider pour garantir la cle `geoManual` dans le JSON.
|
||||
#[Groups(['supplier:item:read'])]
|
||||
#[SerializedName('geoManual')]
|
||||
public function isGeoManual(): bool
|
||||
{
|
||||
return $this->geoManual;
|
||||
}
|
||||
|
||||
public function setGeoManual(bool $geoManual): static
|
||||
{
|
||||
$this->geoManual = $geoManual;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGeocodedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->geocodedAt;
|
||||
}
|
||||
|
||||
public function setGeocodedAt(?DateTimeImmutable $geocodedAt): static
|
||||
{
|
||||
$this->geocodedAt = $geocodedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse postale affichable / geocodable : « rue, code postal ville ». Le
|
||||
* complement (etage, batiment) est volontairement exclu — il bruite le
|
||||
* geocodage BAN (contrat GeolocatableAddressInterface, M6.1).
|
||||
*/
|
||||
public function getDisplayLabel(): string
|
||||
{
|
||||
$locality = trim(implode(' ', array_filter([$this->postalCode, $this->city])));
|
||||
|
||||
return implode(', ', array_filter([$this->street, '' !== $locality ? $locality : null]));
|
||||
}
|
||||
|
||||
/** @return Collection<int, SiteInterface> */
|
||||
public function getSites(): Collection
|
||||
{
|
||||
|
||||
+6
-1
@@ -10,6 +10,7 @@ use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Shared\Application\Service\AddressGeocoder;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -19,7 +20,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : normalisation serveur du billingEmail en lowercase (RG-1.21)
|
||||
* via le ClientFieldNormalizer partage. Les autres regles de l'onglet Adresse
|
||||
* via le ClientFieldNormalizer partage, puis geocodage automatique BAN
|
||||
* (AddressGeocoder, M6.1) — no-op si le pin a ete corrige a la main
|
||||
* (geoManual = true, RG-6.08). Les autres regles de l'onglet Adresse
|
||||
* sont deja garanties en amont : RG-1.09 (code postal) et RG-1.10 (>= 1 site)
|
||||
* par des contraintes Assert sur l'entite, RG-1.06/07/08/11 par des CHECK BDD.
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
@@ -37,6 +40,7 @@ final class ClientAddressProcessor implements ProcessorInterface
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly ClientFieldNormalizer $normalizer,
|
||||
private readonly AddressGeocoder $addressGeocoder,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -52,6 +56,7 @@ final class ClientAddressProcessor implements ProcessorInterface
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->normalize($data);
|
||||
$this->addressGeocoder->geocode($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
+6
-1
@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
||||
use App\Shared\Application\Service\AddressGeocoder;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -19,7 +20,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
* perimetre ERP-88.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au fournisseur parent. Aucune normalisation
|
||||
* - POST / PATCH : rattachement au fournisseur parent, puis geocodage
|
||||
* automatique BAN (AddressGeocoder, M6.1) — no-op si le pin a ete corrige a
|
||||
* la main (geoManual = true, RG-6.08). Aucune normalisation
|
||||
* specifique (pas d'email de facturation au M2). Les regles de l'onglet
|
||||
* Adresse sont garanties en amont par des contraintes sur l'entite, jouees
|
||||
* par API Platform avant ce processor : RG-2.05 (code postal, Assert\Regex),
|
||||
@@ -40,6 +43,7 @@ final class SupplierAddressProcessor implements ProcessorInterface
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly AddressGeocoder $addressGeocoder,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -54,6 +58,7 @@ final class SupplierAddressProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->addressGeocoder->geocode($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ final class RbacSeeder
|
||||
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
// Tournees (M6 § 8, ERP-123) : Bureau = consultation seule.
|
||||
'field_sales.tours.view',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
@@ -96,6 +98,9 @@ final class RbacSeeder
|
||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
// Tournees (M6 § 8, ERP-123) : Commerciale = view + manage.
|
||||
'field_sales.tours.view',
|
||||
'field_sales.tours.manage',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
|
||||
@@ -203,6 +203,10 @@ final class SeedE2ECommand extends Command
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
'commercial.suppliers.archive',
|
||||
// FieldSales — Tournees (M6, ERP-123). Mappe sur le persona
|
||||
// "tout". Miroir de frontend/tests/e2e/_fixtures/personas.ts.
|
||||
'field_sales.tours.view',
|
||||
'field_sales.tours.manage',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\FieldSales;
|
||||
|
||||
final class FieldSalesModule
|
||||
{
|
||||
public const string ID = 'field_sales';
|
||||
public const string LABEL = 'Tournées';
|
||||
public const bool REQUIRED = false;
|
||||
|
||||
/**
|
||||
* Liste declarative des permissions RBAC exposees par le module FieldSales.
|
||||
*
|
||||
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
|
||||
* qui upserte ces entrees dans la table `permission`, reactive les codes
|
||||
* precedemment orphelins et marque comme orphelins ceux disparus du code.
|
||||
*
|
||||
* La cle `module` est auto-injectee par le sync command a partir de
|
||||
* `self::ID`, inutile de la repeter dans chaque entree.
|
||||
*
|
||||
* Convention de nommage : `module.resource[.sub].action` en snake_case, le
|
||||
* prefixe devant correspondre exactement a `self::ID`.
|
||||
*
|
||||
* Scope V0.2 (spec M6 § 8) : UNIQUEMENT les tournees (le volet « rapport de
|
||||
* visite » a ete retire, plus aucune permission `reports.*`). Granularite
|
||||
* view/manage, alignee sur Core/Commercial. Attribution (matrice § 8) :
|
||||
* Commerciale + Admin = manage ; Bureau = view ; Compta exclue.
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'field_sales.tours.view', 'label' => 'Voir les tournées et l\'onglet Carte'],
|
||||
['code' => 'field_sales.tours.manage', 'label' => 'Créer / modifier / optimiser / dupliquer / supprimer une tournée'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Application\Service;
|
||||
|
||||
use App\Shared\Domain\Contract\GeocoderInterface;
|
||||
use App\Shared\Domain\Contract\GeolocatableAddressInterface;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Geocodage automatique d'une adresse Tiers a la creation / mise a jour
|
||||
* (M6.1, spec § 7). Appele par les processors d'adresse (ClientAddressProcessor
|
||||
* / SupplierAddressProcessor) AVANT le persist.
|
||||
*
|
||||
* RG-6.08 : si le pin a ete corrige a la main (geoManual = true), les
|
||||
* coordonnees sont FIGEES — aucun geocodage, aucune reecriture. Le front
|
||||
* « re-geocode » en repassant geoManual a false (le prochain save regeocode).
|
||||
*
|
||||
* En cas d'echec du geocodage (BAN indisponible, adresse introuvable), les
|
||||
* coordonnees existantes sont CONSERVEES en l'etat : pas de retour en arriere
|
||||
* silencieux, l'adresse reste sauvegardable (badge « a geolocaliser » cote
|
||||
* front si elle n'a aucune coordonnee).
|
||||
*/
|
||||
final class AddressGeocoder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GeocoderInterface $geocoder,
|
||||
) {}
|
||||
|
||||
public function geocode(GeolocatableAddressInterface $address): void
|
||||
{
|
||||
// RG-6.08 : pin manuel -> coordonnees figees.
|
||||
if ($address->isGeoManual()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinates = $this->geocoder->geocode($address->getDisplayLabel());
|
||||
if (null === $coordinates) {
|
||||
return;
|
||||
}
|
||||
|
||||
$address
|
||||
->setLatitude($coordinates->latitude)
|
||||
->setLongitude($coordinates->longitude)
|
||||
->setGeocodedAt(new DateTimeImmutable())
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
use App\Shared\Domain\ValueObject\Coordinates;
|
||||
|
||||
/**
|
||||
* Geocodage d'une adresse postale en coordonnees WGS84 (M6.1, spec § 7).
|
||||
*
|
||||
* Encapsule le fournisseur de geocodage (decision Q-M6-3 : BAN
|
||||
* api-adresse.data.gouv.fr) derriere un contrat pour pouvoir en changer sans
|
||||
* toucher aux appelants.
|
||||
*
|
||||
* Contrat d'erreur : `null` si l'adresse n'est pas geocodable (aucun resultat,
|
||||
* score trop faible) OU si le fournisseur est indisponible (reseau, 5xx). Le
|
||||
* geocodage ne doit JAMAIS bloquer la sauvegarde d'une adresse — une adresse
|
||||
* sans coordonnees reste valide (badge « a geolocaliser », spec § 3.2).
|
||||
*/
|
||||
interface GeocoderInterface
|
||||
{
|
||||
public function geocode(string $address): ?Coordinates;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Adresse Tiers geolocalisable (M6.1, spec § 3.2 / § 4.1).
|
||||
*
|
||||
* Implementee par ClientAddress et SupplierAddress (module Commercial) ; le
|
||||
* futur module FieldSales consommera ce contrat pour router les tournees sans
|
||||
* importer le module Commercial (regle ABSOLUE n°1).
|
||||
*
|
||||
* Les setters (latitude / longitude / geocodedAt) sont la surface d'ecriture
|
||||
* du service de geocodage automatique (AddressGeocoder) ; isGeoManual() porte
|
||||
* la RG-6.08 — un pin corrige a la main fige les coordonnees, le geocodage
|
||||
* auto ne les reecrit plus.
|
||||
*/
|
||||
interface GeolocatableAddressInterface
|
||||
{
|
||||
/** Latitude WGS84 en chaine decimale NUMERIC(10,7), null si non geolocalisee. */
|
||||
public function getLatitude(): ?string;
|
||||
|
||||
/** Longitude WGS84 en chaine decimale NUMERIC(10,7), null si non geolocalisee. */
|
||||
public function getLongitude(): ?string;
|
||||
|
||||
/** Adresse postale affichable / geocodable (rue, code postal, ville). */
|
||||
public function getDisplayLabel(): string;
|
||||
|
||||
/** RG-6.08 : pin corrige a la main — le geocodage auto ne reecrit plus. */
|
||||
public function isGeoManual(): bool;
|
||||
|
||||
public function setLatitude(float|string|null $latitude): static;
|
||||
|
||||
public function setLongitude(float|string|null $longitude): static;
|
||||
|
||||
/** Date du dernier geocodage automatique reussi (posee par AddressGeocoder). */
|
||||
public function setGeocodedAt(?DateTimeImmutable $geocodedAt): static;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat partage rendant un Tiers « visitable » par le module FieldSales (M6),
|
||||
* sans creer de couplage direct FieldSales -> Commercial (regle ABSOLUE n°1).
|
||||
*
|
||||
* Implemente par les Tiers du referentiel : Client (M1) et Supplier (M2), et
|
||||
* extensible aux futurs types (Prestataire...) sans toucher au module FieldSales.
|
||||
*
|
||||
* Resolution polymorphe : une etape de tournee (TourStop, ERP-124) cible un Tiers
|
||||
* via le couple (`tier_type`, `tier_id`) plutot que via une association Doctrine.
|
||||
* `getVisitableType()` fournit la valeur stable de `tier_type` ('client',
|
||||
* 'supplier', ...) qui permet, cote service, de retrouver l'implementation
|
||||
* concrete a partir de l'id. Cette interface n'est donc PAS une cible
|
||||
* `resolve_target_entities` (qui ne mappe qu'un contrat -> une seule classe,
|
||||
* alors qu'ici plusieurs entites l'implementent) : cf. note dans doctrine.yaml.
|
||||
*/
|
||||
interface VisitableInterface
|
||||
{
|
||||
/**
|
||||
* Identifiant du Tiers (null tant que non persiste).
|
||||
*/
|
||||
public function getId(): ?int;
|
||||
|
||||
/**
|
||||
* Libelle affichable du Tiers (ex: raison sociale), pour les pins de la carte
|
||||
* et la liste d'etapes d'une tournee. Jamais null (chaine vide a defaut).
|
||||
*/
|
||||
public function getDisplayName(): string;
|
||||
|
||||
/**
|
||||
* Type stable du Tiers, valeur portee par `tour_stop.tier_type`
|
||||
* ('client' | 'supplier' | ... ). Volontairement une string (et non un enum
|
||||
* ferme) pour rester ouvert aux futurs types Visitable + au point `custom`.
|
||||
*/
|
||||
public function getVisitableType(): string;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\ValueObject;
|
||||
|
||||
/**
|
||||
* Paire de coordonnees WGS84 (latitude / longitude) portee en chaines
|
||||
* decimales a 7 decimales — meme format que les colonnes NUMERIC(10,7) des
|
||||
* adresses Tiers (M6.1, spec § 4.1). Immutable.
|
||||
*/
|
||||
final readonly class Coordinates
|
||||
{
|
||||
public function __construct(
|
||||
public string $latitude,
|
||||
public string $longitude,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fabrique depuis des flottants (ex: geometry GeoJSON de la BAN), arrondis
|
||||
* au format NUMERIC(10,7) des colonnes.
|
||||
*/
|
||||
public static function fromFloats(float $latitude, float $longitude): self
|
||||
{
|
||||
return new self(
|
||||
number_format($latitude, 7, '.', ''),
|
||||
number_format($longitude, 7, '.', ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,10 @@ final class ColumnCommentsCatalog
|
||||
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
|
||||
'billing_email_secondary' => '2e email de facturation, optionnel (max 2). Interdit hors facturation, normalise en minuscules (RG-1.21).',
|
||||
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
|
||||
'latitude' => 'Latitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee, exclue du calcul de tournee (RG-6.05).',
|
||||
'longitude' => 'Longitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee.',
|
||||
'geo_manual' => 'Pin positionne/corrige a la main : si vrai, le geocodage auto ne reecrit plus les coordonnees (RG-6.08). Faux par defaut.',
|
||||
'geocoded_at' => 'Date du dernier geocodage automatique reussi (NULL si jamais geocode ou pin 100% manuel).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'client_address_site' => [
|
||||
@@ -332,6 +336,10 @@ final class ColumnCommentsCatalog
|
||||
'bennes' => 'Nombre de bennes sur le site fournisseur (entier nullable) — specifique fournisseur.',
|
||||
'triage_provider' => 'Le fournisseur est prestataire de triage sur cette adresse. Faux par defaut.',
|
||||
'position' => 'Ordre d affichage de l adresse dans la liste du fournisseur (croissant).',
|
||||
'latitude' => 'Latitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee, exclue du calcul de tournee (RG-6.05).',
|
||||
'longitude' => 'Longitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee.',
|
||||
'geo_manual' => 'Pin positionne/corrige a la main : si vrai, le geocodage auto ne reecrit plus les coordonnees (RG-6.08). Faux par defaut.',
|
||||
'geocoded_at' => 'Date du dernier geocodage automatique reussi (NULL si jamais geocode ou pin 100% manuel).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'supplier_address_site' => [
|
||||
@@ -361,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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Geocoding;
|
||||
|
||||
use App\Shared\Domain\Contract\GeocoderInterface;
|
||||
use App\Shared\Domain\ValueObject\Coordinates;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Geocodeur branche sur la Base Adresse Nationale (BAN) —
|
||||
* `api-adresse.data.gouv.fr` (decision Q-M6-3, spec M6 § 7). Meme endpoint
|
||||
* `/search/` que l'autocompletion d'adresse cote front
|
||||
* (useAddressAutocomplete) : integration unique, service public gratuit.
|
||||
*
|
||||
* Tolerance aux pannes : toute erreur (reseau, 5xx, payload inattendu) est
|
||||
* loggee et convertie en `null` — le geocodage ne bloque jamais la sauvegarde
|
||||
* d'une adresse (contrat GeocoderInterface).
|
||||
*/
|
||||
final class BanGeocoder implements GeocoderInterface
|
||||
{
|
||||
private const string SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/';
|
||||
|
||||
/**
|
||||
* Score BAN minimal (0..1) pour considerer le resultat fiable. En deca
|
||||
* (adresse etrangere, lieu-dit inconnu...), on prefere « pas de
|
||||
* coordonnees » a une position fantaisiste — le pin manuel prend le relais
|
||||
* (spec § 3.2).
|
||||
*/
|
||||
private const float MIN_SCORE = 0.4;
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function geocode(string $address): ?Coordinates
|
||||
{
|
||||
$query = trim($address);
|
||||
if ('' === $query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', self::SEARCH_URL, [
|
||||
'query' => ['q' => $query, 'limit' => 1],
|
||||
'timeout' => 5,
|
||||
]);
|
||||
|
||||
/** @var array{features?: list<array{geometry?: array{coordinates?: array{0: float|int, 1: float|int}}, properties?: array{score?: float|int}}>} $payload */
|
||||
$payload = $response->toArray();
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('Geocodage BAN indisponible : {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
'address' => $query,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$feature = $payload['features'][0] ?? null;
|
||||
if (null === $feature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$score = (float) ($feature['properties']['score'] ?? 0.0);
|
||||
if ($score < self::MIN_SCORE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// GeoJSON : coordinates = [longitude, latitude].
|
||||
$coordinates = $feature['geometry']['coordinates'] ?? null;
|
||||
if (!is_array($coordinates) || !isset($coordinates[0], $coordinates[1])
|
||||
|| !is_numeric($coordinates[0]) || !is_numeric($coordinates[1])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Coordinates::fromFloats((float) $coordinates[1], (float) $coordinates[0]);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -271,6 +275,24 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
return $props;
|
||||
}
|
||||
|
||||
// Range : min ET max poses -> notInRangeMessage (le message utilise par
|
||||
// Symfony dans ce cas) ; borne unique -> message dedie a la borne.
|
||||
if ($constraint instanceof Assert\Range) {
|
||||
if (null !== $constraint->min && null !== $constraint->max) {
|
||||
return ['notInRangeMessage'];
|
||||
}
|
||||
|
||||
$props = [];
|
||||
if (null !== $constraint->min) {
|
||||
$props[] = 'minMessage';
|
||||
}
|
||||
if (null !== $constraint->max) {
|
||||
$props[] = 'maxMessage';
|
||||
}
|
||||
|
||||
return $props;
|
||||
}
|
||||
|
||||
if (in_array($constraint::class, self::SIMPLE_MESSAGE_CONSTRAINTS, true)) {
|
||||
return ['message'];
|
||||
}
|
||||
@@ -288,6 +310,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
Assert\Length::class => new Assert\Length(max: 1),
|
||||
Assert\Count::class => new Assert\Count(min: 1),
|
||||
Assert\Regex::class => new Assert\Regex(pattern: '/^x$/'),
|
||||
Assert\Range::class => new Assert\Range(min: 0, max: 1),
|
||||
default => new $class(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Fixtures\Geocoding;
|
||||
|
||||
use App\Shared\Domain\Contract\GeocoderInterface;
|
||||
use App\Shared\Domain\ValueObject\Coordinates;
|
||||
|
||||
/**
|
||||
* Geocodeur en memoire branche a la place de BanGeocoder en env test
|
||||
* (alias when@test de config/services.yaml) : deterministe, sans reseau.
|
||||
*
|
||||
* Renvoie toujours les memes coordonnees (Poitiers) — suffisant pour verifier
|
||||
* le geocodage au create/update et la RG-6.08 (les tests posent un pin manuel
|
||||
* a des coordonnees DIFFERENTES pour detecter une reecriture indue).
|
||||
*/
|
||||
final class InMemoryGeocoder implements GeocoderInterface
|
||||
{
|
||||
public const string LATITUDE = '46.5802596';
|
||||
public const string LONGITUDE = '0.3404333';
|
||||
|
||||
public function geocode(string $address): ?Coordinates
|
||||
{
|
||||
if ('' === trim($address)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Coordinates(self::LATITUDE, self::LONGITUDE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Tests\Fixtures\Geocoding\InMemoryGeocoder;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de la geolocalisation des adresses Tiers (M6.1 / ERP-122,
|
||||
* spec M6 § 3.2 / § 4.1 / § 7).
|
||||
*
|
||||
* Le geocodeur reel (BanGeocoder) est remplace en env test par
|
||||
* l'InMemoryGeocoder (alias when@test) : deterministe, sans reseau. Couvre :
|
||||
* - geocodage automatique au POST (client ET fournisseur) ;
|
||||
* - pin manuel : PATCH latitude/longitude + geoManual = true ;
|
||||
* - RG-6.08 : geoManual = true fige les coordonnees (un PATCH ulterieur de
|
||||
* l'adresse ne les reecrit pas) ;
|
||||
* - « re-geocoder » : PATCH geoManual = false -> le geocodage auto reprend ;
|
||||
* - bornes WGS84 (Assert\Range) -> 422 avec message FR par champ.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class AddressGeolocationTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
public function testClientAddressIsGeocodedOnCreate(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Geo Create');
|
||||
|
||||
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->clientAddressPayload(),
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame(InMemoryGeocoder::LATITUDE, $data['latitude']);
|
||||
self::assertSame(InMemoryGeocoder::LONGITUDE, $data['longitude']);
|
||||
self::assertFalse($data['geoManual']);
|
||||
self::assertNotNull($data['geocodedAt']);
|
||||
}
|
||||
|
||||
public function testSupplierAddressIsGeocodedOnCreate(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Geo Supplier Create');
|
||||
$category = $this->supplierCategory('NEGOCIANT');
|
||||
|
||||
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'addressType' => 'DEPART',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame(InMemoryGeocoder::LATITUDE, $data['latitude']);
|
||||
self::assertSame(InMemoryGeocoder::LONGITUDE, $data['longitude']);
|
||||
self::assertFalse($data['geoManual']);
|
||||
self::assertNotNull($data['geocodedAt']);
|
||||
}
|
||||
|
||||
public function testManualPinIsPersistedViaPatch(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$addressId = $this->createClientAddress($client, 'Geo Pin');
|
||||
|
||||
// Pin deplace a la main cote front : PATCH coordonnees + geoManual.
|
||||
$data = $client->request('PATCH', '/api/client_addresses/'.$addressId, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => [
|
||||
'latitude' => '48.1234567',
|
||||
'longitude' => '-1.6543217',
|
||||
'geoManual' => true,
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertSame('48.1234567', $data['latitude']);
|
||||
self::assertSame('-1.6543217', $data['longitude']);
|
||||
self::assertTrue($data['geoManual']);
|
||||
}
|
||||
|
||||
public function testManualPinIsNotOverwrittenByAutoGeocoding(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$addressId = $this->createClientAddress($client, 'Geo RG-608');
|
||||
|
||||
$client->request('PATCH', '/api/client_addresses/'.$addressId, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['latitude' => '48.1234567', 'longitude' => '-1.6543217', 'geoManual' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// RG-6.08 : une modification metier ulterieure (rue changee) ne doit PAS
|
||||
// relancer le geocodage — l'InMemoryGeocoder renverrait Poitiers, ce qui
|
||||
// trahirait une reecriture indue du pin manuel.
|
||||
$data = $client->request('PATCH', '/api/client_addresses/'.$addressId, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['street' => '2 rue du Pin Manuel'],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertSame('48.1234567', $data['latitude']);
|
||||
self::assertSame('-1.6543217', $data['longitude']);
|
||||
self::assertTrue($data['geoManual']);
|
||||
}
|
||||
|
||||
public function testClearingManualFlagTriggersRegeocoding(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$addressId = $this->createClientAddress($client, 'Geo Regeocode');
|
||||
|
||||
$client->request('PATCH', '/api/client_addresses/'.$addressId, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['latitude' => '48.1234567', 'longitude' => '-1.6543217', 'geoManual' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// Bouton « Re-geocoder depuis l'adresse » : geoManual repasse a false ->
|
||||
// le processor regeocode depuis l'adresse postale.
|
||||
$data = $client->request('PATCH', '/api/client_addresses/'.$addressId, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['geoManual' => false],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertSame(InMemoryGeocoder::LATITUDE, $data['latitude']);
|
||||
self::assertSame(InMemoryGeocoder::LONGITUDE, $data['longitude']);
|
||||
self::assertFalse($data['geoManual']);
|
||||
self::assertNotNull($data['geocodedAt']);
|
||||
}
|
||||
|
||||
public function testOutOfRangeCoordinatesAreRejected(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$addressId = $this->createClientAddress($client, 'Geo Bounds');
|
||||
|
||||
$body = $client->request('PATCH', '/api/client_addresses/'.$addressId, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['latitude' => '95', 'longitude' => '200', 'geoManual' => true],
|
||||
])->toArray(false);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$byPath = $this->violationsByPath($body);
|
||||
self::assertSame('La latitude doit être comprise entre -90 et 90.', $byPath['latitude'] ?? null);
|
||||
self::assertSame('La longitude doit être comprise entre -180 et 180.', $byPath['longitude'] ?? null);
|
||||
}
|
||||
|
||||
/** Cree un client + une adresse valide, et retourne l'id de l'adresse. */
|
||||
private function createClientAddress(\ApiPlatform\Symfony\Bundle\Test\Client $client, string $companyName): int
|
||||
{
|
||||
$seed = $this->seedClient($companyName);
|
||||
|
||||
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->clientAddressPayload(),
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
return (int) $data['id'];
|
||||
}
|
||||
|
||||
/** Payload minimal valide d'une adresse client (livraison, 1 site, 1 categorie). */
|
||||
private function clientAddressPayload(): array
|
||||
{
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
|
||||
return [
|
||||
'isDelivery' => true,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
];
|
||||
}
|
||||
|
||||
/** Retourne l'IRI du premier site seede (fixtures Sites). */
|
||||
private function firstSiteIri(): string
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
|
||||
|
||||
return '/api/sites/'.$site->getId();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\FieldSales;
|
||||
|
||||
use App\Module\FieldSales\FieldSalesModule;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests structurels du module FieldSales (M6) : identite du module et contrat
|
||||
* `permissions()`. Fige le set de permissions du scope V0.2 (tournees seules,
|
||||
* plus aucune permission `reports.*`).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class FieldSalesModuleTest extends TestCase
|
||||
{
|
||||
public function testModuleIdentity(): void
|
||||
{
|
||||
self::assertSame('field_sales', FieldSalesModule::ID);
|
||||
self::assertSame('Tournées', FieldSalesModule::LABEL);
|
||||
self::assertFalse(FieldSalesModule::REQUIRED);
|
||||
}
|
||||
|
||||
public function testPermissionsSetContainsExactlyTwoCodes(): void
|
||||
{
|
||||
// Garde-fou : si quelqu'un ajoute/retire une permission sans ajuster les
|
||||
// tests ou la doc, ce test casse explicitement. Le scope V0.2 est limite
|
||||
// aux tournees (view + manage) : aucune permission `reports.*`.
|
||||
$codes = array_column(FieldSalesModule::permissions(), 'code');
|
||||
sort($codes);
|
||||
|
||||
self::assertSame(
|
||||
['field_sales.tours.manage', 'field_sales.tours.view'],
|
||||
$codes,
|
||||
);
|
||||
}
|
||||
|
||||
public function testEveryPermissionCodeIsPrefixedByModuleId(): void
|
||||
{
|
||||
// Invariant verifie aussi par app:sync-permissions : chaque code doit
|
||||
// etre prefixe par "<ID>." (sinon la commande de sync echoue).
|
||||
foreach (FieldSalesModule::permissions() as $permission) {
|
||||
self::assertStringStartsWith(FieldSalesModule::ID.'.', $permission['code']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Shared\Geocoding;
|
||||
|
||||
use App\Shared\Infrastructure\Geocoding\BanGeocoder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
/**
|
||||
* Mapping de la reponse BAN (api-adresse.data.gouv.fr/search/) vers le value
|
||||
* object Coordinates (M6.1, spec § 7) — via MockHttpClient, sans reseau.
|
||||
*
|
||||
* Contrat teste : GeoJSON [longitude, latitude] inverse en (latitude,
|
||||
* longitude), arrondi NUMERIC(10,7), score minimal, et tolerance aux pannes
|
||||
* (toute erreur -> null, jamais d'exception).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class BanGeocoderTest extends TestCase
|
||||
{
|
||||
public function testGeocodeMapsBanFeatureToCoordinates(): void
|
||||
{
|
||||
$geocoder = $this->geocoderReturning([
|
||||
'features' => [[
|
||||
'geometry' => ['coordinates' => [0.3404333, 46.5802596]],
|
||||
'properties' => ['score' => 0.97],
|
||||
]],
|
||||
]);
|
||||
|
||||
$coordinates = $geocoder->geocode('1 rue du Test, 86100 Châtellerault');
|
||||
|
||||
self::assertNotNull($coordinates);
|
||||
// GeoJSON = [longitude, latitude] : l'ordre doit etre inverse.
|
||||
self::assertSame('46.5802596', $coordinates->latitude);
|
||||
self::assertSame('0.3404333', $coordinates->longitude);
|
||||
}
|
||||
|
||||
public function testGeocodeRoundsToSevenDecimals(): void
|
||||
{
|
||||
$geocoder = $this->geocoderReturning([
|
||||
'features' => [[
|
||||
'geometry' => ['coordinates' => [0.34043337777, 46.58025961111]],
|
||||
'properties' => ['score' => 0.9],
|
||||
]],
|
||||
]);
|
||||
|
||||
$coordinates = $geocoder->geocode('1 rue du Test');
|
||||
|
||||
self::assertNotNull($coordinates);
|
||||
self::assertSame('46.5802596', $coordinates->latitude);
|
||||
self::assertSame('0.3404334', $coordinates->longitude);
|
||||
}
|
||||
|
||||
public function testGeocodeReturnsNullWhenScoreTooLow(): void
|
||||
{
|
||||
// Score < 0.4 : resultat juge non fiable (adresse etrangere, lieu-dit
|
||||
// inconnu) -> pas de coordonnees plutot qu'une position fantaisiste.
|
||||
$geocoder = $this->geocoderReturning([
|
||||
'features' => [[
|
||||
'geometry' => ['coordinates' => [0.34, 46.58]],
|
||||
'properties' => ['score' => 0.12],
|
||||
]],
|
||||
]);
|
||||
|
||||
self::assertNull($geocoder->geocode('Bahnhofstrasse 1, Zürich'));
|
||||
}
|
||||
|
||||
public function testGeocodeReturnsNullWhenNoFeature(): void
|
||||
{
|
||||
$geocoder = $this->geocoderReturning(['features' => []]);
|
||||
|
||||
self::assertNull($geocoder->geocode('zzz introuvable'));
|
||||
}
|
||||
|
||||
public function testGeocodeReturnsNullOnServerError(): void
|
||||
{
|
||||
$client = new MockHttpClient(new MockResponse('oops', ['http_code' => 500]));
|
||||
$geocoder = new BanGeocoder($client, new NullLogger());
|
||||
|
||||
self::assertNull($geocoder->geocode('1 rue du Test'));
|
||||
}
|
||||
|
||||
public function testGeocodeReturnsNullOnBlankAddressWithoutHttpCall(): void
|
||||
{
|
||||
$client = new MockHttpClient(static function (): never {
|
||||
self::fail('Aucun appel HTTP attendu pour une adresse vide.');
|
||||
});
|
||||
$geocoder = new BanGeocoder($client, new NullLogger());
|
||||
|
||||
self::assertNull($geocoder->geocode(' '));
|
||||
}
|
||||
|
||||
/** Fabrique un BanGeocoder repondant le payload JSON donne (HTTP 200). */
|
||||
private function geocoderReturning(array $payload): BanGeocoder
|
||||
{
|
||||
$client = new MockHttpClient(new MockResponse(
|
||||
json_encode($payload, JSON_THROW_ON_ERROR),
|
||||
['response_headers' => ['content-type' => 'application/json']],
|
||||
));
|
||||
|
||||
return new BanGeocoder($client, new NullLogger());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user