Compare commits

...

21 Commits

Author SHA1 Message Date
matthieu 2424cc7c55 Merge branch 'develop' into feature/M6-field-sales
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 1m6s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 10s
2026-06-11 15:40:15 +00:00
Matthieu f8793ab359 feat(field_sales) : carte interactive Leaflet + écran de planification de tournée (ERP-127)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 56s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 21s
- API visitable_tiers (provider DBAL bbox/q/type, paginé) pour les pins de la carte
- POST /tours/{id}/reorder (drag & drop) : renumérotation atomique + recompute
- Layer front field-sales : TourMap (pins, popup, polyline, sélection rectangle),
  liste d'étapes draggable (vuedraggable), composable de planification + Vitest
- Pages /tours, /tours/new, /tours/[id]/plan (split responsive, point custom géocodé)
- i18n FR, deep links Waze/Google/Apple, état 100% local
2026-06-11 17:38:40 +02:00
gitea-actions 7d8a633eee chore: bump version to v0.1.109
Auto Tag Develop / tag (push) Successful in 7s
2026-06-11 15:10:30 +00:00
tristan df9451a5f4 fix(commercial) : champ Fonction du contact sur 2 colonnes (ERP-147) (#88)
Auto Tag Develop / tag (push) Successful in 8s
ERP-147 — Le champ « Fonction » (jobTitle) du bloc contact passe sur 2 colonnes, côté Client (M1) et Fournisseur (M2).

## Changements
- `ClientContactBlock.vue` — champ Fonction enrobé dans `<div class="col-span-2">`
- `SupplierContactBlock.vue` — idem côté fournisseur

## Détail technique
Le wrapper `col-span-2` est nécessaire car `MalioInputText` (`inheritAttrs:false`) renvoie `class` sur son input interne et non sur la cellule de grille — même pattern que `ClientAddressBlock.vue`.

## Vérification
- `eslint` OK sur les 2 fichiers
- Rendu à valider visuellement sur les écrans Ajouter/Modifier client et fournisseur

Reviewed-on: #88
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 15:10:21 +00:00
Matthieu f8f7571cc0 feat(field_sales) : calcul de trajet, optimisation, duplication & roadbook PDF (ERP-125)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 52s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 11s
- RouteEngineInterface (computeMatrix/optimizeOrder/estimateLegDurations) + HaversineRouteEngine V1 (vitesse moyenne parametrable, plus proche voisin)
- TourRouteCalculator : resolution coords, ETA (RG-6.11), exclusion sans coords (RG-6.05), totaux ; optimize = reorder + recompute
- Endpoints API Platform POST /tours/{id}/compute, /optimize, /duplicate (TourDuplicator, RG-6.13) + Processors, security manage
- Feuille de route PDF GET /tours/{id}/roadbook.pdf (Dompdf + Twig) via PdfRendererInterface (Shared), controller priority:1, security view
- TierAddressResolver etendu (coords + location DBAL)
- Tests : HaversineRouteEngine (unit), compute/optimize/duplicate/roadbook (API)
2026-06-11 16:46:49 +02:00
Matthieu 0052eab1fe feat(field_sales) : entités et API Tournée + Étape (Tour/TourStop) (ERP-124)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 29m14s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 11s
Modèle et API CRUD du module Tournées (M6.3, scope réduit V0.2 : pas de
rapport de visite, donc TourStop sans report_id ni check-in).

- Entités Tour (table tour) + TourStop (table tour_stop) : #[Auditable],
  Timestampable/Blamable, enum TourStatus (draft|planned|in_progress|done),
  soft delete sur Tour.
- API Platform : GET/POST/GET/PATCH/DELETE /api/tours (DELETE = soft delete),
  sous-ressource POST /api/tours/{tourId}/stops + PATCH/DELETE /api/tour_stops/{id}.
- RG-6.01 : tournée personnelle (TourProvider filtre owner ; admin/Bureau
  voient tout). RG-6.03 : adresse appartient au Tiers (TourStopProcessor +
  TierAddressResolver DBAL, sans import inter-module). RG-6.07 : pas d'unicité
  tier_id. RG-6.12 : cohérence custom/Tiers (Assert\Callback).
- Migration racine : tables + COMMENT ON COLUMN FR + index unique
  (tour_id, position) + FK CASCADE ; mirror dans ColumnCommentsCatalog.
- i18n audit (fieldsales_tour / _tourstop), mappings Doctrine + API Platform.
- Tests fonctionnels : owner, RG-6.03/6.07/6.12, pagination, unicité position,
  soft delete, RBAC (17 tests).

Co-Authored-By: Matthieu <mtholot19@gmail.com>
2026-06-11 15:54:10 +02:00
Matthieu be9204eca7 feat(field_sales) : fondations du module Tournées + VisitableInterface + RBAC (ERP-123)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m26s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 13s
- Module FieldSales (ID field_sales, REQUIRED false) avec 2 permissions
  field_sales.tours.view / .manage (scope V0.2, pas de reports.*), active
  dans config/modules.php.
- Contrat partage VisitableInterface (getId/getDisplayName/getVisitableType)
  implemente par Client (client) et Supplier (supplier) sans import inter-module.
  Note doctrine.yaml : contrat polymorphe (2 implementations) donc resolu par
  service via (tier_type, tier_id), pas via resolve_target_entities.
- 3 miroirs RBAC alignes : sidebar.php (section Tournées, item /tours, i18n
  sidebar.field_sales.*), personas.ts et SeedE2ECommand.php (user-full) ;
  matrice metier RbacSeeder (Commerciale = view+manage, Bureau = view, Compta
  exclue, Admin bypass).
2026-06-11 14:51:52 +02:00
Matthieu de4aaa1d64 feat(commercial) : géolocalisation des adresses Tiers (lat/lng + géocodage BAN + pin ajustable) (ERP-122)
Ajoute la géolocalisation aux adresses Client et Fournisseur, socle de la
tournée commerciale (M6 field-sales).

Back :
- migration : latitude/longitude NUMERIC(10,7), geo_manual BOOLEAN, geocoded_at
  TIMESTAMPTZ sur client_address et supplier_address (+ COMMENT ON COLUMN FR)
- GeolocatableAddressInterface (Shared/Domain/Contract) implémenté par les deux
  entités ; bornes WGS84 validées (Range -90/90, -180/180, messages FR)
- GeocoderInterface + BanGeocoder (api-adresse.data.gouv.fr), branché via
  AddressGeocoder dans les processors ; géocodage auto au create/update
- RG-6.08 : geo_manual=true fige les coordonnées (pas de réécriture auto)
- symfony/http-client passe en dépendance de production

Front :
- AddressGeoPin (Leaflet + OSM) : marqueur déplaçable -> PATCH lat/lng +
  geoManual=true, bouton Re-géocoder, badges « à géolocaliser » / « pin manuel »
- intégration dans les blocs adresse Client et Fournisseur

Tests : PHPUnit (géocodage create, non-réécriture RG-6.08, mapping BAN, bornes) +
Vitest (drag du pin, badges, re-géocodage).
2026-06-11 14:31:35 +02:00
gitea-actions cb12490ba0 chore: bump version to v0.1.108
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-11 10:05:53 +00:00
tristan a442d124a3 fix(commercial) : conserver le RIB au changement de type de règlement hors-LCR (ERP-121) (#86)
Auto Tag Develop / tag (push) Successful in 11s
## Contexte — ERP-121

Le passage d'un tiers de **LCR** vers **virement** (ou autre) supprimait ses RIB en base : au changement de type de règlement, le front marquait les `ClientRib` / `SupplierRib` existants pour suppression puis envoyait des `DELETE`. Le métier veut **conserver** le RIB (coordonnée bancaire du tiers, découplée du mode de règlement) pour un éventuel retour en LCR.

## Décisions métier (validées)

1. **Affichage hors-LCR** : RIB **totalement masqué**, ré-affiché au retour LCR — jamais supprimé en base.
2. **RGPD / IBAN** : conservation telle quelle, hors-scope de ce ticket.
3. **Données déjà perdues** : acceptable, le fix ne vaut que pour l'avenir.

## Modifications (100% frontend — clients **et** fournisseurs)

- `new.vue` / `[id]/edit.vue` : `onPaymentTypeChange` ne marque plus les RIB pour suppression et ne jette plus la saisie ; ils sont seulement masqués (`visibleRibs`) et réapparaissent au retour LCR.
- `submitAccounting` ne (re)soumet les RIB que **sous LCR** ; seules les suppressions **explicites** (corbeille d'un bloc) restent en `DELETE`.
- Consultation `[id]/index.vue` : RIB dormants masqués hors-LCR via le helper pur type-safe `paymentTypeCodeOf` (+ tests Vitest).

## Back

**Aucune modification** : la seule règle est `LCR → ≥1 RIB` (RG-1.13 / RG-2.08) ; rien n'interdit un RIB sur un tiers non-LCR. Le guard `Client/SupplierRibProcessor` (refus de supprimer le dernier RIB sous LCR) reste inchangé. **Pas de migration.**

## Vérifications

-  Vitest : **384/384** (`make nuxt-test`)
-  ESLint : clean sur les 10 fichiers
- ⏭️ PHPUnit non lancé : aucun fichier back modifié

Reviewed-on: #86
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 10:05:40 +00:00
gitea-actions 431d831c8b chore: bump version to v0.1.107
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-11 08:09:47 +00:00
matthieu 3f356f0679 feat(commercial) : referentiel pays (country) en base + branchement front (ERP-116) (#79)
Auto Tag Develop / tag (push) Successful in 9s
## Objectif (ERP-116, 1re iteration minimale)

Sortir la liste des pays du **code en dur** cote front et la poser en base comme **referentiel `country`**, source unique du select pays. **Perimetre volontairement minimal** : code ISO + libelle + ordre uniquement — **aucune longueur bancaire/fiscale** (numero de compte, IBAN, TVA, BIC, SIREN) a ce stade.

## Backend
- Entite `Country` (`code` ISO 3166-1 alpha-2 unique, `name`, `position`), calquee sur `Bank` : referentiel statique **lecture seule** (`GetCollection` + `Get`), gating `commercial.clients.view OR commercial.suppliers.view`.
- Migration `Version20260609100000` : table `country` + `COMMENT ON COLUMN` + seed des **6 pays** (France, Allemagne, Belgique, Espagne, Italie, Royaume-Uni), `ON CONFLICT DO NOTHING`.
- `CommercialReferentialFixtures` : re-seed des pays en dev/test.
- Garde-fous : ajout au `ColumnCommentsCatalog` + whitelist `EntitiesAreTimestampableBlamableTest`.

## Frontend
- `useClientReferentials` charge `/countries` (value = **nom** du pays : l'adresse stocke `country` en chaine libre, **pas de FK ni migration de donnees**).
- Les 3 listes `countryOptions` en dur (clients new / edit / consultation) sont supprimees ; la consultation derive ses options de l'embed.

## Hors-scope (iterations suivantes du ticket)
- Longueurs bancaires/fiscales par pays + validation associee.
- FK `country_id` sur les adresses + migration de donnees.

## Tests
- Back : suite complete verte (583), tests API dedies countries (200/seed/405/403/401).
- Front : Vitest vert (256), spec `useClientReferentials` mise a jour.
- Migration appliquee en dev + test.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #79
2026-06-11 08:09:38 +00:00
gitea-actions c1ce940c98 chore: bump version to v0.1.106
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 37s
2026-06-11 07:27:54 +00:00
tristan c594a76d47 feat(front) : page Modification fournisseur (/suppliers/{id}/edit) (ERP-96) (#85)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-96 — Modification fournisseur

Étape 7/7 (front). Dépend de #94 (Ajouter) + #95 (Consultation).

> ⚠️ MR **stackée sur `feature/ERP-95-suppliers-show`** (95 → 94, pas encore mergées dans develop) pour limiter le diff aux 3 fichiers d'ERP-96. À recibler sur `develop` une fois 94 puis 95 mergées. Squash au merge.

### Périmètre
- Route `/suppliers/{id}/edit` : champs **pré-remplis** depuis GET /suppliers/{id}, **PATCH partiel indépendant par onglet**. Bloc principal conservé (éditable via son propre PATCH `supplier:write:main`), pas de contact inline (ERP-106).
- **Mode strict (RG-2.16)** : chaque onglet n'envoie QUE les champs de son groupe de sérialisation (jamais de mélange → sinon 403). Builders de payload scopés (`supplierEdit`).
- Éditabilité par rôle (`resolveTabEditability`) : métier readonly sans `manage` ; Comptabilité visible/éditable selon `accounting.view`/`accounting.manage` ; placeholders non éditables.
- Collections contacts/adresses/RIB : POST/PATCH par ligne + DELETE différé des retraits ; 422 mappées **inline par champ** (`propertyPath` → `useSupplierFormErrors`/`extractApiViolations`), jamais un toast fourre-tout (ERP-101).

### Tests
- Vitest : `supplierEdit.spec.ts` enrichi (mappers d'hydratation `mapMainDraft`/`mapInformationDraft` avec `volumeForecast`/`mapAccountingFormDraft` + `resolveTabEditability` matrice § 2.7). `make nuxt-test` → 375/375 . ESLint .
- `nuxi typecheck` non lancé sur l'hôte (casse le conteneur dev-nuxt).

Miroir de l'écran Modification client (M1), adapté M2 (enum `addressType`, `bennes`/`triageProvider`/`volumeForecast`, pas de relation Distributeur/Courtier).

Reviewed-on: #85
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 07:26:32 +00:00
gitea-actions 59bae8c5e6 chore: bump version to v0.1.105
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 3m21s
2026-06-11 07:17:24 +00:00
tristan 477f77a6b5 feat(front) : page Consultation fournisseur (/suppliers/{id}) lecture seule (ERP-95) (#84)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-95 — Consultation fournisseur (lecture seule)

Étape 6/7 (front). Dépend de #92 (contrat JSON figé) et #94 (blocs/types fournisseur). Bloque #96.

> ⚠️ MR **stackée sur `feature/ERP-94-suppliers-new`** (ERP-94 pas encore mergée dans develop) pour garder le diff limité aux 5 fichiers d'ERP-95. À recibler sur `develop` une fois la 94 mergée. Squash au merge.

### Périmètre
- `useSupplier(id)` : GET /api/suppliers/{id} en Hydra (embed contacts/adresses/ribs + scalaires compta si `accounting.view`), `archive()`/`restore()` via PATCH `isArchived` seul + rechargement complet.
- `supplierConsultation` : mappers purs de l'embed (enum `addressType`, `bennes`/`triageProvider`, `volumeForecast`, gating compta par **omission de clé** → null) + helpers de permissions.
- Page `[id]/index.vue` lecture seule : bloc principal + onglets Information / Contacts / Adresses / Comptabilité (si permission) / 4 coquilles « À venir » ; boutons Modifier (`manage`), Archiver/Restaurer (`archive`) ; flèche retour → répertoire. Miroir de l'écran Consultation client (M1).

### Tests
- Vitest : `supplierConsultation.spec.ts` (mappers + permissions, gating compta) + `useSupplier.spec.ts` (GET/PATCH + propagation 403/409). `make nuxt-test` → 365/365 . ESLint .
- `nuxi typecheck` non lancé sur l'hôte (régénère .nuxt/tailwind en chemins hôte et casse le conteneur dev-nuxt).

Reviewed-on: #84
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 07:15:28 +00:00
tristan d6790dd37d feat(front) : page Ajouter un fournisseur (/suppliers/new) + workflow par onglets (ERP-94) (#83)
Auto Tag Develop / tag (push) Successful in 7s
ERP-94 (etape front 7/7 du M2). **Stack sur #97** (base = `feature/ERP-97-suppliers-i18n-sidebar`, elle-meme sur #93) pour un diff isole. A recibler sur `develop` une fois #93 (MR #81) et #97 (MR #82) mergees.

Page « Ajouter un fournisseur » — **replique a l'identique le fonctionnement de l'ecran Client** (workflow inline par onglets, blocs reutilisables, validation 422 inline ERP-101), avec les specificites M2.

## Architecture (miroir Client)
- Workflow par onglets **inline dans `suppliers/new.vue`** (comme `clients/new.vue` — il n'existe pas de `useClientForm` monolithique). Helpers paralleles : `useSupplierReferentials`, `useSupplierFormErrors`, `supplierFormRules`, `supplierEdit` (payloads), `types/supplierForm`.
- Blocs `SupplierContactBlock` / `SupplierAddressBlock` (miroir des blocs Client).
- POST `/suppliers` puis PATCH partiels par onglet (mode strict, groupes de serialisation). Sous-ressources : `/suppliers/{id}/contacts|addresses|ribs`.
- Validation ERP-101 : 422 `violations[].propertyPath` mappees inline par champ (`useFormErrors` / `mapViolationsToRecord`), `{ toast: false }`, bouton Valider toujours actif.

## Specificites M2 (vs M1)
- Formulaire principal **sans contact inline** (ERP-106) : Entreprise + Categorie (type FOURNISSEUR, `?typeCode=FOURNISSEUR`).
- Adresse : **radio exclusif** Prospect/Depart/Rendu (`addressType` enum, RG-2.09), champs **Bennes** (stepper) + **Prestation de triage**, **pas d'email de facturation**.
- Information : champ **Volume previsionnel** (8e champ).
- Compta (Admin+Compta) : banque si VIREMENT (RG-2.07), RIB si LCR (RG-2.08) ; RIB sous-ressource gardee par `accounting.manage`.

## Tests (mirroir strategie Client)
- `make nuxt-test` : 338 passed (specs ajoutees : supplierFormRules, supplierEdit, useSupplierReferentials, SupplierContactBlock, SupplierAddressBlock).
- ESLint propre ; `nuxi typecheck` (lance en container) : **0 erreur**.
- Golden path navigateur valide end-to-end : POST /suppliers OK, companyName normalise UPPERCASE (RG-2.12), gating des onglets (Information actif, Contacts deverrouille).

## Note de revue
~30 `WARN Duplicated imports` au typecheck : les helpers Supplier exportent les memes noms generiques que leurs equivalents Client (`buildMainPayload`, `omitEmptyRequired`, `RefOption`...), tous deux auto-importes par Nuxt. **Sans impact runtime** : tous les consommateurs utilisent des imports explicites (qui priment). Consequence directe du miroir 1:1 ; une factorisation des generiques dans `shared/` pourrait etre un suivi.

Reviewed-on: #83
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 07:14:51 +00:00
gitea-actions 3c1fc39eee chore: bump version to v0.1.103
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m46s
2026-06-11 07:10:24 +00:00
tristan 1b0339bf1c chore(front) : i18n écrans/onglets fournisseurs + sidebar fournisseur avant client (ERP-97) (#82)
Auto Tag Develop / tag (push) Successful in 8s
ERP-97 (étape front 6/7 du M2, parallèle). **Stack sur #93** (MR #81) : base = `feature/ERP-93-suppliers-list` pour un diff isolé. À recibler sur `develop` une fois #93 mergée.

## Périmètre
- **Sidebar** : `Répertoire fournisseurs` placé **avant** `Répertoire clients` (`config/sidebar.php`). Affichage conditionnel par `commercial.suppliers.view` (déjà câblé par #90), vérifié au navigateur.
- **i18n écrans/onglets** : bloc `commercial.suppliers.*` complété (onglets Information/Contacts/Adresses/Transport/Comptabilité/Statistiques/Rapports/Échanges, titres Consultation/Modification, actions, `comingSoon`, toasts). Scaffolding pour #94/#95/#96.

## Déjà couvert (vérifié, non modifié)
- Clé sidebar `sidebar.commercial.suppliers` : déjà présente.
- Libellés audit-log `audit.entity.commercial_supplier{,address,contact,rib}` : **déjà présents** (ajoutés côté back avec les entités `#[Auditable]`). Garde-fou `AuditableEntitiesHaveI18nLabelTest` : OK (43 assertions).

## Tests
- `make nuxt-test` : 284 passed.
- `AuditableEntitiesHaveI18nLabelTest` (isolé) : OK.
- Golden path : sidebar fournisseurs au-dessus de clients ✓.

Reviewed-on: #82
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 07:08:03 +00:00
gitea-actions cc7a657df9 chore: bump version to v0.1.102
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m47s
2026-06-11 07:01:03 +00:00
tristan d72f67d374 feat(front) : page Répertoire fournisseurs (/suppliers) + datatable + filtres + export (ERP-93) (#81)
Auto Tag Develop / tag (push) Successful in 7s
Page liste `/suppliers` (ERP-93, étape front 6/7 du M2).

## Périmètre
Répertoire fournisseurs uniquement (datatable + filtres + export). Les écrans new/consultation/edit sont d'autres tickets.

- `MalioDataTable` branché sur `usePaginatedList<Supplier>({url:'/suppliers'})`
- Colonnes : Nom, Catégories (`categories[].name`), Site (`sites[].name`, badges colorés), Dernière activité (`updatedAt`) ; clic ligne → `/suppliers/{id}`
- Boutons : « + Ajouter » (manage), « Filtrer » (drawer : search / categoryCode / siteId / includeArchived + badge + Réinitialiser), « Exporter » (XLSX)
- État filtres/pagination 100 % local (règle n°6) ; pagination 10/25/50 ; `useApi()` + composants `Malio*` only

## Différences vs Répertoire clients
- filtre `includeArchived` (au lieu de `archivedOnly`)
- colonne Catégories = `name` (clients affiche `code`)
- catégories du filtre = `?typeCode=FOURNISSEUR` ; export `/suppliers/export.xlsx`

## Tests
- `make nuxt-test` : 284 passed (11 nouveaux : useSuppliersRepository ×3, page index ×8)
- ESLint propre ; typecheck sans erreur sur les fichiers suppliers
- Golden path navigateur OK (page + drawer)

Aucun mirror RBAC à toucher (sidebar + permissions posés par #90/#92).

Reviewed-on: #81
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 07:00:55 +00:00
137 changed files with 15843 additions and 898 deletions
+3 -2
View File
@@ -12,6 +12,7 @@
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"dompdf/dompdf": "^3.1",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8",
@@ -24,6 +25,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 +97,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
+620 -175
View File
@@ -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": "b9a204bab17aa0371f8419362f3bee0c",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2520,6 +2520,161 @@
},
"time": "2026-02-08T16:21:46+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
"time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
@@ -2894,6 +3049,73 @@
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
"time": "2025-07-25T09:04:22+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -3937,6 +4159,86 @@
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v9.3.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.32 || 2.1.32",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.2.8",
"rector/type-perfect": "1.0.0 || 2.1.0",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.4.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0"
},
"time": "2026-03-03T17:31:43+00:00"
},
{
"name": "symfony/asset",
"version": "v8.0.8",
@@ -5412,6 +5714,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",
@@ -8605,6 +9081,149 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "twig/twig",
"version": "v3.24.0",
@@ -11785,180 +12404,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",
+2
View File
@@ -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,
];
+5
View File
@@ -12,6 +12,11 @@ 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'
# Module FieldSales (M6) : resources virtuelles sans entite Doctrine
# (VisitableTierResource — pins de la carte, lecture DBAL).
- '%kernel.project_dir%/src/Module/FieldSales/Infrastructure/ApiPlatform/Resource'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
+17
View File
@@ -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
+24
View File
@@ -1,6 +1,8 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
parameters:
# Vitesse moyenne (km/h) du moteur de trajet V1 Haversine (M6 § 3.4).
field_sales.route_average_speed_kmh: 50.0
imports:
- { resource: version.yaml }
@@ -33,3 +35,25 @@ 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
# Moteur de trajet V1 (M6 § 3.4) : Haversine + plus proche voisin. La V2
# rebranchera OrsRouteEngine ici sans toucher au calculateur ni au front.
App\Module\FieldSales\Domain\Route\RouteEngineInterface:
alias: App\Module\FieldSales\Infrastructure\Route\HaversineRouteEngine
# Rendu PDF (feuille de route M6.4, etc.) : Dompdf.
App\Shared\Domain\Contract\PdfRendererInterface:
alias: App\Shared\Infrastructure\Pdf\DompdfRenderer
# 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
+22 -5
View File
@@ -45,6 +45,13 @@ return [
'label' => 'sidebar.commercial.section',
'icon' => 'mdi:account-arrow-left-outline',
'items' => [
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
'permission' => 'commercial.suppliers.view',
],
[
'label' => 'sidebar.commercial.clients',
'to' => '/clients',
@@ -52,12 +59,22 @@ return [
'module' => 'commercial',
'permission' => 'commercial.clients.view',
],
],
],
// 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.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
'permission' => 'commercial.suppliers.view',
'label' => 'sidebar.field_sales.tours',
'to' => '/tours',
'icon' => 'mdi:map-marker-path',
'module' => 'field_sales',
'permission' => 'field_sales.tours.view',
],
],
],
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.101'
app.version: '0.1.109'
+322
View File
@@ -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/
+255 -1
View File
@@ -40,15 +40,267 @@
},
"catalog": {
"categories": "Gestion des catégories"
},
"field_sales": {
"section": "Tournées",
"tours": "Tournées"
}
},
"dashboard": {
"title": "Tableau de bord",
"welcome": "Bienvenue sur Starseed"
},
"field_sales": {
"tours": {
"title": "Tournées",
"add": "Nouvelle tournée",
"empty": "Aucune tournée pour l'instant.",
"column": {
"label": "Nom",
"date": "Date",
"status": "Statut",
"stops": "Étapes",
"distance": "Distance",
"duration": "Durée"
},
"status": {
"draft": "Brouillon",
"planned": "Planifiée",
"in_progress": "En cours",
"done": "Terminée"
},
"new": {
"title": "Nouvelle tournée",
"label": "Nom de la tournée",
"date": "Date",
"create": "Créer la tournée",
"cancel": "Annuler",
"error": "Impossible de créer la tournée."
}
},
"plan": {
"title": "Planification",
"back": "Retour aux tournées",
"panel": {
"title": "Tournée",
"label": "Nom de la tournée",
"date": "Date",
"departureTime": "Heure de départ",
"startLabel": "Point de départ",
"defaultVisitMinutes": "Durée de visite (min)",
"stops": "Étapes",
"noStops": "Aucune étape. Sélectionnez des Tiers sur la carte ou ajoutez un point libre.",
"distance": "Distance",
"duration": "Durée totale",
"visits": "Visites"
},
"actions": {
"compute": "Trajet logique",
"optimize": "Optimiser",
"duplicate": "Dupliquer",
"pdf": "PDF",
"save": "Enregistrer"
},
"stop": {
"eta": "Arrivée",
"fromPrevious": "depuis l'étape précédente",
"toGeolocate": "À géolocaliser",
"goThere": "Y aller",
"viewTier": "Voir le Tiers",
"remove": "Supprimer l'étape",
"waze": "Waze",
"google": "Google Maps",
"apple": "Plan (Apple)"
},
"custom": {
"add": "Ajouter un point libre",
"title": "Point libre",
"label": "Libellé",
"address": "Adresse",
"confirm": "Ajouter le point",
"cancel": "Annuler",
"geocodeFailed": "Adresse introuvable — ajustez le pin sur la carte.",
"hint": "Saisissez une adresse, elle sera géolocalisée automatiquement."
},
"map": {
"typeClient": "Clients",
"typeSupplier": "Fournisseurs",
"search": "Rechercher un Tiers",
"add": "Ajouter",
"lassoHint": "Maintenez Maj et dessinez un rectangle pour sélectionner plusieurs Tiers."
},
"duplicateModal": {
"title": "Dupliquer la tournée",
"date": "Date de la nouvelle tournée",
"confirm": "Dupliquer",
"cancel": "Annuler"
},
"toast": {
"computeError": "Le calcul du trajet a échoué.",
"optimizeError": "L'optimisation a échoué.",
"duplicateError": "La duplication a échoué.",
"saveError": "L'enregistrement a échoué.",
"loadError": "Impossible de charger la tournée.",
"stopError": "L'opération sur l'étape a échoué.",
"duplicated": "Tournée dupliquée."
}
}
},
"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",
"export": "Exporter",
"empty": "Aucun fournisseur pour l'instant.",
"column": {
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire fournisseurs a échoué. Réessayez.",
"createSuccess": "Fournisseur créé avec succès",
"updateSuccess": "Fournisseur mis à jour avec succès",
"addComplete": "Fournisseur ajouté",
"archiveSuccess": "Fournisseur archivé avec succès",
"restoreSuccess": "Fournisseur restauré avec succès",
"restoreConflict": "Impossible de restaurer : un fournisseur actif portant ce nom existe déjà."
},
"comingSoon": "À venir",
"tab": {
"information": "Information",
"contacts": "Contacts",
"addresses": "Adresses",
"transport": "Transport",
"accounting": "Comptabilité",
"statistics": "Statistiques",
"reports": "Rapports",
"exchanges": "Échanges"
},
"action": {
"edit": "Modifier",
"archive": "Archiver",
"restore": "Restaurer"
},
"consultation": {
"title": "Consultation fournisseur",
"back": "Retour au répertoire",
"loading": "Chargement du fournisseur…",
"notFound": "Fournisseur introuvable.",
"confirmArchive": {
"title": "Archiver le fournisseur",
"message": "Ce fournisseur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
},
"confirmRestore": {
"title": "Restaurer le fournisseur",
"message": "Ce fournisseur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
}
},
"edit": {
"title": "Modifier le fournisseur",
"back": "Retour au répertoire",
"loading": "Chargement du fournisseur…",
"notFound": "Fournisseur introuvable.",
"save": "Valider"
},
"form": {
"title": "Ajouter un fournisseur",
"back": "Précédent",
"submit": "Valider",
"duplicateCompany": "Un fournisseur portant ce nom de société existe déjà.",
"main": {
"companyName": "Nom du fournisseur (Entreprise)",
"categories": "Catégorie"
},
"information": {
"description": "Description",
"competitors": "Concurrent",
"foundedAt": "Date de création",
"employeesCount": "Nombre de salariés",
"revenueAmount": "CA",
"profitAmount": "Résultat",
"directorName": "Dirigeant",
"volumeForecast": "Volume prévisionnel"
},
"contact": {
"title": "Contact {n}",
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
"email": "Email",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro",
"remove": "Supprimer le contact",
"add": "Nouveau contact"
},
"address": {
"title": "Adresse {n}",
"addressType": "Type d'adresse",
"addressTypeProspect": "Prospect",
"addressTypeDepart": "Départ",
"addressTypeRendu": "Rendu",
"categories": "Catégorie",
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
"street": "Adresse",
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
"streetComplement": "Adresse complémentaire",
"sites": "Sites",
"contacts": "Contact(s) rattaché(s)",
"bennes": "Benne(s)",
"triageProvider": "Prestation de triage",
"remove": "Supprimer l'adresse",
"add": "Nouvelle adresse",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
"nTva": "N° de TVA",
"paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement",
"bank": "Banque",
"ribLabel": "Libellé",
"ribBic": "BIC",
"ribIban": "IBAN",
"addRib": "Ajouter un RIB",
"removeRib": "Supprimer le RIB"
},
"confirmDelete": {
"title": "Confirmer la suppression",
"contact": "Supprimer ce contact ?",
"address": "Supprimer cette adresse ?",
"rib": "Supprimer ce RIB ?",
"cancel": "Annuler",
"confirm": "Confirmer"
}
}
},
"clients": {
"title": "Répertoire clients",
"add": "Ajouter",
@@ -269,7 +521,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: '&copy; <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 })
@@ -26,13 +26,18 @@
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
:model-value="model.email"
:label="t('commercial.clients.form.contact.email')"
@@ -0,0 +1,345 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
<MalioSelect
:model-value="model.addressType"
:options="addressTypeOptions"
:label="t('commercial.suppliers.form.address.addressType')"
:readonly="readonly"
empty-option-label=""
:required="true"
:error="errors?.addressType"
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.suppliers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif). -->
<MalioSelectCheckbox
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
porte ici l'email de facturation, absent cote fournisseur). -->
<div aria-hidden="true" />
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.suppliers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.suppliers.form.address.country')"
:readonly="readonly"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.suppliers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('commercial.suppliers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')"
:readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
<!-- Bennes : stepper (specifique fournisseur, defaut 0). -->
<MalioInputNumber
:model-value="model.bennes"
:label="t('commercial.suppliers.form.address.bennes')"
:min="0"
:readonly="readonly"
:error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)"
/>
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). -->
<MalioCheckbox
id="address-triage-provider"
:label="t('commercial.suppliers.form.address.triageProvider')"
:model-value="model.triageProvider"
group-class="self-center"
: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>
<script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: SupplierAddressFormDraft
title: string
/** Categories autorisees sur une adresse (type FOURNISSEUR). */
categoryOptions: CategoryOption[]
/** Sites Starseed disponibles. */
siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */
contactOptions: RefOption[]
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: SupplierAddressFormDraft]
'remove': []
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
'degraded': []
}>()
const { t } = useI18n()
const autocomplete = useAddressAutocomplete()
const model = computed(() => props.modelValue)
const addressTypeOptions = computed<{ value: SupplierAddressType, label: string }[]>(() => [
{ value: 'PROSPECT', label: t('commercial.suppliers.form.address.addressTypeProspect') },
{ value: 'DEPART', label: t('commercial.suppliers.form.address.addressTypeDepart') },
{ value: 'RENDU', label: t('commercial.suppliers.form.address.addressTypeRendu') },
])
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
const degraded = ref(false)
let unavailableNotified = false
const banCityOptions = ref<RefOption[]>([])
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
const cityOptions = computed<RefOption[]>(() => {
const current = props.modelValue.city
if (current && !banCityOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banCityOptions.value]
}
return banCityOptions.value
})
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
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) {
unavailableNotified = true
emit('degraded')
}
}
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) {
return
}
try {
const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
if (query.trim().length < 3) {
banAddressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
banAddressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
function onAddressSelect(option: { label: string, value: string | number } | null): void {
if (option === null) {
return
}
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city,
postalCode: suggestion.postalCode,
})
}
</script>
@@ -0,0 +1,109 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
@click="$emit('remove')"
/>
<MalioInputText
:model-value="model.lastName"
:label="t('commercial.suppliers.form.contact.lastName')"
:readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
:model-value="model.firstName"
:label="t('commercial.suppliers.form.contact.firstName')"
:readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
:model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')"
:readonly="readonly"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
:model-value="model.phonePrimary"
:label="t('commercial.suppliers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary"
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</template>
<script setup lang="ts">
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: SupplierContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: SupplierContactFormDraft]
'remove': []
}>()
const { t } = useI18n()
// Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue)
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
function revealSecondaryPhone(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
}
</script>
@@ -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,
},
},
})
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyAddress } from '~/modules/commercial/types/supplierForm'
import SupplierAddressBlock from '../SupplierAddressBlock.vue'
// Mocks controlables du composable BAN (hoisted).
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
searchCityMock: vi.fn(),
searchAddressMock: vi.fn(),
}))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: searchCityMock,
searchAddress: searchAddressMock,
}),
}))
// 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)
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: {
modelValue: { type: [String, Number, null], default: undefined },
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
loading: { type: Boolean, default: false },
minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', {
'data-testid': 'addr-autocomplete',
'data-options': JSON.stringify(props.options.map(o => o.value)),
})
},
})
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
return mount(SupplierAddressBlock, {
props: {
modelValue: { ...emptyAddress(), ...overrides },
title: 'Adresse 1',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioInputNumber: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
describe('SupplierAddressBlock — specificites M2 (type, bennes, triage)', () => {
it('rend un select de type d\'adresse (en attendant l\'arbitrage metier)', () => {
const wrapper = mountBlock()
const addressTypeSelect = wrapper.findAll('malio-select-stub').find(
el => el.attributes('label') === 'commercial.suppliers.form.address.addressType',
)
expect(addressTypeSelect).toBeDefined()
})
it('rend le stepper Bennes et la case Prestation de triage (champs specifiques fournisseur)', () => {
const wrapper = mountBlock()
expect(wrapper.find('malio-input-number-stub').exists()).toBe(true)
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(true)
})
it('ne rend aucun champ d\'email de facturation (difference M1)', () => {
const wrapper = mountBlock()
// Aucun MalioInputEmail dans le bloc adresse fournisseur.
expect(wrapper.find('malio-input-email-stub').exists()).toBe(false)
})
})
describe('SupplierAddressBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur du type d\'adresse (propertyPath addressType) sur le select', () => {
const wrapper = mountBlock({}, { addressType: 'Le type d\'adresse est obligatoire.' })
const addressTypeSelect = wrapper.findAll('malio-select-stub').find(
el => el.attributes('label') === 'commercial.suppliers.form.address.addressType',
)
expect(addressTypeSelect?.attributes('error')).toBe('Le type d\'adresse est obligatoire.')
})
it('affiche les erreurs serveur sur sites et categories', () => {
const wrapper = mountBlock({}, {
sites: 'Au moins un site est obligatoire.',
categories: 'Au moins une catégorie est obligatoire.',
})
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
const sitesField = checkboxes.find(el => el.attributes('label') === 'commercial.suppliers.form.address.sites')
const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.suppliers.form.address.categories')
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
})
it('affiche l\'erreur serveur sur le code postal', () => {
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
const field = wrapper.findAll('malio-input-text-stub').find(
el => el.attributes('label') === 'commercial.suppliers.form.address.postalCode',
)
expect(field?.attributes('error')).toBe('Code postal invalide.')
})
})
describe('SupplierAddressBlock — autocompletion adresse (BAN) robuste', () => {
beforeEach(() => {
searchAddressMock.mockReset()
})
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
const wrapper = mountBlock()
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
await flushPromises()
expect(searchAddressMock).not.toHaveBeenCalled()
})
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
searchAddressMock
.mockRejectedValueOnce(new Error('BAN indisponible'))
.mockResolvedValueOnce([
{ label: '8 Boulevard du Port, Paris', street: '8 Boulevard du Port', postalCode: '75001', city: 'Paris' },
])
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'boulevard du port')
await flushPromises()
auto.vm.$emit('search', 'boulevard du porte')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(2)
expect(wrapper.find('[data-testid="addr-autocomplete"]').exists()).toBe(true)
})
it('emet « degraded » une seule fois malgre plusieurs erreurs', async () => {
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue de la paix')
await flushPromises()
auto.vm.$emit('search', 'rue de la paixx')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
})
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
const wrapper = mountBlock()
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
})
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
const wrapper = mountBlock({ street: '8 Boulevard du Port' })
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
expect(values).toContain('8 Boulevard du Port')
})
})
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyContact } from '~/modules/commercial/types/supplierForm'
import SupplierContactBlock from '../SupplierContactBlock.vue'
// 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)
/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */
function errorProbe(testid: string) {
return defineComponent({
name: `Probe-${testid}`,
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
},
})
}
function mountBlock(errors?: Record<string, string>) {
return mount(SupplierContactBlock, {
props: {
modelValue: emptyContact(),
title: 'Contact 1',
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputPhone: true,
MalioInputText: errorProbe('contact-text'),
MalioInputEmail: errorProbe('contact-email'),
},
},
})
}
describe('SupplierContactBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
const wrapper = mountBlock({ email: 'Adresse e-mail invalide.' })
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('Adresse e-mail invalide.')
})
it('laisse les champs sans erreur quand errors est absent', () => {
const wrapper = mountBlock()
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('')
})
})
@@ -30,6 +30,10 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
}
if (url === '/countries') {
// Pays : value === label === name (l'adresse stocke le nom).
return Promise.resolve({ member: [{ '@id': '/api/countries/1', code: 'FR', name: 'France' }] })
}
return Promise.resolve({
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
})
@@ -44,6 +48,8 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
// Pays : value = nom du pays (et non l'IRI).
expect(refs.countries.value).toEqual([{ value: 'France', label: 'France' }])
// Seul le select en echec reste vide.
expect(refs.categories.value).toEqual([])
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom).
const mockGet = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
const { useSupplier } = await import('../useSupplier')
const SAMPLE = { '@id': '/api/suppliers/85', id: 85, companyName: 'DOD59393F 862875', isArchived: false }
describe('useSupplier', () => {
beforeEach(() => {
mockGet.mockReset()
mockPatch.mockReset()
mockGet.mockResolvedValue(SAMPLE)
mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
})
it('charge le detail via GET /suppliers/{id} en Hydra, sans toast', async () => {
const { supplier, load } = useSupplier(85)
await load()
expect(mockGet).toHaveBeenCalledWith(
'/suppliers/85',
{},
expect.objectContaining({
headers: { Accept: 'application/ld+json' },
toast: false,
}),
)
expect(supplier.value).toEqual(SAMPLE)
})
it('bascule loading pendant le chargement et le retombe a false', async () => {
const { loading, load } = useSupplier(85)
const promise = load()
expect(loading.value).toBe(true)
await promise
expect(loading.value).toBe(false)
})
it('marque error et laisse supplier null si le GET echoue (404...)', async () => {
mockGet.mockRejectedValueOnce(new Error('not found'))
const { supplier, error, load } = useSupplier(99)
await load()
expect(error.value).toBe(true)
expect(supplier.value).toBeNull()
})
it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => {
// 1er GET = chargement initial, 2e GET = rechargement post-archivage.
mockGet.mockResolvedValueOnce(SAMPLE)
mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true })
const { supplier, load, archive } = useSupplier(85)
await load()
await archive()
expect(mockPatch).toHaveBeenCalledWith(
'/suppliers/85',
{ isArchived: true },
expect.objectContaining({ toast: false }),
)
// Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
expect(mockGet).toHaveBeenCalledTimes(2)
expect(supplier.value?.isArchived).toBe(true)
})
it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
const { load, restore } = useSupplier(85)
await load()
await restore()
expect(mockPatch).toHaveBeenCalledWith(
'/suppliers/85',
{ isArchived: false },
expect.objectContaining({ toast: false }),
)
})
it('propage l\'erreur (ex: 403 sans permission archive, 409 conflit homonyme) au lieu de l\'avaler', async () => {
const forbidden = { response: { status: 403 } }
mockPatch.mockRejectedValueOnce(forbidden)
const { load, archive } = useSupplier(85)
await load()
await expect(archive()).rejects.toBe(forbidden)
})
})
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
// les appels de chargement des referentiels et controler les reponses Hydra.
const mockGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockGet }))
const { useSupplierReferentials } = await import('../useSupplierReferentials')
describe('useSupplierReferentials', () => {
beforeEach(() => {
mockGet.mockReset()
mockGet.mockResolvedValue({ member: [] })
})
it('charge les categories filtrees sur le type FOURNISSEUR (RG-2.10)', async () => {
await useSupplierReferentials().loadCommon()
expect(mockGet).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }),
expect.objectContaining({ toast: false }),
)
})
it('mappe les categories en options { value: IRI, label: name, code }', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/categories') {
return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'NEGOCIANT', name: 'Négociant' }] })
}
return Promise.resolve({ member: [] })
})
const refs = useSupplierReferentials()
await refs.loadCommon()
expect(refs.categories.value).toEqual([{ value: '/api/categories/9', label: 'Négociant', code: 'NEGOCIANT' }])
})
it('ne charge ni distributeurs ni courtiers (absents du modele fournisseur)', async () => {
await useSupplierReferentials().loadCommon()
const urls = mockGet.mock.calls.map(c => c[0])
expect(urls).not.toContain('/clients')
expect(urls).toEqual(
expect.arrayContaining(['/categories', '/sites', '/tva_modes', '/payment_delays', '/payment_types', '/banks']),
)
})
it('reste resilient : un referentiel en echec n\'empeche pas les autres', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/categories') return Promise.reject(new Error('403'))
if (url === '/banks') return Promise.resolve({ member: [{ '@id': '/api/banks/1', code: 'SG', label: 'Société Générale' }] })
return Promise.resolve({ member: [] })
})
const refs = useSupplierReferentials()
await refs.loadCommon()
expect(refs.categories.value).toEqual([])
expect(refs.banks.value).toEqual([{ value: '/api/banks/1', label: 'Société Générale' }])
})
})
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { HydraCollection } from '~/shared/utils/api'
import type { Supplier } from '../useSuppliersRepository'
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
// les appels declenches par usePaginatedList (que useSuppliersRepository enveloppe)
// et controler les reponses. Meme pattern que useClientsRepository.spec.ts.
const mockGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}))
// Import APRES le stub pour que useApi soit bien resolu au top-level du module.
const { useSuppliersRepository } = await import('../useSuppliersRepository')
/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */
function makeHydra(total: number): HydraCollection<Supplier> {
return { totalItems: total, member: [] }
}
describe('useSuppliersRepository', () => {
beforeEach(() => {
mockGet.mockReset()
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(25))
})
it('cible la ressource /suppliers en page 1 par defaut', async () => {
const repo = useSuppliersRepository()
await repo.fetch()
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{ page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }),
)
})
it('pousse les filtres du drawer (categories multi, sites, archives inclus) et retombe en page 1', async () => {
const repo = useSuppliersRepository()
await repo.fetch()
await repo.goToPage(2)
expect(repo.currentPage.value).toBe(2)
await repo.setFilters(
{
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
},
{ replace: true },
)
expect(repo.currentPage.value).toBe(1)
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
page: 1,
itemsPerPage: 10,
},
expect.objectContaining({ toast: false }),
)
})
it('repasse a une query propre apres reinitialisation des filtres', async () => {
const repo = useSuppliersRepository()
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true })
await repo.setFilters({}, { replace: true })
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{ page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }),
)
})
})
@@ -3,7 +3,7 @@ import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
* reglement, banques, et les listes distributeurs / courtiers.
* reglement, banques, pays, et les listes distributeurs / courtiers.
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
@@ -57,6 +57,11 @@ interface ClientMember extends HydraMember {
companyName: string
}
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useClientReferentials() {
@@ -68,6 +73,7 @@ export function useClientReferentials() {
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
const distributors = ref<ClientOption[]>([])
const brokers = ref<ClientOption[]>([])
@@ -116,6 +122,12 @@ export function useClientReferentials() {
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
fetchAll<ReferentialMember>('/banks')
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
// car l'adresse stocke `country` en chaine libre (« France »...). On
// conserve ainsi la compatibilite avec les adresses existantes sans FK
// ni migration de donnees a ce stade. value === label.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
])
}
@@ -144,6 +156,7 @@ export function useClientReferentials() {
paymentDelays,
paymentTypes,
banks,
countries,
distributors,
brokers,
loadCommon,
@@ -0,0 +1,71 @@
import { ref } from 'vue'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
/**
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
* fournisseur », ERP-95). Miroir de `useClient` (M1). Lit le detail embarque via
* `GET /api/suppliers/{id}` (contacts / adresses / ribs sous `supplier:item:read` /
* `supplier:read:accounting`) et expose les bascules d'archivage (PATCH `isArchived`
* SEUL — tout autre champ => 422).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
* Hydra complet (sans lui, API Platform 4 renvoie une representation reduite).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs
* d'archivage/restauration (notamment le 409 d'homonyme actif a la restauration)
* sont PROPAGEES a l'appelant, qui decide du toast a afficher.
*/
export function useSupplier(id: number | string) {
const api = useApi()
const supplier = ref<SupplierDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
function fetchDetail(): Promise<SupplierDetail> {
return api.get<SupplierDetail>(
`/suppliers/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le detail du fournisseur. En cas d'echec : `error = true`, `supplier = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
supplier.value = await fetchDetail()
}
catch {
error.value = true
supplier.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422),
* puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe
* `supplier:read` (ni l'embed contacts/adresses/ribs ni les libelles des
* referentiels comptables), un simple merge laisserait l'affichage incoherent.
* Toute erreur (notamment le 409 d'homonyme actif a la restauration) est
* propagee a l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/suppliers/${id}`, { isArchived }, { toast: false })
supplier.value = await fetchDetail()
}
return {
supplier,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -0,0 +1,88 @@
/**
* Composable d'erreurs partage des ecrans fournisseur (creation + edition, M2
* Commercial). Miroir de `useClientFormErrors` (M1) :
* - un `useFormErrors` par groupe scalaire (Principal / Information /
* Comptabilite) : violations 422 affichees inline sous chaque champ ;
* - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts /
* adresses / RIB), aligne sur l'index du `v-for`.
*
* `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe
* inline). Chaque page conserve ainsi son propre fallback dans le `catch`.
*/
import { ref, type Ref } from 'vue'
import { mapViolationsToRecord } from '~/shared/utils/api'
export function useSupplierFormErrors() {
const mainErrors = useFormErrors()
const informationErrors = useFormErrors()
const accountingErrors = useFormErrors()
const contactErrors = ref<Record<string, string>[]>([])
const addressErrors = ref<Record<string, string>[]>([])
const ribErrors = ref<Record<string, string>[]>([])
/**
* Mappe l'erreur d'une ligne de collection sur le tableau cible (par index).
* 422 avec violations exploitables → erreurs inline sous les champs de la
* ligne + retourne true. Sinon → ne touche pas la cible et retourne false.
*/
function mapRowError(
error: unknown,
target: Ref<Record<string, string>[]>,
index: number,
): boolean {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
return true
}
return false
}
/**
* Soumet TOUS les blocs d'une collection (contacts / adresses / RIB) en
* collectant les erreurs par index : on n'arrete PAS au premier bloc en echec
* (decision ERP-110 / ERP-101). Reinitialise le tableau d'erreurs cible, tente
* chaque ligne via `saveRow`, mappe les 422 inline (mapRowError) ou delegue le
* fallback a `onUnmappedError`. `shouldSkip` permet d'ignorer les blocs vides.
* Retourne true si au moins un bloc a echoue.
*/
async function submitRows<T>(
rows: T[],
target: Ref<Record<string, string>[]>,
saveRow: (row: T, index: number) => Promise<void>,
onUnmappedError: (error: unknown, index: number) => void,
shouldSkip?: (row: T, index: number) => boolean,
): Promise<boolean> {
target.value = []
let hasError = false
for (let index = 0; index < rows.length; index++) {
const row = rows[index] as T
if (shouldSkip?.(row, index)) {
continue
}
try {
await saveRow(row, index)
}
catch (error) {
if (!mapRowError(error, target, index)) {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
return {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
submitRows,
}
}
@@ -0,0 +1,132 @@
import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
* « Ajouter un fournisseur » : categories (type FOURNISSEUR), sites, modes de TVA,
* delais et types de reglement, banques. Miroir de `useClientReferentials` (M1).
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
* l'en-tete `Accept: application/ld+json` impose par API Platform 4 pour obtenir
* l'enveloppe Hydra (`member`). Les valeurs d'option sont les IRI Hydra (`@id`)
* renvoyees telles quelles dans les payloads POST/PATCH (relations M:1 / M:N).
*
* Difference M2 : pas de distributeurs/courtiers (absents du modele fournisseur).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
}
/** Option de type de reglement enrichie de son code stable (RG-2.07 / RG-2.08). */
export interface PaymentTypeOption extends RefOption {
code: string
}
/** Option de categorie enrichie de son code stable. */
export interface CategoryOption extends RefOption {
code: string
}
interface HydraMember {
'@id': string
}
interface CategoryMember extends HydraMember {
code: string
name: string
}
interface SiteMember extends HydraMember {
name: string
postalCode: string
}
interface ReferentialMember extends HydraMember {
code: string
label: string
}
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useSupplierReferentials() {
const api = useApi()
const categories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
/** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>(
url: string,
query: Record<string, string | string[]> = {},
): Promise<T[]> {
const res = await api.get<{ member?: T[] }>(
url,
{ pagination: 'false', ...query },
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
/**
* Charge en parallele les referentiels communs.
*
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole.
* Necessaire pour les roles metier qui n'ont pas toutes les permissions de
* lecture (ex. Compta a `commercial.suppliers.view` mais pas forcement
* `catalog.categories.view` ni `sites.view`). Un referentiel en echec reste
* simplement vide.
*/
async function loadCommon(): Promise<void> {
await Promise.allSettled([
// Taxonomie multi-types (ERP-84) : un fournisseur ne porte que des
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ».
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays')
.then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }),
fetchAll<ReferentialMember>('/payment_types')
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
fetchAll<ReferentialMember>('/banks')
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
// car l'adresse stocke `country` en chaine libre (« France »...). On
// conserve ainsi la compatibilite avec les adresses existantes sans FK
// ni migration de donnees a ce stade. value === label. Aligne sur les
// clients (`useClientReferentials`) pour une liste de pays identique.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
])
}
return {
categories,
sites,
tvaModes,
paymentDelays,
paymentTypes,
banks,
countries,
loadCommon,
}
}
@@ -0,0 +1,54 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
/**
* Site Starseed rattache a une adresse du fournisseur, tel qu'embarque en LISTE
* (groupe site:read) pour la colonne « Site » du Repertoire (badges colores).
* Agrege des adresses cote back via Supplier::getSites() (cf. spec-back M2).
*/
export interface SupplierSite {
id: number
name: string
color: string
}
/**
* Categorie (type FOURNISSEUR) rattachee au fournisseur, embarquee en LISTE
* (groupe category:read). La colonne « Catégories » affiche le `name` (et non le
* `code` comme au M1 clients — decision spec-front M2 § Datatable).
*/
export interface SupplierCategory {
code: string
name: string
}
/**
* Vue MINIMALE d'un fournisseur pour le Repertoire (datatable). Volontairement
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-93).
*/
export interface Supplier {
id: number
companyName: string
categories: SupplierCategory[]
sites: SupplierSite[]
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
updatedAt: string | null
isArchived: boolean
}
/**
* Repertoire fournisseurs (ERP-93) — simple enveloppe de `usePaginatedList<Supplier>`
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
*
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
* par la page via `setFilters` du composable partage — la remise en page 1 est
* garantie.
*
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
* `usePaginatedList`. Aucun reset au logout a gerer.
*/
export function useSuppliersRepository() {
return usePaginatedList<Supplier>({ url: '/suppliers' })
}
@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref } from 'vue'
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
// La page ne les importe pas (auto-import) : on les expose en globals pour le
// runtime de test (happy-dom). Meme philosophie que les autres specs commercial.
const mockPush = vi.hoisted(() => vi.fn())
const mockApiGet = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
const mockSetFilters = vi.hoisted(() => vi.fn())
const mockFetch = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
// Le repository est lui aussi un auto-import : on controle items + setFilters.
vi.stubGlobal('useSuppliersRepository', () => ({
items: ref([
{
id: 7,
companyName: 'ACME',
categories: [{ code: 'NEG', name: 'Négociant' }],
sites: [{ id: 86, name: '86', color: '#123456' }],
updatedAt: '2026-01-15T10:00:00+00:00',
},
]),
totalItems: ref(1),
currentPage: ref(1),
itemsPerPage: ref(10),
itemsPerPageOptions: ref([10, 25, 50]),
fetch: mockFetch,
goToPage: vi.fn(),
setItemsPerPage: vi.fn(),
setFilters: mockSetFilters,
}))
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
globalThis.URL.revokeObjectURL = vi.fn()
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
const SuppliersIndex = (await import('../suppliers/index.vue')).default
// ── Stubs de composants ──────────────────────────────────────────────────────
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const DataTableStub = defineComponent({
props: { items: { type: Array, default: () => [] } },
emits: ['row-click', 'update:page', 'update:per-page'],
setup(props, { emit }) {
return () => h('div', { 'data-testid': 'datatable' },
(props.items as Array<{ id: number }>).map(it =>
h('tr', { 'data-row-id': it.id, onClick: () => emit('row-click', it) }),
),
)
},
})
const DrawerStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) {
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
},
})
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
const PageHeaderStub = defineComponent({
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
})
const CheckboxStub = defineComponent({
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
emits: ['update:model-value'],
setup(props, { emit }) {
return () => h('input', {
'type': 'checkbox',
'data-id': props.id,
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
})
},
})
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
function mountPage() {
return mount(SuppliersIndex, {
global: {
stubs: {
PageHeader: PageHeaderStub,
MalioButton: ButtonStub,
MalioDataTable: DataTableStub,
MalioDrawer: DrawerStub,
MalioAccordion: SlotStub,
MalioAccordionItem: SlotStub,
MalioInputText: InputTextStub,
MalioCheckbox: CheckboxStub,
},
},
})
}
describe('Répertoire fournisseurs (page /suppliers)', () => {
beforeEach(() => {
mockPush.mockReset()
mockApiGet.mockReset().mockResolvedValue({ member: [] })
mockCan.mockReset().mockReturnValue(true)
mockSetFilters.mockReset()
mockFetch.mockReset()
mockToastError.mockReset()
})
it('charge la liste au montage', async () => {
mountPage()
await flushPromises()
expect(mockFetch).toHaveBeenCalled()
})
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.manage')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(true)
})
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.view')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(false)
})
it('navigue vers la consultation au clic sur une ligne', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('tr[data-row-id="7"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/suppliers/7')
})
it('charge les categories de type FOURNISSEUR pour le filtre', async () => {
mountPage()
await flushPromises()
expect(mockApiGet).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }),
expect.objectContaining({ toast: false }),
)
})
it('appelle l\'export XLSX sur /suppliers/export.xlsx en blob', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('[data-label="commercial.suppliers.export"]').trigger('click')
await flushPromises()
expect(mockApiGet).toHaveBeenCalledWith(
'/suppliers/export.xlsx',
expect.any(Object),
expect.objectContaining({ responseType: 'blob', toast: false }),
)
})
it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => {
const wrapper = mountPage()
await flushPromises()
// Coche « Inclure les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ includeArchived: true },
{ replace: true },
)
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
expect(mockPush).not.toHaveBeenCalled()
})
it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
expect(wrapper.find('[data-label="commercial.suppliers.filters.title (1)"]').exists()).toBe(true)
// Réinitialiser → query propre (setFilters avec objet vide).
await wrapper.find('[data-label="commercial.suppliers.filters.reset"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
})
})
@@ -303,7 +303,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly"
v-if="!accountingReadonly && visibleRibs.length > 1"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -553,10 +553,21 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays : referentiel `country` charge via l'API (ERP-116), en remplacement de
// l'ancienne liste codee en dur. Valeur = nom du pays (l'adresse stocke
// `country` en chaine libre, donc value === label). On merge la valeur deja
// stockee sur chaque adresse (embed) — comme les autres selects de cet ecran —
// pour ne pas vider le select si `/countries` echoue (resilience ERP-102) ou si
// un pays historique n'appartient pas au referentiel.
const embedCountryOptions = computed<RefOption[]>(() =>
mergeOptions([], (client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c)
.map(c => ({ value: c, label: c }))),
)
const countryOptions = computed<RefOption[]>(() =>
mergeOptions(referentials.countries.value, embedCountryOptions.value),
)
const relationOptions = computed<RefOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
@@ -689,7 +700,7 @@ async function submitMain(): Promise<void> {
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main, { forUpdate: true }), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
@@ -859,7 +870,10 @@ async function submitAddresses(): Promise<void> {
addresses.value,
addressErrors,
async (address) => {
const body = buildAddressPayload(address, isBillingEmailRequired(address))
// Edition d'une adresse existante : champ requis vide envoye en `''`
// (NotBlank 422) au lieu d'etre omis — sinon le PATCH garderait
// l'ancienne valeur (faux 200). Creation (id null) : omit classique.
const body = buildAddressPayload(address, isBillingEmailRequired(address), { forUpdate: address.id !== null })
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/addresses`,
@@ -898,17 +912,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
// marques pour suppression serveur au prochain enregistrement.
// ERP-121 : un RIB est une coordonnee bancaire du client, decouplee du mode de
// reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
// restent en base, simplement masques a l'ecran (visibleRibs = []), et
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
// bloc (askRemoveRib) retire reellement un RIB.
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
for (const rib of ribs.value) {
if (rib.id != null) removedRibIds.value.push(rib.id)
}
ribs.value = []
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
ribErrors.value = []
}
}
@@ -937,45 +950,58 @@ function askRemoveRib(index: number): void {
/**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
* back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide RG-1.13
* (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en
* dernier (le guard back n'autorise la suppression du dernier RIB qu'une fois quitte
* LCR). Aucun champ main/information dans le payload (mode strict RG-1.28 : sinon
* 403 sur tout le payload).
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
*
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
* de type de reglement. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
const body = buildRibPayload(rib)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
},
error => showError(error),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
rib => rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
// une LCR a l'etape 2. Hors-LCR (ERP-121), les RIB sont des coordonnees
// dormantes : rien d'editable n'est affiche, on ne les re-soumet pas.
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
// sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la
// soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE
// echouer en « dernier RIB d'une LCR » (message plat sans propertyPath).
if (isRibRequired.value) {
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
},
error => showError(error),
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
@@ -986,8 +1012,9 @@ async function submitAccounting(): Promise<void> {
return
}
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
@@ -280,7 +280,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditClient,
@@ -290,6 +290,7 @@ import {
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
relationOf,
showArchiveAction,
@@ -355,9 +356,16 @@ const addressViews = computed(() => {
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
})
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
// client n'en a pas (un RIB n'existe que pour un reglement LCR — RG-1.13). Pas
// de bloc vierge fantome en consultation.
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
// client n'en a pas. Pas de bloc vierge fantome en consultation.
// ERP-121 : un client peut desormais conserver des RIB « dormants » apres etre
// repasse hors-LCR (on ne les supprime plus). En consultation, decision metier =
// on les masque TOTALEMENT : on n'affiche les RIB que si le type de reglement
// courant est LCR (le `code` est embarque sous client:read:accounting).
const ribs = computed(() =>
isRibRequiredForPaymentType(paymentTypeCodeOf(client.value?.paymentType))
? (client.value?.ribs ?? []).map(mapRibToDraft)
: [],
)
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -384,10 +392,18 @@ const relationOptions = computed<SelectOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
const countryOptions: SelectOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays (ERP-116) : options construites depuis l'EMBED des adresses (jamais via
// GET /countries, sur le meme principe que les autres selects de consultation
// — en 403 pour les roles metier non-admin). Valeur = nom du pays stocke tel
// quel dans l'adresse, donc value === label ; suffit a afficher le libelle en
// lecture seule.
const countryOptions = computed<SelectOption[]>(() =>
[...new Set(
(client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c),
)].map(c => ({ value: c, label: c })),
)
// Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
@@ -302,7 +302,7 @@
>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="!accountingReadonly"
v-if="!accountingReadonly && visibleRibs.length > 1"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -778,11 +778,17 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays disponibles : referentiel `country` charge via l'API (ERP-116), en
// remplacement de l'ancienne liste codee en dur. France reste preselectionnee
// par defaut sur chaque adresse (cf. valeur initiale du draft d'adresse) : on
// garantit donc sa presence en fallback si `/countries` echoue (resilience
// ERP-102), pour ne pas afficher un select vide sur une valeur deja soumise.
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
@@ -875,13 +881,14 @@ function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
// quand LCR est choisi, on vide la liste sinon (pas de RIB fantome soumis).
// ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
// masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
ribs.value = []
ribErrors.value = []
}
}
@@ -918,35 +925,41 @@ async function submitAccounting(): Promise<void> {
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
// Payload partage avec l'edition (buildRibPayload, ERP-119).
const body = buildRibPayload(rib)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
rib => rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
// une LCR a l'etape 2. Hors-LCR (ERP-121), une saisie RIB eventuellement
// restee dans le brouillon est masquee et n'est PAS persistee (pas de RIB
// orphelin sur un client en virement).
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
// sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline.
if (isRibRequired.value) {
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
// Payload partage avec l'edition (buildRibPayload, ERP-119).
const body = buildRibPayload(rib)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
@@ -0,0 +1,945 @@
<template>
<div>
<!-- En-tete : retour consultation + nom du fournisseur. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.suppliers.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.suppliers.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.suppliers.edit.notFound') }}</p>
<template v-else-if="supplier">
<!-- Bloc principal (pre-rempli, editable si `manage`)
Conserve en modification (miroir client) ; edite via son propre
PATCH scope sur le groupe supplier:write:main. Readonly pour les
roles sans `manage` (ex. Compta). Pas de contact inline (ERP-106). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.edit.save')"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs. -->
<MalioInputTextArea
v-model="information.description"
:label="t('commercial.suppliers.form.information.description')"
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="businessReadonly"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="businessReadonly"
:error="informationErrors.errors.competitors"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="businessReadonly"
:error="informationErrors.errors.employeesCount"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="businessReadonly"
:error="informationErrors.errors.revenueAmount"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="businessReadonly"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="businessReadonly"
:error="informationErrors.errors.profitAmount"
/>
<!-- Volume previsionnel : specifique fournisseur (entier). -->
<MalioInputText
v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK"
:readonly="businessReadonly"
:error="informationErrors.errors.volumeForecast"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.edit.save')"
:disabled="tabSubmitting"
@click="submitInformation"
/>
</div>
</template>
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.edit.save')"
:disabled="tabSubmitting"
@click="submitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresses -->
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<SupplierAddressBlock
v-for="(address, index) in addresses"
:key="address.id ?? `new-${index}`"
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:category-options="mainCategoryOptions"
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="addresses.length > 1"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.edit.save')"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view ;
editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
v-if="isBankRequired"
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
v-if="isRibRequired"
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.edit.save')"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList>
</template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.suppliers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { useSupplierReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors'
import {
canEditSupplier,
categoryOptionsOf,
referentialOptionOf,
siteOptionsOf,
mapContactToDraft,
mapAddressToDraft,
mapRibToDraft,
type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
mapAccountingFormDraft,
mapInformationDraft,
mapMainDraft,
resolveTabEditability,
type AccountingFormDraft,
type InformationFormDraft,
type MainFormDraft,
type SupplierEditAbilities,
} from '~/modules/commercial/utils/supplierEdit'
import {
buildSupplierFormTabKeys,
isAddressValid,
isBankRequiredForPaymentType,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/supplierFormRules'
import {
emptyAddress,
emptyContact,
emptyRib,
type SupplierAddressFormDraft,
type SupplierContactFormDraft,
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
const EMPLOYEES_MASK = '#######'
// Volume previsionnel : champ texte borne aux chiffres (entier >= 0 cote back).
const VOLUME_FORECAST_MASK = '##########'
const { t } = useI18n()
const api = useApi()
const toast = useToast()
const route = useRoute()
const router = useRouter()
const { can, canAny } = usePermissions()
// Gating de la route : l'edition exige de pouvoir editer au moins un onglet
// (`manage` OU `accounting.manage`). Usine et roles en lecture seule sont
// rediriges vers le repertoire (lui-meme protege).
if (!canEditSupplier(canAny)) {
await navigateTo('/suppliers')
}
const supplierId = route.params.id as string
const { supplier, loading, error, load } = useSupplier(supplierId)
const referentials = useSupplierReferentials()
// ── Permissions / editabilite par zone (option 1 ERP-74) ────────────────────
const abilities = computed<SupplierEditAbilities>(() => ({
canManage: can('commercial.suppliers.manage'),
canAccountingView: can('commercial.suppliers.accounting.view'),
canAccountingManage: can('commercial.suppliers.accounting.manage'),
}))
const editability = computed(() => resolveTabEditability(abilities.value))
// Bloc principal + onglets Information / Contacts / Adresses.
const businessReadonly = computed(() => !editability.value.businessEditable)
const canAccountingView = computed(() => editability.value.accountingVisible)
const accountingReadonly = computed(() => !editability.value.accountingEditable)
const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.suppliers.edit.title'))
// ── Brouillons editables (pre-remplis depuis le detail) ─────────────────────
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail))
const contacts = ref<SupplierContactFormDraft[]>([])
const addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
const addressDegradedNotified = ref(false)
/** Recopie le detail charge dans les brouillons editables. */
function hydrate(detail: SupplierDetail): void {
Object.assign(main, mapMainDraft(detail))
Object.assign(information, mapInformationDraft(detail))
Object.assign(accounting, mapAccountingFormDraft(detail))
contacts.value = (detail.contacts ?? []).map(mapContactToDraft)
addresses.value = (detail.addresses ?? []).map(mapAddressToDraft)
ribs.value = (detail.ribs ?? []).map(mapRibToDraft)
// Chaque bloc reste visible meme vide : si une collection est vide, on amorce
// un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canAdd*).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
// RIB : amorce un bloc vide seulement si le type de reglement est une LCR
// (sinon la section reste masquee — RG-2.08).
if (isRibRequired.value && ribs.value.length === 0) ribs.value.push(emptyRib())
}
// ── Options de selects (referentiels UNION valeurs courantes de l'embed) ─────
// L'union garantit que les valeurs deja posees s'affichent meme quand le
// referentiel complet n'est pas chargeable (roles metier sans
// catalog.categories.view / sites.view → 403, cf. matrice § 2.7).
function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[] {
const seen = new Set(primary.map(o => o.value))
return [...primary, ...extra.filter(o => !seen.has(o.value))]
}
// Categories issues de l'embed (fournisseur + adresses), role-independantes.
const embedCategoryOptions = computed<CategoryOption[]>(() => {
const fromSupplier = categoryOptionsOf(supplier.value?.categories)
const fromAddresses = (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
return mergeOptions(fromSupplier, fromAddresses)
})
// Toutes les categories de type FOURNISSEUR sont autorisees, sur le bloc principal
// comme sur une adresse (pas de restriction Distributeur/Courtier comme au M1 — RG-2.10).
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
const embedSiteOptions = computed<RefOption[]>(() =>
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
)
const siteOptions = computed(() => mergeOptions(referentials.sites.value, embedSiteOptions.value))
// Contacts deja persistes (iri non null), rattachables a une adresse (M2M).
const contactOptions = computed<RefOption[]>(() =>
contacts.value
.filter(c => c.iri !== null)
.map(c => ({
value: c.iri as string,
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
})),
)
// Pays : referentiel `country` charge via l'API (ERP-116), aligne sur l'ecran
// client. On merge la valeur deja stockee sur chaque adresse (embed) — comme les
// autres selects de cet ecran — pour ne pas vider le select si `/countries`
// echoue (resilience ERP-102) ou si un pays historique n'est plus au referentiel.
const embedCountryOptions = computed<RefOption[]>(() =>
mergeOptions([], (supplier.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c)
.map(c => ({ value: c, label: c }))),
)
const countryOptions = computed<RefOption[]>(() =>
mergeOptions(referentials.countries.value, embedCountryOptions.value),
)
// Selects comptables : referentiel UNION valeur courante de l'embed (libelle).
const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(supplier.value?.tvaMode)))
const paymentDelayOptions = computed(() => mergeOptions(referentials.paymentDelays.value, referentialOptionOf(supplier.value?.paymentDelay)))
const paymentTypeOptions = computed(() => mergeOptions(
referentials.paymentTypes.value.map(p => ({ value: p.value, label: p.label })),
referentialOptionOf(supplier.value?.paymentType),
))
const bankOptions = computed(() => mergeOptions(referentials.banks.value, referentialOptionOf(supplier.value?.bank)))
// ── Onglets : navigation libre (3 actifs + Compta + 4 coquilles) ────────────
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
contacts: 'mdi:account-box-plus-outline',
addresses: 'mdi:map-marker-outline',
transport: 'mdi:truck-delivery-outline',
accounting: 'mdi:bank-circle-outline',
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Onglet initial : repris de la consultation (history.state), sinon Information.
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// ── Navigation ──────────────────────────────────────────────────────────────
/** Retour consultation en conservant l'onglet courant (via history.state). */
function goBack(): void {
router.push({ path: `/suppliers/${supplierId}`, state: { tab: activeTab.value } })
}
/**
* Message d'erreur a afficher : violation 422 / detail renvoye par le serveur,
* sinon un libelle generique. Le 409 d'unicite de nom (bloc principal) est
* traduit explicitement par l'appelant.
*/
function apiErrorMessage(e: unknown): string {
const data = (e as { data?: unknown })?.data
return extractApiErrorMessage(data) || t('commercial.suppliers.toast.error')
}
function showError(e: unknown): void {
toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) })
}
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
submitRows,
} = useSupplierFormErrors()
// ── Bloc principal ───────────────────────────────────────────────────────────
/** PATCH /suppliers/{id} — groupe supplier:write:main UNIQUEMENT (mode strict). */
async function submitMain(): Promise<void> {
if (businessReadonly.value || mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const updated = await api.patch<SupplierDetail>(`/suppliers/${supplierId}`, buildMainPayload(main, { forUpdate: true }), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
// Reaffiche les valeurs normalisees renvoyees par le serveur (UPPERCASE, RG-2.12).
Object.assign(main, mapMainDraft(updated))
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
// 409 = doublon nom de societe → erreur inline + toast ; 422 → mapping
// inline par champ ; autre → toast de fallback. Cf. ERP-101.
const status = (e as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('commercial.suppliers.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('commercial.suppliers.toast.error'), message })
}
else {
mainErrors.handleApiError(e, { fallbackMessage: t('commercial.suppliers.toast.error') })
}
}
finally {
mainSubmitting.value = false
}
}
// ── Onglet Information ───────────────────────────────────────────────────────
/** PATCH /suppliers/{id} — groupe supplier:write:information UNIQUEMENT. */
async function submitInformation(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
await api.patch(`/suppliers/${supplierId}`, buildInformationPayload(information), { toast: false })
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
informationErrors.handleApiError(e, { fallbackMessage: t('commercial.suppliers.toast.error') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Contacts ───────────────────────────────────────────────────────────
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last === undefined || isContactNamed(last)
})
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
function askRemoveContact(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
}
/**
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
* collection contacts (endpoints supplier_contact dedies).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = buildContactPayload(contact)
if (contact.id === null) {
const created = await api.post<{ '@id'?: string, id: number }>(
`/suppliers/${supplierId}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/supplier_contacts/${contact.id}`, body, { toast: false })
}
},
error => showError(error),
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
if (hasError) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
showError(e)
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Adresses ───────────────────────────────────────────────────────────
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void {
if (canAddAddress.value) addresses.value.push(emptyAddress())
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
}
function onAddressDegraded(): void {
if (addressDegradedNotified.value) return
addressDegradedNotified.value = true
toast.warning({
title: t('commercial.suppliers.toast.error'),
message: t('commercial.suppliers.form.address.degraded'),
})
}
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
// Edition d'une adresse existante : champ requis vide envoye en `''`
// (NotBlank 422) au lieu d'etre omis — sinon le PATCH garderait
// l'ancienne valeur (faux 200). Creation (id null) : omit classique.
const body = buildAddressPayload(address, { forUpdate: address.id !== null })
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/suppliers/${supplierId}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/supplier_addresses/${address.id}`, body, { toast: false })
}
},
error => showError(error),
)
if (hasError) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
showError(e)
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Comptabilite ──────────────────────────────────────────────────────
const selectedPaymentTypeCode = computed(() =>
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
)
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-2.08).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null
// ERP-121 : un RIB est une coordonnee bancaire du fournisseur, decouplee du mode
// de reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
// restent en base, simplement masques a l'ecran (visibleRibs = []), et
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
// bloc (askRemoveRib) retire reellement un RIB.
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
ribErrors.value = []
}
}
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib())
}
function askRemoveRib(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
/**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
*
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// ligne, tous les blocs tentes). Hors-LCR (ERP-121), les RIB sont des
// coordonnees dormantes : rien d'editable n'est affiche, on ne les re-soumet
// pas. On ne saute une amorce neuve vide QUE s'il reste un autre RIB
// soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un
// bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que
// de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat).
if (isRibRequired.value) {
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/suppliers/${supplierId}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
}
},
error => showError(error),
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/suppliers/${supplierId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
return
}
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
showError(e)
}
finally {
tabSubmitting.value = false
}
}
// ── Modal de confirmation generique ──────────────────────────────────────────
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
useHead({ title: headerTitle })
onMounted(async () => {
// Referentiels en best-effort (echec non bloquant : l'embed alimente les
// libelles des valeurs courantes).
referentials.loadCommon().catch(() => {})
await load()
if (supplier.value) hydrate(supplier.value)
})
</script>
@@ -0,0 +1,468 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du fournisseur + actions (Modifier / Archiver|Restaurer). -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.suppliers.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
<!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. -->
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canEdit"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('commercial.suppliers.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="secondary"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('commercial.suppliers.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('commercial.suppliers.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.suppliers.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.suppliers.consultation.notFound') }}</p>
<template v-else-if="supplier">
<!-- Formulaire principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="supplier.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
readonly
/>
<MalioSelectCheckbox
:model-value="categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
readonly
/>
</div>
<!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12). -->
<MalioInputTextArea
:model-value="information.description"
:label="t('commercial.suppliers.form.information.description')"
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
readonly
/>
<MalioInputText
:model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
readonly
/>
<MalioDate
:model-value="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
readonly
/>
<MalioInputText
:model-value="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
readonly
/>
<MalioInputAmount
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
readonly
/>
<MalioInputText
:model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
readonly
/>
<MalioInputAmount
:model-value="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
readonly
/>
<!-- Volume previsionnel : specifique fournisseur (entier). -->
<MalioInputText
:model-value="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
readonly
/>
</div>
</template>
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
readonly
/>
</div>
</template>
<!-- Onglet Adresses -->
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<SupplierAddressBlock
v-for="(view, index) in addressViews"
:key="view.draft.id ?? index"
:model-value="view.draft"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:category-options="view.categoryOptions"
:site-options="allSiteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
readonly
/>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
readonly
/>
<MalioInputText
:model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
readonly
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
empty-option-label=""
readonly
/>
<MalioInputText
:model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
readonly
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
empty-option-label=""
readonly
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')"
empty-option-label=""
readonly
/>
<MalioSelect
v-if="accounting.bankIri"
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')"
empty-option-label=""
readonly
/>
</div>
</div>
<!-- Blocs RIB (0..n), lecture seule. -->
<div
v-for="(rib, index) in ribs"
:key="rib.id ?? index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
readonly
/>
<MalioInputText
:model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
readonly
/>
<MalioInputText
:model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
readonly
/>
</div>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList>
</template>
<!-- Modal de confirmation Archiver / Restaurer. -->
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">
{{ isArchived ? t('commercial.suppliers.consultation.confirmRestore.title') : t('commercial.suppliers.consultation.confirmArchive.title') }}
</h2>
</template>
<p>{{ isArchived ? t('commercial.suppliers.consultation.confirmRestore.message') : t('commercial.suppliers.consultation.confirmArchive.message') }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.cancel')"
@click="confirmOpen = false"
/>
<MalioButton
:variant="isArchived ? 'primary' : 'danger'"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.confirm')"
:disabled="toggling"
@click="confirmToggleArchive"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditSupplier,
categoryOptionsOf,
contactOptionsOf,
emptyAddress,
mapAccountingDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
type SelectOption,
type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation'
import { emptyContact } from '~/modules/commercial/types/supplierForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est
// redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7.
if (!can('commercial.suppliers.view')) {
await navigateTo('/suppliers')
}
const supplierId = route.params.id as string
const { supplier, loading, error, load, archive, restore } = useSupplier(supplierId)
// ── Permissions / visibilite des actions ───────────────────────────────────
const canAccountingView = computed(() => can('commercial.suppliers.accounting.view'))
const canEdit = computed(() => canEditSupplier(canAny))
const isArchived = computed(() => supplier.value?.isArchived === true)
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.suppliers.consultation.title'))
// ── Donnees derivees du payload (lecture seule) ────────────────────────────
const categoryIris = computed(() => (supplier.value?.categories ?? []).map(c => c['@id']))
const information = computed(() => ({
description: supplier.value?.description ?? null,
competitors: supplier.value?.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime renvoye.
foundedAt: supplier.value?.foundedAt ? supplier.value.foundedAt.slice(0, 10) : null,
employeesCount: supplier.value?.employeesCount != null ? String(supplier.value.employeesCount) : null,
revenueAmount: supplier.value?.revenueAmount ?? null,
profitAmount: supplier.value?.profitAmount ?? null,
directorName: supplier.value?.directorName ?? null,
volumeForecast: supplier.value?.volumeForecast != null ? String(supplier.value.volumeForecast) : null,
}))
// Chaque bloc reste visible meme vide en consultation : si la collection est
// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun … »).
const contacts = computed(() => {
const list = (supplier.value?.contacts ?? []).map(mapContactToDraft)
return list.length ? list : [emptyContact()]
})
// Vue par adresse : brouillon + options (sites/categories) propres a l'adresse.
const addressViews = computed(() => {
const views = (supplier.value?.addresses ?? []).map(mapAddressView)
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
})
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
// fournisseur n'en a pas. ERP-121 : un fournisseur peut desormais conserver des RIB
// « dormants » apres etre repasse hors-LCR (on ne les supprime plus). En consultation,
// decision metier = on les masque TOTALEMENT : on n'affiche les RIB que si le type de
// reglement courant est LCR (le `code` est embarque sous supplier:read:accounting).
const ribs = computed(() =>
isRibRequiredForPaymentType(paymentTypeCodeOf(supplier.value?.paymentType))
? (supplier.value?.ribs ?? []).map(mapRibToDraft)
: [],
)
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail)))
// ── Options des selects (construites depuis l'EMBED, jamais via un GET de
// referentiel : /categories et /sites sont en 403 pour les roles metier
// non-admin, ce qui laisserait les libelles vides). ───────────────────────
const mainCategoryOptions = computed(() => categoryOptionsOf(supplier.value?.categories))
const contactOptions = computed(() => contactOptionsOf(supplier.value?.contacts))
// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read — donc
// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero
// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS
// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non
// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris).
const allSiteOptions = computed<SelectOption[]>(() =>
(authStore.user?.sites ?? []).map(s => ({
value: `/api/sites/${s.id}`,
label: (s.postalCode ?? '').slice(0, 2),
})),
)
// Pays (consultation, lecture seule) : derive des adresses du fournisseur, comme
// l'ecran client. Le referentiel `country` (ERP-116) n'est pas charge ici, l'ecran
// n'affiche que les valeurs deja stockees.
const countryOptions = computed<SelectOption[]>(() =>
[...new Set(
(supplier.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c),
)].map(c => ({ value: c, label: c })),
)
// Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(supplier.value?.tvaMode))
const paymentDelayOptions = computed(() => referentialOptionOf(supplier.value?.paymentDelay))
const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
contacts: 'mdi:account-box-plus-outline',
addresses: 'mdi:map-marker-outline',
transport: 'mdi:truck-delivery-outline',
accounting: 'mdi:bank-circle-outline',
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void {
router.push('/suppliers')
}
/** Bascule en edition en conservant l'onglet courant (via history.state). */
function goEdit(): void {
router.push({ path: `/suppliers/${supplierId}/edit`, state: { tab: activeTab.value } })
}
// ── Archivage / Restauration ────────────────────────────────────────────────
const confirmOpen = ref(false)
const toggling = ref(false)
function askToggleArchive(): void {
confirmOpen.value = true
}
/**
* Confirme l'archivage ou la restauration (PATCH isArchived seul). Gere le 409
* de conflit d'homonyme actif a la restauration avec un message dedie.
*/
async function confirmToggleArchive(): Promise<void> {
if (toggling.value) return
toggling.value = true
const restoring = isArchived.value
try {
if (restoring) {
await restore()
toast.success({ title: t('commercial.suppliers.toast.restoreSuccess') })
}
else {
await archive()
toast.success({ title: t('commercial.suppliers.toast.archiveSuccess') })
}
confirmOpen.value = false
}
catch (e) {
const status = (e as { response?: { status?: number } })?.response?.status
toast.error({
title: t('commercial.suppliers.toast.error'),
message: restoring && status === 409
? t('commercial.suppliers.toast.restoreConflict')
: t('commercial.suppliers.toast.error'),
})
}
finally {
toggling.value = false
}
}
useHead({ title: headerTitle })
onMounted(load)
</script>
@@ -0,0 +1,434 @@
<template>
<div>
<PageHeader>
{{ t('commercial.suppliers.title') }}
<template #actions>
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter. -->
<div class="flex items-center gap-8">
<!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('commercial.suppliers.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList via useSuppliersRepository :
pagination serveur, tri companyName ASC par defaut (cote back). -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
table-class="table-fixed suppliers-table"
:empty-message="t('commercial.suppliers.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<!-- Categories : libelles (name) separes par une virgule (spec M2). -->
<template #cell-categories="{ item }">
{{ formatCategories(item) }}
</template>
<!-- Sites : badges colores (name + color), agreges des adresses. -->
<template #cell-sites="{ item }">
<span class="flex flex-wrap gap-1">
<span
v-for="site in (item.sites as SupplierSite[])"
:key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
:style="{ backgroundColor: site.color }"
>
{{ site.name }}
</span>
</span>
</template>
<!-- Derniere activite : date de derniere modification (updatedAt). -->
<template #cell-lastActivity="{ item }">
{{ formatLastActivity(item) }}
</template>
</MalioDataTable>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('commercial.suppliers.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Appliquer ». Meme pattern que le repertoire clients. Etat 100 % local,
jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('commercial.suppliers.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche : nom societe + contact + email (param `search`, decision D1). -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Categories : cases a cocher (multi). Valeur = code stable. -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.categories')" value="categories">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in categoryOptions"
:id="`filter-category-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftCategoryCodes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.sites')" value="sites">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in siteOptions"
:id="`filter-site-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftSiteIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('commercial.suppliers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
/>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('commercial.suppliers.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Supplier, SupplierSite } from '~/modules/commercial/composables/useSuppliersRepository'
interface FilterOption {
value: string
label: string
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('commercial.suppliers.title') })
// Bouton « Ajouter » reserve a `manage` (POST /suppliers garde manage seul →
// Compta / Usine ne creent pas). « Exporter » et « Filtres » suivent `view`.
const canManage = computed(() => can('commercial.suppliers.manage'))
const canView = computed(() => can('commercial.suppliers.view'))
const {
items: suppliers,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadSuppliers,
goToPage,
setItemsPerPage,
setFilters,
} = useSuppliersRepository()
// Mappe les fournisseurs en objets « plats » pour MalioDataTable (items typees
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
// implicite, contrairement a l'interface Supplier. Meme pattern que clients.
const rows = computed(() => suppliers.value.map(supplier => ({
id: supplier.id,
companyName: supplier.companyName,
categories: supplier.categories,
sites: supplier.sites,
updatedAt: supplier.updatedAt,
})))
const columns = [
{ key: 'companyName', label: t('commercial.suppliers.column.companyName') },
{ key: 'categories', label: t('commercial.suppliers.column.categories') },
{ key: 'sites', label: t('commercial.suppliers.column.sites') },
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
]
/** Libelles des categories du fournisseur, separes par une virgule (spec M2 : name). */
function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Supplier['categories']) ?? []
return categories.map(c => c.name).join(', ')
}
/**
* Derniere activite : faute de suivi d'activite metier au M2, on affiche la
* date de derniere modification de la fiche (updatedAt, expose en liste via
* default:read). Format court francais jj/mm/aaaa.
*/
function formatLastActivity(item: Record<string, unknown>): string {
const value = item.updatedAt as string | null | undefined
if (!value) {
return ''
}
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toLocaleDateString('fr-FR')
}
/** Clic sur une ligne → ecran Consultation (route a plat /suppliers/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/suppliers/${item.id}`)
}
function goToCreate(): void {
router.push('/suppliers/new')
}
// ── Filtres (drawer) ────────────────────────────────────────────────────────
// Deux niveaux d'etat (pattern repertoire clients) :
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
// uniquement au clic « Appliquer » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
const siteOptions = ref<FilterOption[]>([])
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('commercial.suppliers.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
// reouverture reflete les filtres actifs.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
filterDrawerOpen.value = true
}
function toggleCategory(code: string, selected: boolean): void {
draftCategoryCodes.value = selected
? [...draftCategoryCodes.value, code]
: draftCategoryCodes.value.filter(c => c !== code)
}
function toggleSite(id: string, selected: boolean): void {
draftSiteIds.value = selected
? [...draftSiteIds.value, id]
: draftSiteIds.value.filter(s => s !== id)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
* Les filtres vides sont omis pour une query propre.
*/
function buildFilterPayload(): Record<string, string | string[] | boolean> {
const payload: Record<string, string | string[] | boolean> = {}
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
return payload
}
// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
// page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
}
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
// Le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
setFilters({}, { replace: true })
}
/** Charge les referentiels du drawer (categories FOURNISSEUR + sites) via ?pagination=false. */
async function loadFilterOptions(): Promise<void> {
const [cats, sites] = await Promise.all([
api.get<{ member?: Array<{ code: string, name: string }> }>(
'/categories',
// Taxonomie multi-types (ERP-84) : le filtre du repertoire fournisseurs
// ne propose que les categories de type FOURNISSEUR (pas les CLIENT).
{ pagination: 'false', typeCode: 'FOURNISSEUR' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
api.get<{ member?: Array<{ id: number, name: string }> }>(
'/sites',
{ pagination: 'false' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
])
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
}
// ── Export XLSX ─────────────────────────────────────────────────────────────
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
// l'utilisateur a accounting.view (gere cote back).
const exporting = ref(false)
async function exportXlsx(): Promise<void> {
if (exporting.value) {
return
}
exporting.value = true
try {
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
// contenu faute d'overload blob sur le client partage — a generaliser via
// un ticket dedie si d'autres exports binaires arrivent.
const blob = await api.get<Blob>('/suppliers/export.xlsx', buildFilterPayload(), {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'repertoire-fournisseurs.xlsx')
}
catch {
toast.error({
title: t('commercial.suppliers.toast.error'),
message: t('commercial.suppliers.toast.exportError'),
})
}
finally {
exporting.value = false
}
}
/** Declenche le telechargement d'un blob via un lien temporaire. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
onMounted(() => {
loadSuppliers()
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
// l'utilisateur perd juste les options de filtre.
loadFilterOptions().catch(() => {
categoryOptions.value = []
siteOptions.value = []
})
})
</script>
<style scoped>
/*
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
* couleurs/tailles (qui restent sur les defauts Malio).
*/
:deep(.suppliers-table tbody td:nth-child(3)) {
padding-top: 8px;
padding-bottom: 8px;
}
</style>
@@ -0,0 +1,876 @@
<template>
<div>
<!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('commercial.suppliers.form.title') }}</h1>
</div>
<!-- Formulaire principal (pre-onglets)
Sans validation de ce bloc, les onglets restent inaccessibles. Au
succes du POST, les champs passent en lecture seule et on bascule
automatiquement sur l'onglet Information. Pas de contact inline (ERP-106). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
</div>
<div v-if="!mainLocked" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
<!-- ── Onglets a validation incrementale ─────────────────────────────-->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<MalioInputTextArea
v-model="information.description"
:label="t('commercial.suppliers.form.information.description')"
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="isValidated('information')"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="isValidated('information')"
:error="informationErrors.errors.competitors"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="isValidated('information')"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/>
<!-- Volume previsionnel : specifique fournisseur. Champ texte
masque (chiffres uniquement) ; l'entier est resolu au PATCH. -->
<MalioInputText
v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK"
:readonly="isValidated('information')"
:error="informationErrors.errors.volumeForecast"
/>
</div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting || supplierId === null"
@click="submitInformation"
/>
</div>
</template>
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="index > 0"
:readonly="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!isValidated('contacts')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting"
@click="submitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresses -->
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<SupplierAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:readonly="isValidated('addresses')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!isValidated('addresses')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('commercial.suppliers.form.accounting.paymentType')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
v-if="isBankRequired"
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('commercial.suppliers.form.accounting.bank')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
v-if="isRibRequired"
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
</div>
</template>
<!-- Onglet placeholder : frame vide, passage automatique. -->
<template #transport><ComingSoonPlaceholder /></template>
</MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.suppliers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useSupplierReferentials, type RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors'
import {
buildSupplierFormTabKeys,
SUPPLIER_FORM_PLACEHOLDER_TABS,
isAddressValid,
isBankRequiredForPaymentType,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
} from '~/modules/commercial/utils/supplierFormRules'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/supplierEdit'
import {
emptyAddress,
emptyContact,
emptyRib,
type SupplierAddressFormDraft,
type SupplierContactFormDraft,
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
const EMPLOYEES_MASK = '#######'
// Volume previsionnel : champ texte borne aux chiffres (entier >= 0 cote back).
const VOLUME_FORECAST_MASK = '##########'
const { t } = useI18n()
const api = useApi()
const toast = useToast()
const router = useRouter()
const { can } = usePermissions()
/** Retour vers le repertoire fournisseurs (fleche d'en-tete). */
function goBack(): void {
router.push('/suppliers')
}
/**
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API. Retourne
* TOUJOURS une chaine (le composant de toast plante sur `undefined`).
*/
function apiErrorMessage(error: unknown): string {
const data = (error as { data?: unknown })?.data
return extractApiErrorMessage(data) || t('commercial.suppliers.toast.error')
}
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
submitRows,
} = useSupplierFormErrors()
useHead({ title: t('commercial.suppliers.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
// seul) et Usine sont rediriges vers le repertoire.
if (!can('commercial.suppliers.manage')) {
await navigateTo('/suppliers')
}
const canAccountingView = computed(() => can('commercial.suppliers.accounting.view'))
const canAccountingManage = computed(() => can('commercial.suppliers.accounting.manage'))
const referentials = useSupplierReferentials()
// ── Etat du fournisseur cree ────────────────────────────────────────────────
const supplierId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ────────────────────────────────────────────────────
const main = reactive({
companyName: null as string | null,
categoryIris: [] as string[],
})
/** POST /suppliers (groupe supplier:write:main). Au succes : verrouille + bascule Information. */
async function submitMain(): Promise<void> {
if (mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const created = await api.post<SupplierResponse>('/suppliers', buildMainPayload(main), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
supplierId.value = created.id
// Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-2.12).
main.companyName = created.companyName ?? main.companyName
mainLocked.value = true
// Information est facultatif : on deverrouille jusqu'a Contacts (index 1).
unlockedIndex.value = tabIndex('contacts')
activeTab.value = 'information'
toast.success({ title: t('commercial.suppliers.toast.createSuccess') })
}
catch (error) {
// 409 = doublon nom de societe (RG d'unicite) → erreur inline + toast ;
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('commercial.suppliers.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('commercial.suppliers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
}
}
finally {
mainSubmitting.value = false
}
}
// ── Onglets : ordre + gating progressif ─────────────────────────────────────
const activeTab = ref('information')
// Index du dernier onglet deverrouille (-1 tant que le fournisseur n'est pas cree).
const unlockedIndex = ref(-1)
// Onglets valides (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value))
// Dernier onglet REMPLISSABLE par le role : sa validation cloture l'ajout.
const lastFillableTab = computed(() => lastFillableTabKey(tabKeys.value))
// Icone (Iconify) affichee dans l'onglet, par cle.
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
contacts: 'mdi:account-box-plus-outline',
addresses: 'mdi:map-marker-outline',
transport: 'mdi:truck-delivery-outline',
accounting: 'mdi:bank-circle-outline',
}
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
function isValidated(key: string): boolean {
return validated[key] === true
}
function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/**
* Marque l'onglet valide. Si c'est le dernier onglet remplissable, l'ajout est
* termine : toast final + redirection vers la liste, et on retourne true. Sinon,
* deverrouille et avance a l'onglet suivant, et retourne false.
*/
function completeTab(key: string): boolean {
validated[key] = true
if (key === lastFillableTab.value) {
toast.success({ title: t('commercial.suppliers.toast.addComplete') })
router.push('/suppliers')
return true
}
const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next
return false
}
// Passage automatique sur les onglets coquille (Transport).
watch(activeTab, (key) => {
if ((SUPPLIER_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key)) {
const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next
}
})
// ── Onglet Information ──────────────────────────────────────────────────────
const information = reactive({
description: null as string | null,
competitors: null as string | null,
foundedAt: null as string | null,
employeesCount: null as string | null,
revenueAmount: null as string | null,
profitAmount: null as string | null,
directorName: null as string | null,
volumeForecast: null as string | null,
})
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
await api.patch(`/suppliers/${supplierId.value}`, buildInformationPayload(information), { toast: false })
if (completeTab('information')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (error) {
informationErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Contacts ─────────────────────────────────────────────────────────
const contacts = ref<SupplierContactFormDraft[]>([emptyContact()])
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && isContactNamed(last)
})
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
function askRemoveContact(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
})
}
/** POST/PATCH des contacts sur la sous-ressource /suppliers/{id}/contacts. */
async function submitContacts(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces vides, on les soumet pour declencher la 422 RG-2.04 inline.
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = buildContactPayload(contact)
if (contact.id === null) {
const created = await api.post<ContactResponse>(
`/suppliers/${supplierId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/supplier_contacts/${contact.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
if (hasError) return
if (completeTab('contacts')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Adresses ─────────────────────────────────────────────────────────
const addresses = ref<SupplierAddressFormDraft[]>([emptyAddress()])
const addressDegradedNotified = ref(false)
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
const contactOptions = computed<RefOption[]>(() =>
contacts.value
.filter(c => c.iri !== null)
.map(c => ({
value: c.iri as string,
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
})),
)
// Pays : referentiel `country` charge via l'API (ERP-116), aligne sur l'ecran
// client. France garantie en tete pour rester preselectionnable par defaut sur
// chaque adresse meme si `/countries` echoue (resilience ERP-102).
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void {
if (canAddAddress.value) addresses.value.push(emptyAddress())
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
})
}
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade. */
function onAddressDegraded(): void {
if (addressDegradedNotified.value) return
addressDegradedNotified.value = true
toast.warning({
title: t('commercial.suppliers.toast.error'),
message: t('commercial.suppliers.form.address.degraded'),
})
}
/** POST/PATCH des adresses sur la sous-ressource /suppliers/{id}/addresses. */
async function submitAddresses(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
const body = buildAddressPayload(address)
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/suppliers/${supplierId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/supplier_addresses/${address.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
)
if (hasError) return
if (completeTab('addresses')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Comptabilite ─────────────────────────────────────────────────────
const accounting = reactive({
siren: null as string | null,
accountNumber: null as string | null,
tvaModeIri: null as string | null,
nTva: null as string | null,
paymentDelayIri: null as string | null,
paymentTypeIri: null as string | null,
bankIri: null as string | null,
})
const ribs = ref<SupplierRibFormDraft[]>([])
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
// Code du type de reglement selectionne (pour RG-2.07 / RG-2.08).
const selectedPaymentTypeCode = computed(() =>
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
)
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-2.08).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
if (!isBankRequired.value) accounting.bankIri = null
// ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
// masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
ribErrors.value = []
}
}
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib())
}
function askRemoveRib(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible.
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
/**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur /suppliers/{id}/ribs PUIS
* PATCH des scalaires (groupe supplier:write:accounting). Les RIB d'abord : le back
* valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires, les RIB
* doivent donc exister en base AVANT. Deux appels distincts (mode strict).
*/
async function submitAccounting(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// ligne). Hors-LCR (ERP-121), une saisie RIB eventuellement restee dans le
// brouillon est masquee et n'est PAS persistee (pas de RIB orphelin sur un
// fournisseur en virement). On ne saute une amorce neuve vide QUE s'il reste
// un autre RIB soumettable : sinon (LCR sans aucun RIB rempli) on la soumet
// pour declencher la 422 NotBlank inline.
if (isRibRequired.value) {
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
const body = buildRibPayload(rib)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/suppliers/${supplierId.value}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(
`/suppliers/${supplierId.value}`,
buildAccountingPayload(accounting, isBankRequired.value),
{ toast: false },
)
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
return
}
if (completeTab('accounting')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
// ── Modal de confirmation generique ─────────────────────────────────────────
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
// ── Types de reponse API ────────────────────────────────────────────────────
interface SupplierResponse {
id: number
companyName: string | null
}
interface ContactResponse {
'@id'?: string
id: number
}
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadCommon().catch(() => {})
})
</script>
@@ -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,
}
}
@@ -0,0 +1,118 @@
/**
* Types « brouillon » de l'ecran « Ajouter un fournisseur » (M2 Commercial).
*
* Miroir de `types/clientForm.ts` (M1). Ces interfaces decrivent l'etat LOCAL du
* formulaire (refs Vue), distinct des DTO de l'API : elles portent en plus des
* champs purement UI (`hasSecondaryPhone`) et l'`iri` Hydra des entites creees
* (necessaire pour rattacher une adresse a des contacts deja persistes, M2M).
* Partage par la page de creation et les blocs `SupplierContactBlock` /
* `SupplierAddressBlock` (reutilises par la consultation/modification 95/96).
*
* Differences M2 vs M1 (cf. spec-front § « Differences notables ») :
* - Adresse : type via enum exclusif `addressType` (PROSPECT/DEPART/RENDU,
* RG-2.09) pas de drapeaux isProspect/isDelivery/isBilling.
* - Adresse : champs specifiques fournisseur `bennes` (nombre) et
* `triageProvider` (prestation de triage). Pas d'email de facturation.
* - Pas de relation Distributeur/Courtier ni de triage sur le bloc principal.
*/
/** Type d'adresse fournisseur (enum exclusif RG-2.09). */
export type SupplierAddressType = 'PROSPECT' | 'DEPART' | 'RENDU'
/** Un contact du fournisseur (onglet Contacts). */
export interface SupplierContactFormDraft {
/** Id serveur une fois le contact cree (null tant que non persiste). */
id: number | null
/** IRI Hydra du contact cree — utilise pour le rattachement M2M cote adresse. */
iri: string | null
firstName: string | null
lastName: string | null
jobTitle: string | null
phonePrimary: string | null
phoneSecondary: string | null
email: string | null
/** UI : le 2e numero a ete revele via le bouton « + ». */
hasSecondaryPhone: boolean
}
/** Une adresse du fournisseur (onglet Adresses). */
export interface SupplierAddressFormDraft {
id: number | null
/** Type exclusif Prospect / Depart / Rendu (RG-2.09). null tant que non choisi. */
addressType: SupplierAddressType | null
country: string
postalCode: string | null
city: string | null
street: string | null
streetComplement: string | null
/** IRI des categories rattachees (type FOURNISSEUR, RG-2.10). */
categoryIris: string[]
/** IRI des sites Starseed rattaches (>= 1 obligatoire — RG-2.06). */
siteIris: string[]
/** IRI des contacts rattaches (= blocs Contact deja crees). */
contactIris: string[]
/** Nombre de bennes (stepper, defaut 0). Chaine pour MalioInputNumber, convertie au payload. */
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). */
export interface SupplierRibFormDraft {
id: number | null
label: string | null
bic: string | null
iban: string | null
}
/** Fabrique un contact vierge. */
export function emptyContact(): SupplierContactFormDraft {
return {
id: null,
iri: null,
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
hasSecondaryPhone: false,
}
}
/** Fabrique une adresse vierge (pays prerempli « France », 0 benne). */
export function emptyAddress(): SupplierAddressFormDraft {
return {
id: null,
addressType: null,
country: 'France',
postalCode: null,
city: null,
street: null,
streetComplement: null,
categoryIris: [],
siteIris: [],
contactIris: [],
bennes: '0',
triageProvider: false,
latitude: null,
longitude: null,
geoManual: false,
}
}
/** Fabrique un RIB vierge. */
export function emptyRib(): SupplierRibFormDraft {
return {
id: null,
label: null,
bic: null,
iban: null,
}
}
@@ -9,6 +9,7 @@ import {
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
relationOf,
showArchiveAction,
@@ -233,3 +234,17 @@ describe('showArchiveAction / showRestoreAction', () => {
expect(showRestoreAction(can([]), true)).toBe(false)
})
})
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -211,6 +211,38 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
})
})
// Bug edition : en PATCH (merge), une cle de champ requis OMISE laisse la valeur
// serveur inchangee -> faux 200 quand l'utilisateur vide le champ. En `forUpdate`,
// on envoie `''` (chaine valide, pas de 400 de type) -> NotBlank 422 inline.
describe('forUpdate (EDITION/PATCH) : champ requis vide -> `\'\'` au lieu d\'etre omis', () => {
it('buildMainPayload : companyName vide envoye en `\'\'`', () => {
const payload = buildMainPayload(mainDraft({ companyName: '' }), { forUpdate: true })
expect('companyName' in payload).toBe(true)
expect(payload.companyName).toBe('')
})
it('buildAddressPayload : postalCode / city / street vides envoyes en `\'\'`', () => {
const address: AddressFormDraft = {
id: 7, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France',
postalCode: '', city: null, street: '1 rue X', streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false,
}
const payload = buildAddressPayload(address, false, { forUpdate: true })
expect(payload.postalCode).toBe('')
expect(payload.city).toBe('')
// Un champ requis renseigne reste tel quel.
expect(payload.street).toBe('1 rue X')
})
it('buildRibPayload : label / bic vides envoyes en `\'\'`, iban conserve', () => {
const payload = buildRibPayload({ id: 4, label: '', bic: null, iban: 'FR7612345' }, { forUpdate: true })
expect(payload.label).toBe('')
expect(payload.bic).toBe('')
expect(payload.iban).toBe('FR7612345')
})
})
describe('mapMainDraft — pre-remplissage bloc principal', () => {
it('resout la relation et extrait les IRI (sans contact inline)', () => {
const client = {
@@ -0,0 +1,239 @@
import { describe, expect, it } from 'vitest'
import {
canEditSupplier,
categoryOptionsOf,
contactOptionsOf,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
type SupplierDetail,
} from '../supplierConsultation'
describe('iriOf', () => {
it('retourne l\'@id d\'une relation embarquee (objet)', () => {
expect(iriOf({ '@id': '/api/payment_types/14', code: 'LCR' })).toBe('/api/payment_types/14')
})
it('retourne la chaine telle quelle si la relation est deja un IRI', () => {
expect(iriOf('/api/banks/3')).toBe('/api/banks/3')
})
it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => {
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
})
describe('mapContactToDraft', () => {
it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => {
const draft = mapContactToDraft({
'@id': '/api/supplier_contacts/39',
id: 39,
firstName: 'Marie',
lastName: 'Martin',
jobTitle: 'Responsable achats',
phonePrimary: '0612345678',
email: 'marie.martin@seed.test',
})
expect(draft.id).toBe(39)
expect(draft.iri).toBe('/api/supplier_contacts/39')
expect(draft.phonePrimary).toBe('06 12 34 56 78')
expect(draft.hasSecondaryPhone).toBe(false)
})
it('revele le 2e telephone quand phoneSecondary est present', () => {
const draft = mapContactToDraft({
'@id': '/api/supplier_contacts/40',
id: 40,
phonePrimary: '0600000000',
phoneSecondary: '0611111111',
})
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.phoneSecondary).toBe('06 11 11 11 11')
})
})
describe('mapAddressToDraft', () => {
it('mappe l\'enum addressType, les champs fournisseur et extrait les iris', () => {
const draft = mapAddressToDraft({
'@id': '/api/supplier_addresses/33',
id: 33,
addressType: 'DEPART',
country: 'France',
postalCode: '86000',
city: 'Poitiers',
street: '12 rue des Acacias',
bennes: 3,
triageProvider: true,
sites: [{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#056CF2' }],
categories: [{ '@id': '/api/categories/2279', code: 'NEGOCIANT' }],
contacts: [{ '@id': '/api/supplier_contacts/39' }, '/api/supplier_contacts/41'],
})
expect(draft.addressType).toBe('DEPART')
expect(draft.siteIris).toEqual(['/api/sites/87'])
expect(draft.categoryIris).toEqual(['/api/categories/2279'])
expect(draft.contactIris).toEqual(['/api/supplier_contacts/39', '/api/supplier_contacts/41'])
// bennes (entier) → chaine pour MalioInputNumber.
expect(draft.bennes).toBe('3')
expect(draft.triageProvider).toBe(true)
expect(draft.city).toBe('Poitiers')
expect(draft.country).toBe('France')
})
it('tolere les champs absents (defauts : France, bennes « 0 », triage faux, type null)', () => {
const draft = mapAddressToDraft({ '@id': '/api/supplier_addresses/9', id: 9 })
expect(draft.addressType).toBeNull()
expect(draft.siteIris).toEqual([])
expect(draft.categoryIris).toEqual([])
expect(draft.contactIris).toEqual([])
expect(draft.country).toBe('France')
expect(draft.bennes).toBe('0')
expect(draft.triageProvider).toBe(false)
})
})
describe('mapRibToDraft', () => {
it('mappe label / bic / iban et l\'id serveur', () => {
const draft = mapRibToDraft({ '@id': '/api/supplier_ribs/27', id: 27, label: 'Compte principal', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
expect(draft).toEqual({ id: 27, label: 'Compte principal', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
})
})
describe('mapAccountingDraft', () => {
it('mappe les scalaires et resout les iris des referentiels embarques', () => {
const acc = mapAccountingDraft({
'@id': '/api/suppliers/85',
id: 85,
siren: '123456789',
accountNumber: 'F0001',
nTva: 'FR00123456789',
tvaMode: { '@id': '/api/tva_modes/30' },
paymentDelay: { '@id': '/api/payment_delays/11' },
paymentType: { '@id': '/api/payment_types/14', code: 'LCR' },
bank: { '@id': '/api/banks/3' },
} as SupplierDetail)
expect(acc).toEqual({
siren: '123456789',
accountNumber: 'F0001',
nTva: 'FR00123456789',
tvaModeIri: '/api/tva_modes/30',
paymentDelayIri: '/api/payment_delays/11',
paymentTypeIri: '/api/payment_types/14',
bankIri: '/api/banks/3',
})
})
it('renvoie des null quand les champs comptables sont absents (gating par omission, sans accounting.view)', () => {
const acc = mapAccountingDraft({} as SupplierDetail)
expect(acc).toEqual({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
bankIri: null,
})
})
})
describe('options construites depuis l\'embed (role-independantes)', () => {
it('categoryOptionsOf expose value=IRI, label=nom, code', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/2279', name: 'Negociant', code: 'NEGOCIANT' }])).toEqual([
{ value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' },
])
})
it('siteOptionsOf expose value=IRI, label=nom', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/87', label: 'Chatellerault' },
])
})
it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => {
expect(contactOptionsOf([
{ '@id': '/api/supplier_contacts/1', id: 1, firstName: 'Marie', lastName: 'Martin' },
{ '@id': '/api/supplier_contacts/2', id: 2, email: 'a@b.fr' },
])).toEqual([
{ value: '/api/supplier_contacts/1', label: 'Marie Martin' },
{ value: '/api/supplier_contacts/2', label: 'a@b.fr' },
])
})
it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => {
expect(referentialOptionOf({ '@id': '/api/payment_types/14', label: 'LCR' })).toEqual([
{ value: '/api/payment_types/14', label: 'LCR' },
])
expect(referentialOptionOf('/api/banks/3')).toEqual([])
expect(referentialOptionOf(null)).toEqual([])
})
it('mapAddressView assemble brouillon + options propres a l\'adresse', () => {
const view = mapAddressView({
'@id': '/api/supplier_addresses/33',
id: 33,
addressType: 'RENDU',
city: 'Poitiers',
sites: [{ '@id': '/api/sites/87', name: 'Chatellerault' }],
categories: [{ '@id': '/api/categories/2279', name: 'Negociant', code: 'NEGOCIANT' }],
})
expect(view.draft.id).toBe(33)
expect(view.draft.addressType).toBe('RENDU')
expect(view.siteOptions).toEqual([{ value: '/api/sites/87', label: 'Chatellerault' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' }])
})
})
describe('canEditSupplier', () => {
const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
it('visible pour manage', () => {
expect(canEditSupplier(can(['commercial.suppliers.manage']))).toBe(true)
})
it('visible pour accounting.manage (role Compta)', () => {
expect(canEditSupplier(can(['commercial.suppliers.accounting.manage']))).toBe(true)
})
it('masque sans aucune des deux permissions (role Usine)', () => {
expect(canEditSupplier(can(['commercial.suppliers.view']))).toBe(false)
})
})
describe('showArchiveAction / showRestoreAction', () => {
const can = (granted: string[]) => (code: string) => granted.includes(code)
it('Archiver : visible avec la permission archive ET fournisseur non archive', () => {
expect(showArchiveAction(can(['commercial.suppliers.archive']), false)).toBe(true)
expect(showArchiveAction(can(['commercial.suppliers.archive']), true)).toBe(false)
expect(showArchiveAction(can([]), false)).toBe(false)
})
it('Restaurer : visible avec la permission archive ET fournisseur archive', () => {
expect(showRestoreAction(can(['commercial.suppliers.archive']), true)).toBe(true)
expect(showRestoreAction(can(['commercial.suppliers.archive']), false)).toBe(false)
expect(showRestoreAction(can([]), true)).toBe(false)
})
})
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
mapAccountingFormDraft,
mapInformationDraft,
mapMainDraft,
resolveTabEditability,
} from '../supplierEdit'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
describe('buildMainPayload (groupe supplier:write:main)', () => {
it('envoie companyName + categories quand renseignes', () => {
expect(buildMainPayload({ companyName: 'ACME', categoryIris: ['/api/categories/1'] })).toEqual({
companyName: 'ACME',
categories: ['/api/categories/1'],
})
})
it('CREATION : omet companyName vide (-> 422 NotBlank, ERP-119)', () => {
const payload = buildMainPayload({ companyName: null, categoryIris: [] })
expect('companyName' in payload).toBe(false)
expect(payload.categories).toEqual([])
})
it('EDITION (forUpdate) : companyName vide envoye en `\'\'` (PATCH -> 422 NotBlank, pas un faux 200)', () => {
const payload = buildMainPayload({ companyName: '', categoryIris: [] }, { forUpdate: true })
expect('companyName' in payload).toBe(true)
expect(payload.companyName).toBe('')
})
})
describe('buildInformationPayload (groupe supplier:write:information)', () => {
const base = {
description: null, competitors: null, foundedAt: null, employeesCount: null,
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
}
it('convertit employeesCount et volumeForecast en nombre, null si vide', () => {
expect(buildInformationPayload({ ...base, employeesCount: '42', volumeForecast: '1000' })).toMatchObject({
employeesCount: 42,
volumeForecast: 1000,
})
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
})
})
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
it('n\'envoie le 2e telephone que si revele (hasSecondaryPhone)', () => {
const contact = { ...emptyContact(), phonePrimary: '0102030405', phoneSecondary: '0607080910' }
expect(buildContactPayload({ ...contact, hasSecondaryPhone: false }).phoneSecondary).toBeNull()
expect(buildContactPayload({ ...contact, hasSecondaryPhone: true }).phoneSecondary).toBe('0607080910')
})
})
describe('buildAddressPayload (sous-ressource supplier_address — specificites M2)', () => {
it('envoie addressType (enum), bennes (nombre) et triageProvider', () => {
const address = {
...emptyAddress(),
addressType: 'RENDU' as const,
postalCode: '86100', city: 'Châtellerault', street: '1 rue de la Paix',
siteIris: ['/api/sites/1'], categoryIris: ['/api/categories/2'],
bennes: '3', triageProvider: true,
}
expect(buildAddressPayload(address)).toMatchObject({
addressType: 'RENDU',
bennes: 3,
triageProvider: true,
sites: ['/api/sites/1'],
categories: ['/api/categories/2'],
})
})
it('bennes null quand le champ est vide', () => {
expect(buildAddressPayload({ ...emptyAddress(), bennes: '' }).bennes).toBeNull()
})
it('omet postalCode / city / street vides (-> 422 NotBlank, ERP-119)', () => {
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'PROSPECT' })
expect('postalCode' in payload).toBe(false)
expect('city' in payload).toBe(false)
expect('street' in payload).toBe(false)
// Les champs non requis restent presents.
expect('streetComplement' in payload).toBe(true)
expect(payload.addressType).toBe('PROSPECT')
})
it('omet addressType quand aucun radio n\'est choisi (-> 422 NotBlank au lieu d\'un 400 de type)', () => {
// emptyAddress() laisse addressType a null : la cle doit etre absente du
// payload pour que le back renvoie une 422 propertyPath addressType.
const payload = buildAddressPayload(emptyAddress())
expect('addressType' in payload).toBe(false)
})
it('EDITION (forUpdate) : un champ requis vide est envoye en `\'\'` (et NON omis) pour declencher la 422 NotBlank au PATCH', () => {
// Bug edition : omettre la cle d'un champ requis vide laisse le PATCH garder
// l'ancienne valeur (faux 200). En `forUpdate`, on envoie `''` -> NotBlank 422.
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART', postalCode: '' }, { forUpdate: true })
expect('postalCode' in payload).toBe(true)
expect(payload.postalCode).toBe('')
// Un champ requis renseigne reste tel quel.
expect(payload.addressType).toBe('DEPART')
})
it('n\'expose jamais d\'email de facturation (difference M1)', () => {
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART' })
expect('billingEmail' in payload).toBe(false)
})
})
describe('buildAccountingPayload (groupe supplier:write:accounting)', () => {
const base = {
siren: '123456789', accountNumber: '00012345678', nTva: 'FR123',
tvaModeIri: '/api/tva_modes/1', paymentDelayIri: '/api/payment_delays/1',
paymentTypeIri: '/api/payment_types/1', bankIri: '/api/banks/1',
}
it('envoie la banque seulement si requise (VIREMENT, RG-2.07)', () => {
expect(buildAccountingPayload(base, true).bank).toBe('/api/banks/1')
expect(buildAccountingPayload(base, false).bank).toBeNull()
})
})
describe('buildRibPayload (sous-ressource supplier_rib)', () => {
it('omet les champs requis vides (-> 422 NotBlank, ERP-119)', () => {
const payload = buildRibPayload({ ...emptyRib(), iban: 'FR1420041010050500013M02606' })
expect('label' in payload).toBe(false)
expect('bic' in payload).toBe(false)
expect(payload.iban).toBe('FR1420041010050500013M02606')
})
})
describe('mapMainDraft — pre-remplissage bloc principal (companyName + categories, pas de relation M2)', () => {
it('extrait companyName et les IRI de categories', () => {
const draft = mapMainDraft({
'@id': '/api/suppliers/85', id: 85,
companyName: 'DOD862875',
categories: [{ '@id': '/api/categories/2279', code: 'NEGOCIANT' }],
} as SupplierDetail)
expect(draft.companyName).toBe('DOD862875')
expect(draft.categoryIris).toEqual(['/api/categories/2279'])
})
it('gere les cles omises (skip_null_values) sans planter', () => {
const draft = mapMainDraft({ '@id': '/api/suppliers/2', id: 2 } as SupplierDetail)
expect(draft.companyName).toBeNull()
expect(draft.categoryIris).toEqual([])
})
})
describe('mapInformationDraft — pre-remplissage onglet Information (+ volumeForecast M2)', () => {
it('tronque foundedAt, stringifie employeesCount et volumeForecast', () => {
const draft = mapInformationDraft({
'@id': '/api/suppliers/85', id: 85,
foundedAt: '2008-04-01T00:00:00+02:00', employeesCount: 42, volumeForecast: 8000,
} as SupplierDetail)
expect(draft.foundedAt).toBe('2008-04-01')
expect(draft.employeesCount).toBe('42')
expect(draft.volumeForecast).toBe('8000')
})
it('cles omises -> null (volumeForecast inclus)', () => {
const draft = mapInformationDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail)
expect(draft.foundedAt).toBeNull()
expect(draft.employeesCount).toBeNull()
expect(draft.volumeForecast).toBeNull()
expect(draft.description).toBeNull()
})
})
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
it('extrait les scalaires et les IRI des referentiels embarques', () => {
const draft = mapAccountingFormDraft({
'@id': '/api/suppliers/85', id: 85,
siren: '123456789', accountNumber: 'F0001', nTva: 'FR00123456789',
tvaMode: { '@id': '/api/tva_modes/30', label: 'France (ventes)' },
paymentType: '/api/payment_types/14',
} as SupplierDetail)
expect(draft.siren).toBe('123456789')
expect(draft.tvaModeIri).toBe('/api/tva_modes/30')
expect(draft.paymentTypeIri).toBe('/api/payment_types/14')
expect(draft.bankIri).toBeNull()
})
it('cles comptables absentes (gating par omission) -> scalaires/IRI null', () => {
const draft = mapAccountingFormDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail)
expect(draft.siren).toBeNull()
expect(draft.tvaModeIri).toBeNull()
expect(draft.bankIri).toBeNull()
})
})
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
it('Admin : tout editable', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
})
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
})
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
})
it('Sans permission d\'edition : rien d\'editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
})
})
@@ -0,0 +1,190 @@
import { describe, it, expect } from 'vitest'
import {
buildSupplierFormTabKeys,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBlankRow,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
omitEmptyRequired,
type AddressValidityDraft,
type ContactDraft,
type ContactFillableDraft,
} from '../supplierFormRules'
/** Bloc contact totalement vide (amorce par defaut). */
function blankContact(): ContactFillableDraft {
return {
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
}
}
describe('buildSupplierFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => {
it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
expect(buildSupplierFormTabKeys(true)).toContain('accounting')
})
it('exclut l onglet accounting sinon (Bureau / Commerciale)', () => {
expect(buildSupplierFormTabKeys(false)).not.toContain('accounting')
})
it('a la creation, ordre = information / contacts / addresses / transport (+ accounting si vu)', () => {
expect(buildSupplierFormTabKeys(true)).toEqual(['information', 'contacts', 'addresses', 'transport', 'accounting'])
expect(buildSupplierFormTabKeys(false)).toEqual(['information', 'contacts', 'addresses', 'transport'])
})
it('a la creation, exclut Statistiques / Rapports / Echanges', () => {
const keys = buildSupplierFormTabKeys(true)
expect(keys).not.toContain('statistics')
expect(keys).not.toContain('reports')
expect(keys).not.toContain('exchanges')
})
it('en modification (includeEditOnlyTabs), ajoute les onglets edit-only en fin', () => {
expect(buildSupplierFormTabKeys(true, { includeEditOnlyTabs: true })).toEqual([
'information', 'contacts', 'addresses', 'transport', 'accounting', 'statistics', 'reports', 'exchanges',
])
})
})
describe('lastFillableTabKey (redirection fin d\'ajout, role-aware)', () => {
it('addresses pour un role sans Comptabilite (Bureau / Commerciale)', () => {
expect(lastFillableTabKey(buildSupplierFormTabKeys(false))).toBe('addresses')
})
it('accounting pour un role avec accounting.view (Admin)', () => {
expect(lastFillableTabKey(buildSupplierFormTabKeys(true))).toBe('accounting')
})
it('ignore les onglets placeholder (Transport en dernier ne compte pas)', () => {
expect(lastFillableTabKey(['information', 'contacts', 'addresses', 'transport'])).toBe('addresses')
})
it('undefined si aucun onglet remplissable (que des placeholders)', () => {
expect(lastFillableTabKey(['transport', 'statistics'])).toBeUndefined()
})
})
describe('isContactNamed (RG-2.04)', () => {
it('vrai si le prenom ou le nom est renseigne', () => {
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
expect(isContactNamed({ firstName: null, lastName: 'Martin' })).toBe(true)
})
it('faux si les deux sont vides ou espaces uniquement', () => {
expect(isContactNamed({ firstName: null, lastName: null })).toBe(false)
expect(isContactNamed({ firstName: ' ', lastName: '' })).toBe(false)
})
})
describe('hasAtLeastOneValidContact (RG-2.13)', () => {
it('faux sur une liste vide ou sans contact nomme', () => {
expect(hasAtLeastOneValidContact([])).toBe(false)
const contacts: ContactDraft[] = [{ firstName: null, lastName: null }, { firstName: '', lastName: ' ' }]
expect(hasAtLeastOneValidContact(contacts)).toBe(false)
})
it('vrai des qu un contact a un nom ou un prenom', () => {
expect(hasAtLeastOneValidContact([{ firstName: null, lastName: null }, { firstName: 'Bob', lastName: null }])).toBe(true)
})
})
describe('isBlankRow / isContactBlank / isRibBlank (blocs vides vs partiels)', () => {
it('isBlankRow vrai si toutes les valeurs sont vides', () => {
expect(isBlankRow([null, undefined, '', ' '])).toBe(true)
expect(isBlankRow([null, 'x', ''])).toBe(false)
})
it('isContactBlank faux si un email seul est saisi (bloc a soumettre -> 422 RG-2.04 inline)', () => {
expect(isContactBlank(blankContact())).toBe(true)
expect(isContactBlank({ ...blankContact(), email: 'jean@acme.fr' })).toBe(false)
})
it('isRibBlank faux si un IBAN seul est saisi (bloc a soumettre -> 422 NotBlank inline)', () => {
expect(isRibBlank({ label: null, bic: null, iban: null })).toBe(true)
expect(isRibBlank({ label: null, bic: null, iban: 'FR1420041010050500013M02606' })).toBe(false)
})
})
describe('isRibComplete (gating « + RIB » + RG-2.08)', () => {
it('vrai quand label + BIC + IBAN sont remplis', () => {
expect(isRibComplete({ label: 'Compte courant', bic: 'BNPAFRPP', iban: 'FR1420041010050500013M02606' })).toBe(true)
})
it('faux si un champ manque', () => {
expect(isRibComplete({ label: null, bic: 'BNPAFRPP', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: ' ', iban: 'FR14...' })).toBe(false)
})
})
describe('regles type de reglement (RG-2.07 / RG-2.08)', () => {
it('banque obligatoire si VIREMENT', () => {
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
expect(isBankRequiredForPaymentType(null)).toBe(false)
})
it('RIB obligatoire si LCR', () => {
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
expect(isRibRequiredForPaymentType(null)).toBe(false)
})
})
describe('isAddressValid (enum addressType, RG-2.06/2.09/2.10 ; pas d\'email facturation)', () => {
function validAddress(): AddressValidityDraft {
return {
addressType: 'DEPART',
categoryIris: ['/api/categories/1'],
siteIris: ['/api/sites/1'],
}
}
it('vrai quand type + >= 1 site + >= 1 categorie', () => {
expect(isAddressValid(validAddress())).toBe(true)
})
it('faux si le type d\'adresse n\'est pas renseigne (amorce vierge)', () => {
expect(isAddressValid({ ...validAddress(), addressType: null })).toBe(false)
})
it('faux si aucun site (RG-2.06)', () => {
expect(isAddressValid({ ...validAddress(), siteIris: [] })).toBe(false)
})
it('faux si aucune categorie (RG-2.10)', () => {
expect(isAddressValid({ ...validAddress(), categoryIris: [] })).toBe(false)
})
it('accepte les trois valeurs d\'enum PROSPECT / DEPART / RENDU', () => {
for (const type of ['PROSPECT', 'DEPART', 'RENDU'] as const) {
expect(isAddressValid({ ...validAddress(), addressType: type })).toBe(true)
}
})
})
describe('omitEmptyRequired (ERP-119 : 422 NotBlank au lieu de 400 de type)', () => {
it('retire les cles requises vides et conserve le reste', () => {
const payload = omitEmptyRequired(
{ companyName: null, sites: ['/api/sites/1'] },
['companyName'],
)
expect('companyName' in payload).toBe(false)
expect(payload.sites).toEqual(['/api/sites/1'])
})
it('false / 0 ne sont pas consideres vides (booleens / nombres preserves)', () => {
const payload = omitEmptyRequired({ triageProvider: false, bennes: 0 }, ['triageProvider', 'bennes'])
expect(payload).toEqual({ triageProvider: false, bennes: 0 })
})
})
@@ -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,
}
}
@@ -293,6 +300,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
return [{ value: relation['@id'], label }]
}
/**
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
* hors-LCR en consultation).
*/
export function paymentTypeCodeOf(relation: Relation): string | null {
if (!relation || typeof relation === 'string') {
return null
}
return (relation.code as string | undefined) ?? null
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView {
return {
+41 -11
View File
@@ -23,6 +23,7 @@ import {
} from '~/modules/commercial/utils/clientConsultation'
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
blankEmptyRequired,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
@@ -139,12 +140,35 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf
// ── Scoping strict des payloads PATCH ────────────────────────────────────────
/**
* Options de construction d'un payload d'ecriture.
* - `forUpdate: false` (defaut, CREATION/POST) : champs requis vides OMIS -> 422
* NotBlank (le back ne reçoit pas la cle, la propriete garde son defaut).
* - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : champs requis vides
* envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur serveur
* inchangee, faux 200 cf. blankEmptyRequired).
*/
export interface BuildPayloadOptions {
forUpdate?: boolean
}
/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */
function finalizeRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
options: BuildPayloadOptions,
): T {
return options.forUpdate
? blankEmptyRequired(payload, requiredKeys)
: omitEmptyRequired(payload, requiredKeys)
}
/**
* Payload du bloc principal groupe client:write:main UNIQUEMENT. La relation
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
* que la FK correspondant au type choisi, l'autre est forcee a null.
*/
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
// companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
// relationType : champ transitoire (non persiste cote back) qui porte
// l'intention UI « ce client depend d'un distributeur / courtier ». Il sert
@@ -152,14 +176,14 @@ export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
// la FK correspondante devient obligatoire -> 422 sur distributor / broker.
// Sans equivalent derivable cote back (FK nullable), c'est la seule facon de
// rester sur « on soumet, le back tranche » plutot qu'une garde front-only.
return omitEmptyRequired({
return finalizeRequired({
companyName: main.companyName,
categories: main.categoryIris,
relationType: main.relationType,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService,
}, MAIN_REQUIRED_NON_NULLABLE_KEYS)
}, MAIN_REQUIRED_NON_NULLABLE_KEYS, options)
}
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
@@ -211,9 +235,10 @@ export function buildContactPayload(contact: ContactFormDraft): Record<string, u
export function buildAddressPayload(
address: AddressFormDraft,
isBillingEmailRequired: boolean,
options: BuildPayloadOptions = {},
): Record<string, unknown> {
// postalCode / city / street omis si vides -> 422 NotBlank (ERP-119).
return omitEmptyRequired({
// postalCode / city / street : omis a la creation, `''` en edition -> 422 NotBlank (ERP-119).
return finalizeRequired({
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
@@ -229,18 +254,23 @@ export function buildAddressPayload(
contacts: address.contactIris,
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null,
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS)
// 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)
}
/** Payload d'un RIB (sous-ressource client_rib). */
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
// label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400 de type
// sur un RIB partiel (ex. IBAN seul). ERP-119.
return omitEmptyRequired({
export function buildRibPayload(rib: RibFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
// label / bic / iban : omis a la creation, `''` en edition -> 422 NotBlank au lieu
// d'un 400 de type (ou d'un faux 200 PATCH qui garderait l'ancienne valeur). ERP-119.
return finalizeRequired({
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}, RIB_REQUIRED_NON_NULLABLE_KEYS)
}, RIB_REQUIRED_NON_NULLABLE_KEYS, options)
}
// ── Gating par permission ────────────────────────────────────────────────────
@@ -419,3 +419,28 @@ export function omitEmptyRequired<T extends Record<string, unknown>>(
return payload
}
/**
* Variante PATCH (edition d'une ligne EXISTANTE) : remplace les cles requises
* laissees vides par une chaine vide `''` au lieu de les OMETTRE.
*
* Pourquoi pas `omitEmptyRequired` en edition : un PATCH a semantique merge une
* cle absente laisse la valeur serveur INCHANGEE. Vider un champ requis puis valider
* renverrait alors un 200 trompeur (l'ancienne valeur est conservee). En envoyant
* `''` (chaine valide), on evite le 400 de type (« must be string, NULL given ») et
* le Validator `NotBlank(trim)` rejette la valeur -> 422 avec propertyPath, mappee
* inline sous le champ. Mute et retourne le payload.
*/
export function blankEmptyRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
): T {
for (const key of requiredKeys) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
(payload as Record<string, unknown>)[key] = ''
}
}
return payload
}
@@ -0,0 +1,323 @@
/**
* Helpers purs de l'ecran « Consultation fournisseur » (M2 Commercial, lecture
* seule). Miroir de `clientConsultation.ts` (M1), adapte aux differences M2.
*
* Mappent le payload `GET /api/suppliers/{id}` (relations embarquees, cf. groupe
* `supplier:item:read` + `supplier:read:accounting`) vers les brouillons « plats »
* partages avec les blocs reutilisables `SupplierContactBlock` / `SupplierAddressBlock`
* et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables
* unitairement (cf. supplierConsultation.spec.ts).
*
* Rappels de contrat back (verifies sur le JSON reel fige ERP-92, spec-back § 4.0.bis) :
* - les relations ManyToOne (tvaMode/paymentDelay/paymentType/bank) sont
* serialisees en OBJETS embarques (`{id, code, label}`), pas en IRI nu ;
* - les champs nuls sont OMIS du JSON (skip_null_values) toujours lire avec `?? null` ;
* - les champs comptables et `ribs` sont TOTALEMENT ABSENTS (cle omise, pas `null`)
* sans permission accounting.view (gate serveur via SupplierReadGroupContextBuilder).
*
* Differences M2 vs M1 :
* - Adresse via enum `addressType` (PROSPECT/DEPART/RENDU, RG-2.09) pas de
* drapeaux isProspect/isDelivery/isBilling.
* - Adresse : champs specifiques fournisseur `bennes` (nombre) et `triageProvider`.
* Pas d'email de facturation.
* - Information : champ specifique fournisseur `volumeForecast`.
* - Pas de relation Distributeur/Courtier ni de triage sur le bloc principal.
*/
import { formatPhoneFR } from '~/shared/utils/phone'
import {
emptyAddress,
type SupplierAddressFormDraft,
type SupplierAddressType,
type SupplierContactFormDraft,
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
/** Reference Hydra embarquee minimale (@id toujours present). */
export interface HydraRef {
'@id': string
[key: string]: unknown
}
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
export type Relation = HydraRef | string | null | undefined
/** Site embarque dans une adresse (groupe site:read). */
export interface SiteRead extends HydraRef {
name?: string
color?: string
}
/** Categorie embarquee (groupe category:read). */
export interface CategoryRead extends HydraRef {
code?: string
name?: string
}
/** Contact embarque (groupe supplier_contact:read). */
export interface ContactRead extends HydraRef {
id: number
firstName?: string | null
lastName?: string | null
jobTitle?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
}
/** Adresse embarquee (groupe supplier_address:read). */
export interface AddressRead extends HydraRef {
id: number
addressType?: SupplierAddressType | null
country?: string | null
postalCode?: string | null
city?: string | null
street?: string | null
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.
contacts?: Array<HydraRef | string>
}
/** RIB embarque (groupe supplier:read:accounting, present ssi accounting.view). */
export interface RibRead extends HydraRef {
id: number
label?: string | null
bic?: string | null
iban?: string | null
}
/**
* Detail d'un fournisseur tel que renvoye par `GET /api/suppliers/{id}`. Tous les
* champs sont optionnels : skip_null_values cote serveur et gating accounting
* peuvent omettre n'importe quelle cle.
*/
export interface SupplierDetail extends HydraRef {
id: number
companyName?: string | null
isArchived?: boolean
categories?: CategoryRead[]
contacts?: ContactRead[]
addresses?: AddressRead[]
ribs?: RibRead[]
// Onglet Information
description?: string | null
competitors?: string | null
foundedAt?: string | null
employeesCount?: number | null
revenueAmount?: string | null
profitAmount?: string | null
directorName?: string | null
/** Volume previsionnel (entier, specifique fournisseur). */
volumeForecast?: number | null
// Onglet Comptabilite (present ssi accounting.view)
siren?: string | null
accountNumber?: string | null
nTva?: string | null
tvaMode?: Relation
paymentDelay?: Relation
paymentType?: Relation
bank?: Relation
}
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire). */
export interface AccountingDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
bankIri: string | null
}
/** Option de select ({ value, label }) construite a partir de l'embed. */
export interface SelectOption {
value: string
label: string
}
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
export interface CategorySelectOption extends SelectOption {
code: string
}
/**
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
*/
export interface AddressView {
draft: SupplierAddressFormDraft
siteOptions: SelectOption[]
categoryOptions: CategorySelectOption[]
}
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
export function iriOf(relation: Relation): string | null {
if (relation === null || relation === undefined) {
return null
}
if (typeof relation === 'string') {
return relation
}
return relation['@id'] ?? null
}
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
export function mapContactToDraft(contact: ContactRead): SupplierContactFormDraft {
const phoneSecondary = contact.phoneSecondary ?? null
return {
id: contact.id,
iri: contact['@id'] ?? null,
firstName: contact.firstName ?? null,
lastName: contact.lastName ?? null,
jobTitle: contact.jobTitle ?? null,
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
email: contact.email ?? null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
}
}
/**
* Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections).
* `bennes` (entier) est converti en chaine pour MalioInputNumber (defaut « 0 »).
*/
export function mapAddressToDraft(address: AddressRead): SupplierAddressFormDraft {
return {
id: address.id,
addressType: address.addressType ?? null,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
street: address.street ?? null,
streetComplement: address.streetComplement ?? null,
categoryIris: (address.categories ?? []).map(c => c['@id']),
siteIris: (address.sites ?? []).map(s => s['@id']),
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,
}
}
/** Mappe un RIB embarque vers un brouillon. */
export function mapRibToDraft(rib: RibRead): SupplierRibFormDraft {
return {
id: rib.id,
label: rib.label ?? null,
bic: rib.bic ?? null,
iban: rib.iban ?? null,
}
}
/** Mappe les champs comptables du fournisseur (scalaires + IRI des referentiels). */
export function mapAccountingDraft(supplier: SupplierDetail): AccountingDraft {
return {
siren: supplier.siren ?? null,
accountNumber: supplier.accountNumber ?? null,
nTva: supplier.nTva ?? null,
tvaModeIri: iriOf(supplier.tvaMode),
paymentDelayIri: iriOf(supplier.paymentDelay),
paymentTypeIri: iriOf(supplier.paymentType),
bankIri: iriOf(supplier.bank),
}
}
/**
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
* roles metier non-admin), qui laisserait les libelles vides.
*/
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
return (categories ?? []).map(c => ({
value: c['@id'],
label: c.name ?? c.code ?? c['@id'],
code: c.code ?? '',
}))
}
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
}
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed fournisseur. */
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
return (contacts ?? []).map(c => ({
value: c['@id'],
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
}))
}
/**
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
* `GET` de referentiel l'affichage reste correct quel que soit le role.
*/
export function referentialOptionOf(relation: Relation): SelectOption[] {
if (!relation || typeof relation === 'string') {
return []
}
const label = (relation.label as string | undefined)
?? (relation.name as string | undefined)
?? relation['@id']
return [{ value: relation['@id'], label }]
}
/**
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
* hors-LCR en consultation).
*/
export function paymentTypeCodeOf(relation: Relation): string | null {
if (!relation || typeof relation === 'string') {
return null
}
return (relation.code as string | undefined) ?? null
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView {
return {
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
}
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
* par onglet est gere sur l'ecran d'edition (96).
*/
export function canEditSupplier(canAny: (codes: string[]) => boolean): boolean {
return canAny(['commercial.suppliers.manage', 'commercial.suppliers.accounting.manage'])
}
/** Bouton « Archiver » : permission archive ET fournisseur encore actif. */
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('commercial.suppliers.archive') && !isArchived
}
/** Bouton « Restaurer » : permission archive ET fournisseur deja archive. */
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('commercial.suppliers.archive') && isArchived
}
/** Brouillon d'adresse vierge (reexport pour la page : 1 bloc vide si aucune adresse). */
export { emptyAddress }
@@ -0,0 +1,255 @@
/**
* Helpers purs des ecrans « Ajouter » / « Modifier » un fournisseur (M2
* Commercial) miroir de `clientEdit.ts` (M1). Deux responsabilites, toutes deux
* testables unitairement (cf. supplierEdit.spec.ts) :
* 1. Pre-remplissage : mapper le payload `GET /api/suppliers/{id}` (embed +
* scalaires) vers les brouillons « plats » edites par la page de modification.
* 2. Scoping STRICT des payloads PATCH (mode strict RG-2.16 / ERP-74) : chaque
* onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un
* payload mixte (un champ hors-permission = 403 sur l'integralite cote back).
*
* Ces helpers ne touchent ni a l'API ni a l'etat reactif.
*/
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
blankEmptyRequired,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import type {
SupplierAddressFormDraft,
SupplierContactFormDraft,
SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
/** Etat « plat » du bloc principal (groupe supplier:write:main). */
export interface MainFormDraft {
companyName: string | null
/** IRI des categories rattachees (M2M, type FOURNISSEUR). */
categoryIris: string[]
}
/** Etat « plat » de l'onglet Information (groupe supplier:write:information). */
export interface InformationFormDraft {
description: string | null
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null
revenueAmount: string | null
profitAmount: string | null
directorName: string | null
/** Volume previsionnel (entier >= 0, specifique fournisseur) en chaine. */
volumeForecast: string | null
}
/** Etat « plat » de l'onglet Comptabilite (groupe supplier:write:accounting). */
export interface AccountingFormDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
bankIri: string | null
}
/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un fournisseur. */
export interface SupplierEditAbilities {
/** `commercial.suppliers.manage` : bloc principal + onglets metier. */
canManage: boolean
/** `commercial.suppliers.accounting.view` : visibilite de l'onglet Comptabilite. */
canAccountingView: boolean
/** `commercial.suppliers.accounting.manage` : edition de l'onglet Comptabilite. */
canAccountingManage: boolean
}
/** Editabilite resolue par zone d'onglet (deduite des permissions). */
export interface TabEditability {
/** Bloc principal + onglets Information / Contacts / Adresses editables. */
businessEditable: boolean
/** Onglet Comptabilite present (affiche). */
accountingVisible: boolean
/** Onglet Comptabilite editable. */
accountingEditable: boolean
}
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
/** Mappe le detail fournisseur vers le brouillon du bloc principal. */
export function mapMainDraft(supplier: SupplierDetail): MainFormDraft {
return {
companyName: supplier.companyName ?? null,
categoryIris: (supplier.categories ?? []).map(c => c['@id']),
}
}
/** Mappe le detail fournisseur vers le brouillon de l'onglet Information. */
export function mapInformationDraft(supplier: SupplierDetail): InformationFormDraft {
return {
description: supplier.description ?? null,
competitors: supplier.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
revenueAmount: supplier.revenueAmount ?? null,
profitAmount: supplier.profitAmount ?? null,
directorName: supplier.directorName ?? null,
// Volume previsionnel (entier, specifique fournisseur) en chaine pour la saisie.
volumeForecast: supplier.volumeForecast != null ? String(supplier.volumeForecast) : null,
}
}
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
export function mapAccountingFormDraft(supplier: SupplierDetail): AccountingFormDraft {
return {
siren: supplier.siren ?? null,
accountNumber: supplier.accountNumber ?? null,
nTva: supplier.nTva ?? null,
tvaModeIri: iriOf(supplier.tvaMode),
paymentDelayIri: iriOf(supplier.paymentDelay),
paymentTypeIri: iriOf(supplier.paymentType),
bankIri: iriOf(supplier.bank),
}
}
/**
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
* miroir UI du re-gating champ-par-champ du SupplierProcessor) :
* - bloc principal + Information/Contacts/Adresses : editables ssi `manage` ;
* - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`.
*
* Produit le comportement attendu :
* - Admin : tout editable.
* - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee.
* - Compta (accounting seul, sans manage) : metier readonly, Compta editable.
*/
export function resolveTabEditability(abilities: SupplierEditAbilities): TabEditability {
return {
businessEditable: abilities.canManage,
accountingVisible: abilities.canAccountingView,
accountingEditable: abilities.canAccountingManage,
}
}
// ── Scoping strict des payloads PATCH/POST ──────────────────────────────────
/**
* Options de construction d'un payload d'ecriture.
* - `forUpdate: false` (defaut, CREATION/POST) : les champs requis vides sont OMIS
* -> 422 NotBlank a l'insert (le back ne reçoit pas la cle).
* - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : les champs requis
* vides sont envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur
* serveur inchangee, faux 200 cf. blankEmptyRequired).
*/
export interface BuildPayloadOptions {
forUpdate?: boolean
}
/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */
function finalizeRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
options: BuildPayloadOptions,
): T {
return options.forUpdate
? blankEmptyRequired(payload, requiredKeys)
: omitEmptyRequired(payload, requiredKeys)
}
/**
* Payload du bloc principal groupe supplier:write:main UNIQUEMENT.
* companyName vide -> 422 NotBlank (omis a la creation, `''` en edition ERP-119).
*/
export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
companyName: main.companyName,
categories: main.categoryIris,
}, MAIN_REQUIRED_NON_NULLABLE_KEYS, options)
}
/** Payload de l'onglet Information — groupe supplier:write:information UNIQUEMENT. */
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
return {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
directorName: information.directorName || null,
volumeForecast: information.volumeForecast ? Number(information.volumeForecast) : null,
}
}
/**
* Payload des scalaires de l'onglet Comptabilite groupe supplier:write:accounting
* UNIQUEMENT (les RIB passent par la sous-ressource /suppliers/{id}/ribs). La
* banque n'a de sens que pour un Virement (RG-2.07) : forcee a null sinon.
*/
export function buildAccountingPayload(
accounting: AccountingFormDraft,
isBankRequired: boolean,
): Record<string, unknown> {
return {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired ? accounting.bankIri : null,
}
}
/** Payload d'un contact (sous-ressource supplier_contact). */
export function buildContactPayload(contact: SupplierContactFormDraft): Record<string, unknown> {
return {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
phonePrimary: contact.phonePrimary || null,
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
email: contact.email || null,
}
}
/**
* Payload d'une adresse (sous-ressource supplier_address). postalCode / city /
* street omis si vides -> 422 NotBlank (ERP-119). Specifique fournisseur :
* `bennes` (entier, 0 par defaut) + `triageProvider` (booleen). Pas d'email de
* facturation (difference M1).
*/
export function buildAddressPayload(address: SupplierAddressFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
addressType: address.addressType,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: address.categoryIris,
sites: address.siteIris,
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)
}
/** Payload d'un RIB (sous-ressource supplier_rib). */
export function buildRibPayload(rib: SupplierRibFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}, RIB_REQUIRED_NON_NULLABLE_KEYS, options)
}
@@ -0,0 +1,244 @@
/**
* Regles metier pures de l'ecran « Ajouter un fournisseur » (M2 Commercial).
*
* Miroir de `clientFormRules.ts` (M1), centralisees ici (hors composant) pour
* rester testables unitairement et partagees entre la creation et les ecrans
* d'edition/consultation (95/96). Ces helpers ne touchent ni a l'API ni a l'etat
* reactif : ils prennent des brouillons « plats » et retournent des booleens.
*
* Le back reste la source de verite (les RG sont re-validees serveur, mode
* strict) ; ces regles ne servent qu'au feedback UI immediat (gating de boutons).
*
* Differences M2 vs M1 :
* - Adresse via enum `addressType` (PROSPECT/DEPART/RENDU, RG-2.09) pas de
* drapeaux ni d'exclusivite a gerer cote front (le radio est exclusif par nature).
* - Pas d'email de facturation, pas de relation Distributeur/Courtier.
*/
import type { SupplierAddressType } from '~/modules/commercial/types/supplierForm'
/**
* Onglets « coquille » (non encore implementes) : frame vide, passage
* automatique a l'onglet suivant (aligne M1).
*/
export const SUPPLIER_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const
/**
* Onglets affiches uniquement en MODIFICATION/CONSULTATION (jamais a la
* creation) : Statistiques / Rapports / Echanges. A rebrancher dans les ecrans
* 95/96 via l'option `includeEditOnlyTabs`.
*/
export const SUPPLIER_FORM_EDIT_ONLY_TABS = ['statistics', 'reports', 'exchanges'] as const
/**
* Construit l'ordre des onglets du formulaire fournisseur.
* - L'onglet Comptabilite n'est present que si l'utilisateur a `accounting.view`
* (Bureau / Commerciale ne le voient pas).
* - Les onglets edit-only sont exclus par defaut (creation) ; passer
* `includeEditOnlyTabs: true` pour les afficher en modification/consultation.
* Ordre aligne sur la spec M2 § Ecran « Ajouter un fournisseur » (barre 5 onglets).
*/
export function buildSupplierFormTabKeys(
canAccountingView: boolean,
options: { includeEditOnlyTabs?: boolean } = {},
): string[] {
const keys = ['information', 'contacts', 'addresses', 'transport']
if (canAccountingView) {
keys.push('accounting')
}
if (options.includeEditOnlyTabs) {
keys.push(...SUPPLIER_FORM_EDIT_ONLY_TABS)
}
return keys
}
/**
* Dernier onglet REMPLISSABLE d'un jeu d'onglets : le dernier qui n'est pas un
* placeholder. Role-aware sans regle ad hoc il suffit de lui passer les
* `tabKeys` deja filtres par permission. Sa validation marque la fin de l'ajout.
*/
export function lastFillableTabKey(tabKeys: string[]): string | undefined {
return [...tabKeys].reverse().find(
key => !(SUPPLIER_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key),
)
}
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-2.04/2.13). */
export interface ContactDraft {
firstName: string | null
lastName: string | null
}
/** Vrai si une chaine porte au moins un caractere non-espace. */
function isFilled(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim() !== ''
}
/** RG-2.04 : un contact est valide des qu'il porte un nom OU un prenom. */
export function isContactNamed(contact: ContactDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/**
* RG-2.13 : l'onglet Contacts ne peut etre finalise que s'il reste au moins un
* contact nomme (nom ou prenom).
*/
export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean {
return contacts.some(isContactNamed)
}
/**
* Primitive reutilisable : vrai si TOUTES les valeurs fournies sont vides. Sert a
* detecter un bloc de collection totalement vide (amorce non remplie). Un bloc qui
* porte la moindre donnee n'est PAS « blank » : il doit etre soumis pour declencher
* sa 422 inline plutot que d'etre saute silencieusement.
*/
export function isBlankRow(values: (string | null | undefined)[]): boolean {
return values.every(value => !isFilled(value))
}
/** Champs saisissables d'un bloc contact (pour detecter un bloc totalement vide). */
export interface ContactFillableDraft extends ContactDraft {
jobTitle: string | null
phonePrimary: string | null
phoneSecondary: string | null
email: string | null
}
/**
* Vrai si AUCUN champ saisissable du bloc contact n'est rempli. Distingue un bloc
* d'amorce vide (a ignorer au submit) d'un bloc partiellement rempli sans nom
* (email/telephone/fonction seul) : ce dernier doit etre soumis pour declencher la
* 422 RG-2.04 affichee inline.
*/
export function isContactBlank(contact: ContactFillableDraft): boolean {
return isBlankRow([
contact.firstName,
contact.lastName,
contact.jobTitle,
contact.phonePrimary,
contact.phoneSecondary,
contact.email,
])
}
/** Champs saisissables d'un bloc RIB (pour detecter un bloc totalement vide). */
export interface RibFillableDraft {
label: string | null
bic: string | null
iban: string | null
}
/**
* Vrai si AUCUN champ du bloc RIB n'est rempli. Un RIB partiellement rempli (ex.
* IBAN seul) n'est PAS « blank » : il doit etre soumis pour declencher les 422
* NotBlank inline plutot que d'etre saute silencieusement.
*/
export function isRibBlank(rib: RibFillableDraft): boolean {
return isBlankRow([rib.label, rib.bic, rib.iban])
}
/**
* RG-2.08 : un RIB est complet quand ses trois champs sont remplis (label, BIC,
* IBAN). Predicat partage entre le gating du bouton « + RIB » et la validation de
* l'onglet (au moins un RIB complet si reglement LCR).
*/
export function isRibComplete(rib: RibFillableDraft): boolean {
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
}
/**
* Sous-ensemble d'une adresse necessaire a sa validite par-bloc : type (enum),
* sites et categories rattaches.
*/
export interface AddressValidityDraft {
addressType: SupplierAddressType | null
categoryIris: string[]
siteIris: string[]
}
/**
* Validite par-bloc d'une adresse : type renseigne (RG-2.09), >= 1 site (RG-2.06)
* et >= 1 categorie (RG-2.10). Predicat partage entre le gating du bouton
* « + Adresse » (le dernier bloc doit etre valide avant d'en ajouter un autre) et
* la validation de l'onglet (toutes les adresses valides). Pas d'email de
* facturation cote fournisseur (difference M1).
*/
export function isAddressValid(address: AddressValidityDraft): boolean {
return address.addressType !== null
&& address.siteIris.length >= 1
&& address.categoryIris.length >= 1
}
/** Code stable du type de reglement « virement » (RG-2.07). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
/** Code stable du type de reglement « lettre de change » (RG-2.08). */
const PAYMENT_TYPE_LCR = 'LCR'
/** RG-2.07 : la banque est obligatoire lorsque le type de reglement est un virement. */
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_TRANSFER
}
/** RG-2.08 : au moins un RIB complet est obligatoire lorsque le type de reglement est une LCR. */
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_LCR
}
// ── Champs requis adosses a une colonne NON-nullable (ERP-119) ───────────────
// Memes contraintes qu'au M1 : un champ requis (NotBlank) porte par une colonne
// Doctrine NON nullable rejette `null` en 400 de TYPE avant le Validator. Parade :
// OMETTRE la cle du payload quand elle est vide -> le back produit une 422 NotBlank
// avec propertyPath, mappee en rouge sous le champ.
export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const
// addressType : colonne non-nullable + NotBlank cote back. Envoyer `null` (radio
// non choisi) provoque un 400 de TYPE a la deserialisation AVANT le Validator
// (« must be string, NULL given ») -> pas de violation, pas d'erreur inline. On
// omet donc la cle quand elle est vide pour obtenir une 422 NotBlank propertyPath.
export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['addressType', 'postalCode', 'city', 'street'] as const
export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
/**
* Retire d'un payload d'ecriture les cles requises laissees vides (null / '' /
* undefined), pour laisser le back produire une 422 NotBlank par champ plutot
* qu'un 400 de type sur une colonne non-nullable. Mute et retourne le payload.
*/
export function omitEmptyRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
): T {
for (const key of requiredKeys) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
delete payload[key]
}
}
return payload
}
/**
* Variante PATCH (edition d'une ligne EXISTANTE) : remplace les cles requises
* laissees vides par une chaine vide `''` au lieu de les OMETTRE.
*
* Pourquoi pas `omitEmptyRequired` en edition : un PATCH a semantique merge une
* cle absente laisse la valeur serveur INCHANGEE. Vider un champ requis puis valider
* renverrait alors un 200 trompeur (l'ancienne valeur est conservee). En envoyant
* `''`, la propriete `?string` est bien deserialisee (pas de 400 de type, contrairement
* a `null` sur une colonne non-nullable), puis le Validator `NotBlank(trim)` la rejette
* -> 422 avec propertyPath, mappee inline sous le champ. Mute et retourne le payload.
*/
export function blankEmptyRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
): T {
for (const key of requiredKeys) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
(payload as Record<string, unknown>)[key] = ''
}
}
return payload
}
@@ -0,0 +1,353 @@
<template>
<!-- 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 class="relative h-full w-full">
<div ref="mapEl" class="h-full w-full" data-testid="tour-map" />
<!-- Aide a la selection rectangle (lasso facon Badger Maps). -->
<div class="pointer-events-none absolute bottom-2 left-2 z-[400] rounded bg-white/90 px-2 py-1 text-xs text-gray-600 shadow">
{{ t('field_sales.plan.map.lassoHint') }}
</div>
</div>
</template>
<script setup lang="ts">
import type { Map as LeafletMap, Marker, Polyline, Rectangle } from 'leaflet'
import type { PlanningStop } from '~/modules/field-sales/composables/useTourPlanning'
import type { VisitableTier } from '~/modules/field-sales/types/tour'
/**
* Carte interactive de planification de tournee (M6.5, spec § 6.1).
*
* - Charge les pins des Tiers geolocalises de la zone visible
* (GET /api/visitable_tiers?bbox=...), colores par type (client/fournisseur),
* filtrables (types + recherche). Recharge au deplacement/zoom (debounce).
* - Popup au clic : nom, adresse, bouton « + Ajouter » (emet `add-tier`).
* - Selection rectangle (Maj + glisser) : ajoute tous les Tiers entoures
* (emet `add-tiers`).
* - Trace la tournee par-dessus : polyline + marqueurs numerotes suivant l'ordre
* des etapes geolocalisees.
*
* Instances Leaflet hors reactivite Vue (un proxy casse l'API Leaflet).
*/
const props = withDefaults(defineProps<{
/** Etapes geolocalisees a tracer (polyline numerotee). */
stops: PlanningStop[]
/** Types de pins affiches. */
types: Array<'client' | 'supplier'>
/** Recherche raison sociale / ville. */
search: string
/** Centre initial (defaut : Nantes). */
center?: [number, number]
}>(), {
center: () => [47.218, -1.553],
})
const emit = defineEmits<{
/** Ajout d'un seul Tiers (popup « + Ajouter »). */
'add-tier': [tier: VisitableTier]
/** Ajout d'un lot de Tiers (selection rectangle). */
'add-tiers': [tiers: VisitableTier[]]
}>()
const { t } = useI18n()
const api = useApi()
const mapEl = ref<HTMLElement | null>(null)
// Instances Leaflet (hors reactivite).
let L: typeof import('leaflet') | null = null
let map: LeafletMap | null = null
let pinLayer: Marker[] = []
let pinTiers: Array<{ tier: VisitableTier, marker: Marker }> = []
let routeLine: Polyline | null = null
let stopMarkers: Marker[] = []
let selectionRect: Rectangle | null = null
/** Zoom initial (niveau agglomeration). */
const INITIAL_ZOOM = 12
/** Couleur du pin par type de Tiers. */
const PIN_COLORS: Record<string, string> = {
client: '#2563eb', // bleu
supplier: '#16a34a', // vert
}
/** Debounce du rechargement des pins au deplacement de la carte. */
let fetchTimer: ReturnType<typeof setTimeout> | null = null
async function ensureMap(): Promise<void> {
if (map !== null || mapEl.value === null) {
return
}
const mod = await import('leaflet')
L = mod.default ?? mod
await import('leaflet/dist/leaflet.css')
if (mapEl.value === null) {
return
}
map = L.map(mapEl.value).setView(props.center, INITIAL_ZOOM)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map)
// Selection rectangle a la place du box-zoom natif (Maj + glisser).
map.boxZoom.disable()
map.on('mousedown', onMouseDown)
// Rechargement des pins quand la zone visible change.
map.on('moveend', scheduleFetch)
drawRoute()
await fetchPins()
}
/** bbox de la zone visible au format Leaflet (minLng,minLat,maxLng,maxLat). */
function currentBbox(): string | null {
if (map === null) {
return null
}
return map.getBounds().toBBoxString()
}
function scheduleFetch(): void {
if (fetchTimer !== null) {
clearTimeout(fetchTimer)
}
fetchTimer = setTimeout(() => {
void fetchPins()
}, 300)
}
/**
* Charge les pins de la zone visible. `?pagination=false` : la carte affiche
* TOUS les pins de la bbox (le volume est borne par la zone, pas par la page).
*/
async function fetchPins(): Promise<void> {
if (map === null || L === null) {
return
}
if (props.types.length === 0) {
clearPins()
return
}
const bbox = currentBbox()
if (bbox === null) {
return
}
const query: Record<string, string> = {
bbox,
type: props.types.join(','),
pagination: 'false',
}
if (props.search.trim() !== '') {
query.q = props.search.trim()
}
try {
const response = await api.get<{ member?: VisitableTier[] }>(
'/visitable_tiers',
query,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
renderPins(response.member ?? [])
}
catch {
// Echec non bloquant : la carte reste utilisable, les pins ne se mettent
// simplement pas a jour.
}
}
function clearPins(): void {
pinLayer.forEach(m => m.remove())
pinLayer = []
pinTiers = []
}
function renderPins(tiers: VisitableTier[]): void {
if (map === null || L === null) {
return
}
clearPins()
for (const tier of tiers) {
const marker = L.marker([tier.latitude, tier.longitude], {
icon: pinIcon(PIN_COLORS[tier.tierType] ?? '#6b7280'),
}).addTo(map)
marker.bindPopup(popupHtml(tier))
marker.on('popupopen', () => bindPopupButton(tier))
pinLayer.push(marker)
pinTiers.push({ tier, marker })
}
}
/** divIcon SVG inline colore (evite les assets PNG Leaflet casses par Vite). */
function pinIcon(color: string) {
return L!.divIcon({
className: '',
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="26" height="38" fill="${color}" stroke="#ffffff" stroke-width="1"><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: [26, 38],
iconAnchor: [13, 38],
popupAnchor: [0, -34],
})
}
/** Contenu HTML du popup (le bouton est cable a l'ouverture, cf. bindPopupButton). */
function popupHtml(tier: VisitableTier): string {
const name = escapeHtml(tier.displayName)
const address = escapeHtml(tier.address)
return `<div class="text-sm">
<div class="font-semibold">${name}</div>
<div class="text-gray-600">${address}</div>
<button type="button" data-add-tier class="mt-2 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white">${t('field_sales.plan.map.add')}</button>
</div>`
}
/** Cable le bouton « + Ajouter » du popup ouvert vers l'emit `add-tier`. */
function bindPopupButton(tier: VisitableTier): void {
const el = map?.getContainer().querySelector('[data-add-tier]')
el?.addEventListener('click', () => {
emit('add-tier', tier)
map?.closePopup()
}, { once: true })
}
// Selection rectangle (lasso)
let selectStart: import('leaflet').LatLng | null = null
function onMouseDown(e: import('leaflet').LeafletMouseEvent): void {
if (map === null || L === null || !e.originalEvent.shiftKey) {
return
}
// Empeche le drag de la carte pendant la selection.
map.dragging.disable()
selectStart = e.latlng
selectionRect = L.rectangle(L.latLngBounds(e.latlng, e.latlng), {
color: '#2563eb',
weight: 1,
fillOpacity: 0.1,
}).addTo(map)
map.on('mousemove', onMouseMove)
map.on('mouseup', onMouseUp)
}
function onMouseMove(e: import('leaflet').LeafletMouseEvent): void {
if (selectStart === null || selectionRect === null || L === null) {
return
}
selectionRect.setBounds(L.latLngBounds(selectStart, e.latlng))
}
function onMouseUp(): void {
if (map === null) {
return
}
const bounds = selectionRect?.getBounds() ?? null
cleanupSelection()
if (bounds === null) {
return
}
const selected = pinTiers
.filter(({ marker }) => bounds.contains(marker.getLatLng()))
.map(({ tier }) => tier)
if (selected.length > 0) {
emit('add-tiers', selected)
}
}
function cleanupSelection(): void {
selectionRect?.remove()
selectionRect = null
selectStart = null
map?.off('mousemove', onMouseMove)
map?.off('mouseup', onMouseUp)
map?.dragging.enable()
}
// Trace de la tournee
function drawRoute(): void {
if (map === null || L === null) {
return
}
routeLine?.remove()
routeLine = null
stopMarkers.forEach(m => m.remove())
stopMarkers = []
const located = props.stops.filter(s => s.latitude != null && s.longitude != null)
const points = located.map(s => [s.latitude as number, s.longitude as number] as [number, number])
if (points.length >= 2) {
routeLine = L.polyline(points, { color: '#1e40af', weight: 3, opacity: 0.7 }).addTo(map)
}
located.forEach((stop, index) => {
const marker = L!.marker([stop.latitude as number, stop.longitude as number], {
icon: numberedIcon(index + 1),
zIndexOffset: 1000,
}).addTo(map!)
marker.bindTooltip(stop.label, { direction: 'top' })
stopMarkers.push(marker)
})
}
/** Pastille numerotee pour une etape de la tournee. */
function numberedIcon(n: number) {
return L!.divIcon({
className: '',
html: `<div style="display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:9999px;background:#1e40af;color:#fff;font-size:12px;font-weight:700;border:2px solid #fff;box-shadow:0 1px 2px rgba(0,0,0,.4)">${n}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
})
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// Recharge les pins quand les filtres changent.
watch(() => [props.types, props.search], scheduleFetch, { deep: true })
// Redessine le trace quand les etapes changent.
watch(() => props.stops, drawRoute, { deep: true })
onMounted(ensureMap)
onBeforeUnmount(() => {
if (fetchTimer !== null) {
clearTimeout(fetchTimer)
}
map?.remove()
map = null
L = null
pinLayer = []
pinTiers = []
stopMarkers = []
routeLine = null
selectionRect = null
})
defineExpose({
/** Recentre la carte sur une cible (ex: depuis le panneau). */
panTo(target: { latitude: number, longitude: number }) {
map?.panTo([target.latitude, target.longitude])
},
})
</script>
@@ -0,0 +1,148 @@
<template>
<div>
<p v-if="stops.length === 0" class="py-6 text-center text-sm text-gray-500" data-testid="stops-empty">
{{ t('field_sales.plan.panel.noStops') }}
</p>
<!-- Liste draggable (vuedraggable / SortableJS) : au drop, on emet le
nouvel ordre. La poignee limite le drag a l'icone (le reste de la
ligne reste cliquable). Etat 100 % local cote parent. -->
<draggable
v-else
:model-value="stops"
item-key="id"
handle=".drag-handle"
ghost-class="opacity-50"
class="flex flex-col gap-2"
@update:model-value="onReorder"
>
<template #item="{ element, index }">
<div
class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2"
:data-testid="`stop-${element.id}`"
>
<!-- Poignee de drag + numero d'ordre. -->
<button
type="button"
class="drag-handle mt-0.5 flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-full bg-blue-800 text-xs font-bold text-white"
:aria-label="t('field_sales.plan.panel.stops')"
>
{{ index + 1 }}
</button>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-gray-900">{{ element.label }}</span>
<span
v-if="!isStopLocated(element)"
class="shrink-0 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
>
{{ t('field_sales.plan.stop.toGeolocate') }}
</span>
</div>
<p class="truncate text-xs text-gray-500">{{ element.displayAddress }}</p>
<!-- ETA + temps depuis l'etape precedente. -->
<p v-if="isStopLocated(element)" class="mt-0.5 text-xs text-gray-600">
<span class="font-medium">{{ t('field_sales.plan.stop.eta') }}</span>
{{ formatTime(element.eta) }}
<span v-if="index > 0" class="text-gray-400">
· {{ formatDuration(element.legDurationS) }} / {{ formatDistance(element.legDistanceM) }}
{{ t('field_sales.plan.stop.fromPrevious') }}
</span>
</p>
<!-- Actions : Y aller (deep links) · Voir le Tiers. -->
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<div class="relative">
<button
type="button"
class="font-medium text-blue-700 hover:underline disabled:text-gray-300"
:disabled="navLinks(element) === null"
@click="toggleMenu(element.id)"
>
{{ t('field_sales.plan.stop.goThere') }}
</button>
<div
v-if="openMenuId === element.id && navLinks(element) !== null"
class="absolute z-10 mt-1 flex flex-col rounded border border-gray-200 bg-white py-1 shadow-lg"
>
<a :href="navLinks(element)!.waze" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.waze') }}</a>
<a :href="navLinks(element)!.google" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.google') }}</a>
<a :href="navLinks(element)!.apple" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.apple') }}</a>
</div>
</div>
<button
v-if="element.tierType !== 'custom'"
type="button"
class="text-gray-600 hover:underline"
@click="emit('view-tier', element)"
>
{{ t('field_sales.plan.stop.viewTier') }}
</button>
</div>
</div>
<!-- Suppression de l'etape. -->
<button
type="button"
class="mt-0.5 shrink-0 text-gray-400 hover:text-red-600"
:aria-label="t('field_sales.plan.stop.remove')"
@click="emit('remove', element)"
>
<Icon name="mdi:close" size="18" />
</button>
</div>
</template>
</draggable>
</div>
</template>
<script setup lang="ts">
import draggable from 'vuedraggable'
import {
buildNavigationLinks,
isStopLocated,
formatDistance,
formatDuration,
formatTime,
type PlanningStop,
} from '~/modules/field-sales/composables/useTourPlanning'
import type { NavigationLinks } from '~/modules/field-sales/types/tour'
/**
* Liste ordonnee et draggable des etapes d'une tournee (panneau de
* planification, M6.5). Le reordonnancement (drag & drop) emet le nouvel ordre ;
* la persistance (POST /reorder) est a la charge de la page.
*/
defineProps<{
stops: PlanningStop[]
}>()
const emit = defineEmits<{
/** Nouvel ordre des etapes apres drop. */
'reorder': [stops: PlanningStop[]]
/** Retrait d'une etape. */
'remove': [stop: PlanningStop]
/** « Voir le Tiers » (etape sur Tiers referentiel). */
'view-tier': [stop: PlanningStop]
}>()
const { t } = useI18n()
/** Menu « Y aller » ouvert (id de l'etape) ou null. */
const openMenuId = ref<number | null>(null)
function toggleMenu(id: number): void {
openMenuId.value = openMenuId.value === id ? null : id
}
function navLinks(stop: PlanningStop): NavigationLinks | null {
return buildNavigationLinks(stop)
}
function onReorder(next: PlanningStop[]): void {
emit('reorder', next)
}
</script>
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest'
import {
reorderStops,
computeTotals,
buildNavigationLinks,
isStopLocated,
formatDistance,
formatDuration,
formatTime,
type PlanningStop,
} from '../useTourPlanning'
/** Fabrique une etape de planification minimale pour les tests. */
function makeStop(overrides: Partial<PlanningStop> = {}): PlanningStop {
return {
id: overrides.id ?? 1,
tierType: overrides.tierType ?? 'client',
tierId: overrides.tierId ?? null,
addressId: overrides.addressId ?? null,
customLabel: null,
customAddress: null,
customLatitude: null,
customLongitude: null,
position: overrides.position ?? 0,
visitMinutes: overrides.visitMinutes ?? null,
legDistanceM: overrides.legDistanceM ?? null,
legDurationS: overrides.legDurationS ?? null,
eta: overrides.eta ?? null,
label: overrides.label ?? 'Étape',
displayAddress: overrides.displayAddress ?? '',
latitude: overrides.latitude ?? null,
longitude: overrides.longitude ?? null,
}
}
describe('reorderStops', () => {
it('deplace une etape et renumerote les positions de maniere contigue', () => {
const stops = [
makeStop({ id: 1, position: 0, label: 'A' }),
makeStop({ id: 2, position: 1, label: 'B' }),
makeStop({ id: 3, position: 2, label: 'C' }),
]
// Deplace C (index 2) en tete (index 0).
const result = reorderStops(stops, 2, 0)
expect(result.map(s => s.label)).toEqual(['C', 'A', 'B'])
expect(result.map(s => s.position)).toEqual([0, 1, 2])
})
it('ne mute pas le tableau source', () => {
const stops = [makeStop({ id: 1, position: 0 }), makeStop({ id: 2, position: 1 })]
reorderStops(stops, 0, 1)
expect(stops.map(s => s.id)).toEqual([1, 2])
})
it('retourne une copie inchangee si un index est hors borne', () => {
const stops = [makeStop({ id: 1, position: 0 })]
const result = reorderStops(stops, 0, 5)
expect(result.map(s => s.id)).toEqual([1])
})
})
describe('computeTotals', () => {
it('somme distances/trajets et ajoute les visites (defaut + specifique)', () => {
const stops = [
// 1re etape : pas de leg (point de depart). Visite = defaut 30 min.
makeStop({ id: 1, legDistanceM: null, legDurationS: null }),
// 2e : 10 km / 12 min de trajet, visite specifique 15 min.
makeStop({ id: 2, legDistanceM: 10_000, legDurationS: 720, visitMinutes: 15 }),
// 3e : 5 km / 6 min, visite par defaut.
makeStop({ id: 3, legDistanceM: 5_000, legDurationS: 360, visitMinutes: null }),
]
const totals = computeTotals(stops, 30)
expect(totals.totalDistanceM).toBe(15_000)
expect(totals.travelDurationS).toBe(1_080)
// Visites : 30 + 15 + 30 = 75 min = 4500 s.
expect(totals.visitDurationS).toBe(4_500)
expect(totals.totalDurationS).toBe(1_080 + 4_500)
expect(totals.visitCount).toBe(3)
})
it('renvoie des totaux nuls pour une tournee vide', () => {
const totals = computeTotals([], 30)
expect(totals.totalDistanceM).toBe(0)
expect(totals.totalDurationS).toBe(0)
expect(totals.visitCount).toBe(0)
})
})
describe('buildNavigationLinks', () => {
it('construit les trois deep links Waze/Google/Apple', () => {
const links = buildNavigationLinks({ latitude: 47.218, longitude: -1.553 })
expect(links).not.toBeNull()
expect(links!.waze).toBe('https://waze.com/ul?ll=47.218,-1.553&navigate=yes')
expect(links!.google).toBe('https://www.google.com/maps/dir/?api=1&destination=47.218,-1.553')
expect(links!.apple).toBe('https://maps.apple.com/?daddr=47.218,-1.553')
})
it('retourne null sans coordonnees (etape a geolocaliser)', () => {
expect(buildNavigationLinks(null)).toBeNull()
expect(buildNavigationLinks({ latitude: 47.2 })).toBeNull()
expect(buildNavigationLinks({ latitude: null, longitude: null })).toBeNull()
})
})
describe('isStopLocated', () => {
it('distingue une etape geolocalisee d\'une etape sans coordonnees', () => {
expect(isStopLocated({ latitude: 47.2, longitude: -1.5 })).toBe(true)
expect(isStopLocated({ latitude: null, longitude: null })).toBe(false)
})
})
describe('formatteurs', () => {
it('formate distances et durees', () => {
expect(formatDistance(850)).toBe('850 m')
expect(formatDistance(12_340)).toBe('12,3 km')
expect(formatDistance(null)).toBe('—')
expect(formatDuration(1_500)).toBe('25 min')
expect(formatDuration(5_100)).toBe('1 h 25')
expect(formatDuration(null)).toBe('—')
})
it('extrait l\'heure HH:MM d\'une chaine ISO', () => {
expect(formatTime('1970-01-01T08:30:00+00:00')).toBe('08:30')
expect(formatTime(null)).toBe('—')
})
})
@@ -0,0 +1,178 @@
import type { NavigationLinks, TierType, TourStop, TourTotals } from '~/modules/field-sales/types/tour'
/**
* Composable de planification de tournee (M6.5).
*
* Porte la logique PURE de l'ecran de planification, isolee de Vue/Nuxt pour
* etre testable directement (Vitest) :
* - reordonnancement des etapes (drag & drop) + renumerotation des positions ;
* - recalcul instantane des totaux (trajets + visites) pour le feedback UI,
* avant le retour serveur du /compute ;
* - construction des deep links de navigation « Y aller » (Waze/Google/Apple).
*
* Les coordonnees et libelles des etapes sur Tiers referentiel ne sont pas
* portes par tour_stop:read : l'ecran les resout via GET /visitable_tiers/{id}
* et alimente un `PlanningStop` enrichi, sur lequel operent ces fonctions.
*/
/** Coordonnees WGS84 minimales d'une cible. */
export interface LatLng {
latitude: number
longitude: number
}
/**
* Etape « enrichie » manipulee par l'ecran : l'etape API + le libelle, l'adresse
* et les coordonnees resolus (depuis le Tiers pour une etape referentiel, depuis
* les colonnes custom_* pour un point libre).
*/
export interface PlanningStop extends TourStop {
/** Nom affichable (raison sociale du Tiers ou libelle du point libre). */
label: string
/** Adresse formatee sur une ligne. */
displayAddress: string
/** Coordonnees resolues, ou null si l'etape n'est pas geolocalisee (RG-6.05). */
latitude: number | null
longitude: number | null
}
/** Vitesse moyenne par defaut (km/h) — alignee sur HaversineRouteEngine (back). */
const DEFAULT_SPEED_KMH = 50
/**
* Deplace l'etape `fromIndex` vers `toIndex` et renumerote toutes les positions
* (0-indexees, contigues). Retourne un NOUVEAU tableau (pas de mutation).
*/
export function reorderStops<T extends { position: number }>(stops: readonly T[], fromIndex: number, toIndex: number): T[] {
const next = [...stops]
if (fromIndex < 0 || fromIndex >= next.length || toIndex < 0 || toIndex >= next.length) {
return next
}
const [moved] = next.splice(fromIndex, 1)
if (moved === undefined) {
return next
}
next.splice(toIndex, 0, moved)
return next.map((stop, index) => ({ ...stop, position: index }))
}
/**
* Recalcule les totaux d'une tournee a partir des legs deja calcules et des
* durees de visite (RG-6.11). Duree totale = trajets + visites.
*/
export function computeTotals(stops: readonly PlanningStop[], defaultVisitMinutes: number): TourTotals {
let totalDistanceM = 0
let travelDurationS = 0
let visitDurationS = 0
for (const stop of stops) {
totalDistanceM += stop.legDistanceM ?? 0
travelDurationS += stop.legDurationS ?? 0
visitDurationS += (stop.visitMinutes ?? defaultVisitMinutes) * 60
}
return {
totalDistanceM,
travelDurationS,
visitDurationS,
totalDurationS: travelDurationS + visitDurationS,
visitCount: stops.length,
}
}
/**
* Deep links de navigation vers une cible geolocalisee (spec M6 § 6.1).
* Waze/Google Maps ne prennent qu'UNE destination -> navigation etape par etape
* (HP-M6-7 assume). Retourne null si la cible n'a pas de coordonnees.
*/
export function buildNavigationLinks(target: { latitude?: number | null, longitude?: number | null } | null): NavigationLinks | null {
if (target == null || target.latitude == null || target.longitude == null) {
return null
}
const lat = target.latitude
const lng = target.longitude
return {
waze: `https://waze.com/ul?ll=${lat},${lng}&navigate=yes`,
google: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`,
apple: `https://maps.apple.com/?daddr=${lat},${lng}`,
}
}
/** Vrai si l'etape est geolocalisee (entre dans le calcul de trajet, RG-6.05). */
export function isStopLocated(stop: Pick<PlanningStop, 'latitude' | 'longitude'>): boolean {
return stop.latitude != null && stop.longitude != null
}
/** Estime une duree de trajet (s) a partir d'une distance (m) et la vitesse moyenne. */
export function estimateDurationSeconds(distanceMeters: number, speedKmh: number = DEFAULT_SPEED_KMH): number {
if (speedKmh <= 0) {
return 0
}
return Math.round((distanceMeters / 1000) / speedKmh * 3600)
}
/** Formate une distance (m) en « 12,3 km » ou « 850 m ». */
export function formatDistance(meters: number | null): string {
if (meters == null) {
return '—'
}
if (meters < 1000) {
return `${Math.round(meters)} m`
}
return `${(meters / 1000).toFixed(1).replace('.', ',')} km`
}
/** Formate une duree (s) en « 1 h 25 » ou « 25 min ». */
export function formatDuration(seconds: number | null): string {
if (seconds == null) {
return '—'
}
const totalMinutes = Math.round(seconds / 60)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (hours === 0) {
return `${minutes} min`
}
return `${hours} h ${String(minutes).padStart(2, '0')}`
}
/** Extrait l'heure « HH:MM » d'une chaine ISO (eta / departureTime). */
export function formatTime(iso: string | null): string {
if (iso == null || iso === '') {
return '—'
}
const match = iso.match(/(\d{2}):(\d{2})/)
return match ? `${match[1]}:${match[2]}` : '—'
}
/** Libelle FR court d'un type de Tiers (pour la couleur/le badge du pin). */
export function tierTypeLabel(type: TierType): string {
switch (type) {
case 'client':
return 'Client'
case 'supplier':
return 'Fournisseur'
default:
return 'Point libre'
}
}
export function useTourPlanning() {
return {
reorderStops,
computeTotals,
buildNavigationLinks,
isStopLocated,
estimateDurationSeconds,
formatDistance,
formatDuration,
formatTime,
tierTypeLabel,
}
}
@@ -0,0 +1,15 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
import type { Tour } from '~/modules/field-sales/types/tour'
/**
* Liste paginee des tournees (GET /api/tours), branchee sur usePaginatedList
* (regle ABSOLUE n°13 : toute collection est paginee). Tri par date decroissante
* par defaut. Le filtre `owner` est applique cote back (RG-6.01) rien a passer
* ici.
*/
export function useToursRepository() {
return usePaginatedList<Tour>({
url: '/tours',
defaultSort: { field: 'tourDate', direction: 'desc' },
})
}
@@ -0,0 +1,4 @@
// Layer Nuxt du module « Tournées » (field_sales, M6). Auto-detecte par le
// shell via le scan de frontend/modules/*/. Config minimale : pages,
// composants et composables sont decouverts par convention.
export default defineNuxtConfig({})
@@ -0,0 +1,648 @@
<template>
<div>
<!-- Entete : retour + nom de la tournee. -->
<div class="flex items-center gap-3 pt-6 pb-4">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="t('field_sales.plan.back')" @click="goBack" />
<h1 class="truncate text-[24px] font-semibold text-primary-500">
{{ tour?.label ?? t('field_sales.plan.title') }}
</h1>
</div>
<!-- Layout split responsive : carte + panneau cote a cote en desktop,
empile en mobile (carte au-dessus). Etat 100 % local. -->
<div class="flex flex-col gap-4 lg:h-[calc(100vh-180px)] lg:flex-row">
<!-- Carte interactive. -->
<div class="relative h-[45vh] overflow-hidden rounded border border-gray-200 lg:h-auto lg:flex-1">
<TourMap
ref="mapRef"
:stops="stops"
:types="activeTypes"
:search="mapSearch"
:center="mapCenter"
@add-tier="addTier"
@add-tiers="addTiers"
/>
<!-- Filtres de la carte (types + recherche). -->
<div class="absolute right-2 top-2 z-[400] flex flex-col gap-2 rounded bg-white/95 p-2 shadow">
<MalioInputText
v-model="mapSearch"
:placeholder="t('field_sales.plan.map.search')"
icon-name="mdi:magnify"
:reserve-message-space="false"
input-class="w-44"
/>
<div class="flex gap-3 text-xs">
<label class="flex items-center gap-1">
<input v-model="showClients" type="checkbox" class="accent-blue-600"> {{ t('field_sales.plan.map.typeClient') }}
</label>
<label class="flex items-center gap-1">
<input v-model="showSuppliers" type="checkbox" class="accent-green-600"> {{ t('field_sales.plan.map.typeSupplier') }}
</label>
</div>
</div>
</div>
<!-- Panneau tournee. -->
<div class="flex flex-col gap-4 overflow-y-auto rounded border border-gray-200 p-4 lg:w-[420px]">
<!-- Parametres de la tournee. -->
<div class="flex flex-col gap-3">
<MalioInputText
v-model="panel.label"
:label="t('field_sales.plan.panel.label')"
@update:model-value="debouncedSaveLabel"
/>
<div class="flex gap-3">
<MalioDate v-model="panel.tourDate" :label="t('field_sales.plan.panel.date')" class="flex-1" @update:model-value="saveDate" />
<MalioTime v-model="panel.departureTime" :label="t('field_sales.plan.panel.departureTime')" class="flex-1" @update:model-value="saveDepartureTime" />
</div>
<MalioInputText
v-model="panel.startLabel"
:label="t('field_sales.plan.panel.startLabel')"
@update:model-value="debouncedSaveStart"
/>
<MalioInputNumber
v-model="panel.defaultVisitMinutes"
:label="t('field_sales.plan.panel.defaultVisitMinutes')"
:min="0"
@update:model-value="debouncedSaveVisitMinutes"
/>
</div>
<!-- Totaux. -->
<div class="grid grid-cols-3 gap-2 rounded bg-gray-50 p-3 text-center">
<div>
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.distance') }}</p>
<p class="font-semibold">{{ formatDistance(totals.totalDistanceM) }}</p>
</div>
<div>
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.duration') }}</p>
<p class="font-semibold">{{ formatDuration(totals.totalDurationS) }}</p>
</div>
<div>
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.visits') }}</p>
<p class="font-semibold">{{ totals.visitCount }}</p>
</div>
</div>
<!-- Actions tournee. -->
<div class="flex flex-wrap gap-2">
<MalioButton variant="primary" :label="t('field_sales.plan.actions.compute')" :disabled="busy" @click="runCompute" />
<MalioButton variant="secondary" :label="t('field_sales.plan.actions.optimize')" :disabled="busy" @click="runOptimize" />
<MalioButton variant="tertiary" :label="t('field_sales.plan.actions.duplicate')" :disabled="busy" @click="duplicateOpen = true" />
<MalioButton variant="tertiary" :label="t('field_sales.plan.actions.pdf')" @click="openPdf" />
</div>
<!-- Etapes draggables. -->
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="font-semibold text-gray-800">{{ t('field_sales.plan.panel.stops') }}</h2>
<MalioButton
variant="tertiary"
:label="t('field_sales.plan.custom.add')"
icon-name="mdi:map-marker-plus"
icon-position="left"
@click="customOpen = true"
/>
</div>
<TourStopList
:stops="stops"
@reorder="onReorder"
@remove="removeStop"
@view-tier="viewTier"
/>
</div>
</div>
</div>
<!-- Modale : point libre custom. -->
<MalioModal v-model="customOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[22px] font-bold">{{ t('field_sales.plan.custom.title') }}</h2>
</template>
<div class="flex flex-col gap-3">
<MalioInputText v-model="customForm.label" :label="t('field_sales.plan.custom.label')" required :error="customErrors.label" />
<MalioInputText v-model="customForm.address" :label="t('field_sales.plan.custom.address')" required :error="customErrors.address" :hint="t('field_sales.plan.custom.hint')" />
<p v-if="customGeocodeFailed" class="text-xs text-red-600">{{ t('field_sales.plan.custom.geocodeFailed') }}</p>
</div>
<template #footer>
<MalioButton variant="secondary" :label="t('field_sales.plan.custom.cancel')" button-class="flex-1" @click="customOpen = false" />
<MalioButton variant="primary" :label="t('field_sales.plan.custom.confirm')" button-class="flex-1" :disabled="busy" @click="confirmCustom" />
</template>
</MalioModal>
<!-- Modale : duplication. -->
<MalioModal v-model="duplicateOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[22px] font-bold">{{ t('field_sales.plan.duplicateModal.title') }}</h2>
</template>
<MalioDate v-model="duplicateDate" :label="t('field_sales.plan.duplicateModal.date')" required :error="duplicateError" />
<template #footer>
<MalioButton variant="secondary" :label="t('field_sales.plan.duplicateModal.cancel')" button-class="flex-1" @click="duplicateOpen = false" />
<MalioButton variant="primary" :label="t('field_sales.plan.duplicateModal.confirm')" button-class="flex-1" :disabled="busy" @click="confirmDuplicate" />
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import TourMap from '~/modules/field-sales/components/TourMap.vue'
import TourStopList from '~/modules/field-sales/components/TourStopList.vue'
import {
computeTotals,
formatDistance,
formatDuration,
formatTime,
type PlanningStop,
} from '~/modules/field-sales/composables/useTourPlanning'
import { useAddressAutocomplete } from '~/shared/composables/useAddressAutocomplete'
import type { Tour, TourStop, VisitableTier } from '~/modules/field-sales/types/tour'
/**
* Ecran de planification d'une tournee (M6.5, spec § 6.1).
*
* Carte interactive (pins + lasso + trace) a gauche, panneau (parametres,
* totaux, actions, etapes draggables) a droite ; empile en mobile. Etat 100 %
* LOCAL (jamais dans l'URL, regle ABSOLUE n°6).
*
* Les etapes sur Tiers referentiel ne portent pas leurs coordonnees/nom dans
* tour_stop:read : on les resout via GET /visitable_tiers/{type}-{addressId}
* (cache local) pour alimenter des `PlanningStop` enrichis.
*/
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const route = useRoute()
const toast = useToast()
const autocomplete = useAddressAutocomplete()
const tourId = computed(() => Number(route.params.id))
const tour = ref<Tour | null>(null)
const stops = ref<PlanningStop[]>([])
const busy = ref(false)
const mapRef = ref<InstanceType<typeof TourMap> | null>(null)
useHead({ title: () => tour.value?.label ?? t('field_sales.plan.title') })
// Cache des infos Tiers (nom/adresse/coords) par cle « type-addressId » : evite
// de refetcher /visitable_tiers/{id} a chaque recompute.
const tierCache = new Map<string, { label: string, displayAddress: string, latitude: number, longitude: number }>()
// Panneau (formulaire local synchronise avec la tournee)
// defaultVisitMinutes est une chaine (MalioInputNumber est un v-model string).
const panel = reactive<{
label: string
tourDate: string | null
departureTime: string | null
startLabel: string
defaultVisitMinutes: string
}>({
label: '',
tourDate: null,
departureTime: null,
startLabel: '',
defaultVisitMinutes: '30',
})
/** Debounce simple (les saves lisent l'etat `panel`, donc sans argument). */
function debounce(fn: () => void, ms: number): () => void {
let handle: ReturnType<typeof setTimeout> | null = null
return () => {
if (handle !== null) {
clearTimeout(handle)
}
handle = setTimeout(fn, ms)
}
}
const debouncedSaveLabel = debounce(() => { void saveLabel() }, 600)
const debouncedSaveStart = debounce(() => { void saveStart() }, 800)
const debouncedSaveVisitMinutes = debounce(() => { void saveVisitMinutes() }, 600)
// Carte : filtres
const showClients = ref(true)
const showSuppliers = ref(true)
const mapSearch = ref('')
const activeTypes = computed<Array<'client' | 'supplier'>>(() => {
const types: Array<'client' | 'supplier'> = []
if (showClients.value) {
types.push('client')
}
if (showSuppliers.value) {
types.push('supplier')
}
return types
})
const mapCenter = ref<[number, number]>([47.218, -1.553])
// Totaux recalcules localement (feedback instantane)
const totals = computed(() => computeTotals(stops.value, Number(panel.defaultVisitMinutes) || 0))
// Modales
const customOpen = ref(false)
const customForm = reactive({ label: '', address: '' })
const customErrors = reactive<{ label: string, address: string }>({ label: '', address: '' })
const customGeocodeFailed = ref(false)
const duplicateOpen = ref(false)
const duplicateDate = ref<string | null>(null)
const duplicateError = ref('')
// =============================================================================
// Chargement + enrichissement
// =============================================================================
onMounted(loadTour)
async function loadTour(): Promise<void> {
try {
const raw = await api.get<Tour>(`/tours/${tourId.value}`, {}, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
await applyTour(raw, true)
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.loadError') })
router.push('/tours')
}
}
/** Applique une reponse Tour au state local. `withStops` re-enrichit les etapes. */
async function applyTour(raw: Tour, withStops: boolean): Promise<void> {
tour.value = raw
panel.label = raw.label
panel.tourDate = raw.tourDate ? raw.tourDate.slice(0, 10) : null
panel.departureTime = extractTime(raw.departureTime)
panel.startLabel = raw.startLabel ?? ''
panel.defaultVisitMinutes = String(raw.defaultVisitMinutes)
if (withStops) {
stops.value = await enrichStops(raw.stops ?? [])
recenterOnFirstStop()
}
}
/** Resout nom/adresse/coords de chaque etape en `PlanningStop`. */
async function enrichStops(rawStops: TourStop[]): Promise<PlanningStop[]> {
const ordered = [...rawStops].sort((a, b) => a.position - b.position)
return Promise.all(ordered.map(async (stop): Promise<PlanningStop> => {
if (stop.tierType === 'custom') {
return {
...stop,
label: stop.customLabel ?? '',
displayAddress: stop.customAddress ?? '',
latitude: stop.customLatitude != null ? Number(stop.customLatitude) : null,
longitude: stop.customLongitude != null ? Number(stop.customLongitude) : null,
}
}
const info = await resolveTier(stop.tierType, stop.addressId)
return {
...stop,
label: info?.label ?? `#${stop.tierId}`,
displayAddress: info?.displayAddress ?? '',
latitude: info?.latitude ?? null,
longitude: info?.longitude ?? null,
}
}))
}
/** Infos d'un Tiers (cache + GET /visitable_tiers/{type-addressId}). */
async function resolveTier(tierType: string, addressId: number | null) {
if (addressId === null) {
return null
}
const key = `${tierType}-${addressId}`
const cached = tierCache.get(key)
if (cached) {
return cached
}
try {
const tier = await api.get<VisitableTier>(`/visitable_tiers/${key}`, {}, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
const info = {
label: tier.displayName,
displayAddress: tier.address,
latitude: tier.latitude,
longitude: tier.longitude,
}
tierCache.set(key, info)
return info
}
catch {
return null
}
}
function recenterOnFirstStop(): void {
const located = stops.value.find(s => s.latitude != null && s.longitude != null)
if (located) {
mapCenter.value = [located.latitude as number, located.longitude as number]
}
}
// =============================================================================
// Ajout d'etapes (carte)
// =============================================================================
async function addTier(tier: VisitableTier): Promise<void> {
// Pre-alimente le cache (la carte connait deja nom/adresse/coords du pin).
tierCache.set(`${tier.tierType}-${tier.addressId}`, {
label: tier.displayName,
displayAddress: tier.address,
latitude: tier.latitude,
longitude: tier.longitude,
})
await postStop({
tierType: tier.tierType,
tierId: tier.tierId,
addressId: tier.addressId,
position: stops.value.length,
})
}
async function addTiers(tiers: VisitableTier[]): Promise<void> {
if (busy.value) {
return
}
busy.value = true
try {
let position = stops.value.length
for (const tier of tiers) {
tierCache.set(`${tier.tierType}-${tier.addressId}`, {
label: tier.displayName,
displayAddress: tier.address,
latitude: tier.latitude,
longitude: tier.longitude,
})
await api.post('/tours/' + tourId.value + '/stops', {
tierType: tier.tierType,
tierId: tier.tierId,
addressId: tier.addressId,
position: position++,
}, { toast: false })
}
await runCompute()
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
}
finally {
busy.value = false
}
}
/** POST d'une etape puis recompute (factorise add tier / custom). */
async function postStop(payload: Record<string, unknown>): Promise<void> {
if (busy.value) {
return
}
busy.value = true
try {
await api.post('/tours/' + tourId.value + '/stops', payload, { toast: false })
await runCompute()
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
}
finally {
busy.value = false
}
}
async function confirmCustom(): Promise<void> {
customErrors.label = customForm.label.trim() === '' ? t('field_sales.plan.custom.label') : ''
customErrors.address = customForm.address.trim() === '' ? t('field_sales.plan.custom.address') : ''
if (customErrors.label || customErrors.address) {
return
}
customGeocodeFailed.value = false
// Geocodage de l'adresse libre (BAN). Echec -> point non geolocalise (exclu
// du calcul, RG-6.05) mais l'etape est tout de meme creee.
let coords: { latitude: string, longitude: string } | null = null
try {
coords = await autocomplete.geocode(customForm.address.trim())
}
catch {
coords = null
}
if (coords === null) {
customGeocodeFailed.value = true
}
await postStop({
tierType: 'custom',
customLabel: customForm.label.trim(),
customAddress: customForm.address.trim(),
customLatitude: coords?.latitude ?? null,
customLongitude: coords?.longitude ?? null,
position: stops.value.length,
})
if (!customGeocodeFailed.value) {
customOpen.value = false
customForm.label = ''
customForm.address = ''
}
}
// =============================================================================
// Reordonnancement / suppression / navigation
// =============================================================================
async function onReorder(next: PlanningStop[]): Promise<void> {
if (busy.value) {
return
}
// Optimisme : on reflete l'ordre immediatement, le serveur recalcule ensuite.
stops.value = next.map((s, i) => ({ ...s, position: i }))
busy.value = true
try {
const raw = await api.post<Tour>('/tours/' + tourId.value + '/reorder', {
stopIds: next.map(s => s.id),
}, { headers: { Accept: 'application/ld+json' }, toast: false })
await applyTour(raw, true)
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
await loadTour()
}
finally {
busy.value = false
}
}
async function removeStop(stop: PlanningStop): Promise<void> {
if (busy.value) {
return
}
busy.value = true
try {
await api.delete(`/tour_stops/${stop.id}`, {}, { toast: false })
await runCompute()
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
}
finally {
busy.value = false
}
}
function viewTier(stop: PlanningStop): void {
if (stop.tierId === null) {
return
}
router.push(stop.tierType === 'supplier' ? `/suppliers/${stop.tierId}` : `/clients/${stop.tierId}`)
}
// =============================================================================
// Actions tournee : compute / optimize / duplicate / pdf
// =============================================================================
async function runCompute(): Promise<void> {
await runTourAction('/compute', 'computeError')
}
async function runOptimize(): Promise<void> {
await runTourAction('/optimize', 'optimizeError')
}
/** Factorise compute/optimize : POST sans corps -> reapplique la tournee. */
async function runTourAction(path: string, errorKey: string): Promise<void> {
const wasBusy = busy.value
busy.value = true
try {
const raw = await api.post<Tour>('/tours/' + tourId.value + path, {}, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
await applyTour(raw, true)
}
catch {
toast.error({ title: t('errors.title'), message: t(`field_sales.plan.toast.${errorKey}`) })
}
finally {
busy.value = wasBusy ? busy.value : false
}
}
async function confirmDuplicate(): Promise<void> {
duplicateError.value = ''
if (duplicateDate.value === null || duplicateDate.value === '') {
duplicateError.value = t('field_sales.plan.duplicateModal.date')
return
}
busy.value = true
try {
const copy = await api.post<Tour>('/tours/' + tourId.value + '/duplicate', {
tourDate: duplicateDate.value,
}, { headers: { Accept: 'application/ld+json' }, toast: false })
toast.success({ title: t('field_sales.tours.title'), message: t('field_sales.plan.toast.duplicated') })
duplicateOpen.value = false
router.push(`/tours/${copy.id}/plan`)
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.duplicateError') })
}
finally {
busy.value = false
}
}
/** Ouvre la feuille de route PDF (le cookie JWT est envoye avec la requete). */
function openPdf(): void {
window.open(`/api/tours/${tourId.value}/roadbook.pdf`, '_blank')
}
// =============================================================================
// Sauvegarde des parametres du panneau (PATCH, recompute si ETA impactee)
// =============================================================================
async function saveLabel(): Promise<void> {
if (panel.label.trim() !== '' && panel.label !== tour.value?.label) {
await patchTour({ label: panel.label.trim() }, false)
}
}
async function saveDate(): Promise<void> {
if (panel.tourDate) {
await patchTour({ tourDate: panel.tourDate }, false)
}
}
async function saveDepartureTime(): Promise<void> {
if (panel.departureTime) {
await patchTour({ departureTime: panel.departureTime }, true)
}
}
async function saveVisitMinutes(): Promise<void> {
const minutes = Number(panel.defaultVisitMinutes)
if (!Number.isFinite(minutes) || minutes < 0) {
return
}
await patchTour({ defaultVisitMinutes: minutes }, true)
}
/** Geocode le point de depart (ou le vide) puis recompute. */
async function saveStart(): Promise<void> {
const label = panel.startLabel.trim()
if (label === '' && (tour.value?.startLabel ?? '') === '') {
return
}
let coords: { latitude: string, longitude: string } | null = null
if (label !== '') {
try {
coords = await autocomplete.geocode(label)
}
catch {
coords = null
}
}
await patchTour({
startLabel: label === '' ? null : label,
startLatitude: coords?.latitude ?? null,
startLongitude: coords?.longitude ?? null,
}, true)
}
/** PATCH /tours/{id}. `recompute` enchaine /compute (ETA impactee). */
async function patchTour(partial: Record<string, unknown>, recompute: boolean): Promise<void> {
busy.value = true
try {
const raw = await api.patch<Tour>(`/tours/${tourId.value}`, partial, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
// PATCH renvoie tour:read SANS etapes : on ne touche pas a `stops`.
await applyTour(raw, false)
if (recompute) {
await runCompute()
}
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.saveError') })
}
finally {
busy.value = false
}
}
// =============================================================================
// Utilitaires
// =============================================================================
function extractTime(iso: string | null): string | null {
const formatted = formatTime(iso)
return formatted === '—' ? null : formatted
}
function goBack(): void {
router.push('/tours')
}
</script>
@@ -0,0 +1,124 @@
<template>
<div>
<PageHeader>
{{ t('field_sales.tours.title') }}
<template #actions>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('field_sales.tours.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</template>
</PageHeader>
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
:empty-message="t('field_sales.tours.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<template #cell-tourDate="{ item }">
{{ formatDate(item.tourDate as string) }}
</template>
<template #cell-status="{ item }">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium" :class="statusClass(item.status as TourStatus)">
{{ t(`field_sales.tours.status.${item.status}`) }}
</span>
</template>
<template #cell-distance="{ item }">
{{ formatDistance((item.totalDistanceM as number | null) ?? null) }}
</template>
<template #cell-duration="{ item }">
{{ formatDuration((item.totalDurationS as number | null) ?? null) }}
</template>
</MalioDataTable>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useToursRepository } from '~/modules/field-sales/composables/useToursRepository'
import { formatDistance, formatDuration } from '~/modules/field-sales/composables/useTourPlanning'
import type { Tour, TourStatus } from '~/modules/field-sales/types/tour'
const { t } = useI18n()
const router = useRouter()
const { can } = usePermissions()
useHead({ title: t('field_sales.tours.title') })
const canManage = computed(() => can('field_sales.tours.manage'))
const {
items: tours,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadTours,
goToPage,
setItemsPerPage,
} = useToursRepository()
const rows = computed(() => tours.value.map(tour => ({
id: tour.id,
label: tour.label,
tourDate: tour.tourDate,
status: tour.status,
totalDistanceM: tour.totalDistanceM,
totalDurationS: tour.totalDurationS,
})))
const columns = [
{ key: 'label', label: t('field_sales.tours.column.label') },
{ key: 'tourDate', label: t('field_sales.tours.column.date') },
{ key: 'status', label: t('field_sales.tours.column.status') },
{ key: 'distance', label: t('field_sales.tours.column.distance') },
{ key: 'duration', label: t('field_sales.tours.column.duration') },
]
/** Couleur du badge de statut. */
function statusClass(status: TourStatus): string {
switch (status) {
case 'planned':
return 'bg-blue-100 text-blue-800'
case 'in_progress':
return 'bg-amber-100 text-amber-800'
case 'done':
return 'bg-green-100 text-green-800'
default:
return 'bg-gray-100 text-gray-700'
}
}
/** Date courte FR (la date arrive en ISO depuis l'API). */
function formatDate(iso: string): string {
if (!iso) {
return ''
}
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString('fr-FR')
}
/** Clic ligne → ecran de planification de la tournee. */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/tours/${(item as { id: Tour['id'] }).id}/plan`)
}
function goToCreate(): void {
router.push('/tours/new')
}
onMounted(loadTours)
</script>
@@ -0,0 +1,96 @@
<template>
<div>
<PageHeader>
{{ t('field_sales.tours.new.title') }}
</PageHeader>
<div class="mx-auto max-w-xl">
<form class="flex flex-col gap-4" @submit.prevent="submit">
<MalioInputText
v-model="form.label"
:label="t('field_sales.tours.new.label')"
required
:error="errors.label"
/>
<MalioDate
v-model="form.tourDate"
:label="t('field_sales.tours.new.date')"
required
:error="errors.tourDate"
/>
<div class="mt-2 flex justify-end gap-3">
<MalioButton
variant="secondary"
:label="t('field_sales.tours.new.cancel')"
type="button"
@click="cancel"
/>
<MalioButton
variant="primary"
:label="t('field_sales.tours.new.create')"
type="submit"
:disabled="submitting"
/>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import type { Tour } from '~/modules/field-sales/types/tour'
/**
* Creation d'une tournee (draft). Formulaire minimal (nom + date) : le reste de
* la planification (etapes, point de depart, heure) se fait sur l'ecran de
* planification une fois la tournee creee. Validation inline 422 via useFormErrors.
*/
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const { can } = usePermissions()
const { errors, clearErrors, handleApiError } = useFormErrors()
useHead({ title: t('field_sales.tours.new.title') })
// Garde-fou : sans manage, on renvoie vers la liste (le back refuse de toute facon).
if (!can('field_sales.tours.manage')) {
router.replace('/tours')
}
const form = reactive<{ label: string, tourDate: string | null }>({
label: '',
tourDate: null,
})
const submitting = ref(false)
async function submit(): Promise<void> {
if (submitting.value) {
return
}
clearErrors()
submitting.value = true
try {
const tour = await api.post<Tour>('/tours', {
label: form.label,
tourDate: form.tourDate,
}, { toast: false })
// Enchaine directement sur la planification de la tournee creee.
router.push(`/tours/${tour.id}/plan`)
}
catch (e) {
handleApiError(e, { fallbackMessage: t('field_sales.tours.new.error') })
}
finally {
submitting.value = false
}
}
function cancel(): void {
router.push('/tours')
}
</script>
@@ -0,0 +1,84 @@
/**
* Types du module « Tournées » (field_sales, M6.5).
*
* Reflet des DTO exposes par l'API (groupes `tour:read` / `tour_stop:read` /
* VisitableTier). Les dates/heures arrivent en chaines ISO 8601 ; le formatage
* d'affichage (HH:MM, jj/mm/aaaa) est fait dans les ecrans.
*/
/** Type de Tiers visitable cote front (aligne sur l'enum ouvert du back). */
export type TierType = 'client' | 'supplier' | 'custom'
/** Cycle de vie d'une tournee (RG-6.02). */
export type TourStatus = 'draft' | 'planned' | 'in_progress' | 'done'
/** Une etape de tournee (tour_stop:read). */
export interface TourStop {
id: number
tierType: TierType
tierId: number | null
addressId: number | null
customLabel: string | null
customAddress: string | null
customLatitude: string | null
customLongitude: string | null
position: number
visitMinutes: number | null
/** Distance depuis l'etape precedente (m), calculee (compute). */
legDistanceM: number | null
/** Duree de trajet depuis l'etape precedente (s), calculee. */
legDurationS: number | null
/** Heure d'arrivee estimee (ISO time), calculee (RG-6.11). */
eta: string | null
}
/** Une tournee (tour:read + stops embarquees en tour:item:read). */
export interface Tour {
id: number
label: string
/** Date de realisation (ISO date). */
tourDate: string
/** Heure de depart (ISO time). */
departureTime: string
startLatitude: string | null
startLongitude: string | null
startLabel: string | null
defaultVisitMinutes: number
status: TourStatus
totalDistanceM: number | null
totalDurationS: number | null
stops?: TourStop[]
}
/** Un pin de la carte = une adresse geolocalisee d'un Tiers (VisitableTier). */
export interface VisitableTier {
id: string
tierType: Exclude<TierType, 'custom'>
tierId: number
addressId: number
displayName: string
address: string
latitude: number
longitude: number
}
/** Liens d'ouverture de navigation externe (« Y aller »). */
export interface NavigationLinks {
waze: string
google: string
apple: string
}
/** Totaux d'une tournee recalcules cote front (feedback instantane). */
export interface TourTotals {
/** Distance cumulee des trajets (m). */
totalDistanceM: number
/** Duree totale = trajets + visites (s). */
totalDurationS: number
/** Duree de trajet seule (s). */
travelDurationS: number
/** Duree de visite cumulee (s). */
visitDurationS: number
/** Nombre de visites (etapes). */
visitCount: number
}
+76 -23
View File
@@ -12,11 +12,14 @@
"@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",
"vue": "^3.5.29",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@nuxt/eslint-config": "^1.9.0",
@@ -85,6 +88,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 +586,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 +1286,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 +2205,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 +2308,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 +4624,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 +4873,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 +4979,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 +5006,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 +5021,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 +5131,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 +5180,7 @@
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.19.0"
}
@@ -5236,6 +5243,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 +6023,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 +6267,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 +6655,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 +6853,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -6956,6 +6968,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 +7163,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 +8217,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 +9376,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 +10548,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 +11829,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 +12888,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 +12946,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 +13213,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 +13339,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13856,6 +13883,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 +14674,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"
},
@@ -15038,6 +15067,12 @@
"node": ">=20.0.0"
}
},
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -15549,6 +15584,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 +16264,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -16494,6 +16531,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 +17450,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 +17495,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 +17532,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",
@@ -17535,6 +17576,18 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+4 -1
View File
@@ -22,11 +22,14 @@
"@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",
"vue": "^3.5.29",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@nuxt/eslint-config": "^1.9.0",
@@ -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),
}
},
}
}
+6
View File
@@ -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'],
},
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-116 Referentiel Pays (Country), 1re iteration : creation de la table
* `country` + seed des 7 pays (France, Allemagne, Belgique, Espagne, Italie,
* Royaume-Uni, Suisse). Devient la source unique du select pays, en
* remplacement de la liste codee en dur cote front.
*
* Perimetre minimal voulu : code ISO 3166-1 alpha-2 + libelle FR + ordre
* d'affichage UNIQUEMENT. Aucune longueur bancaire/fiscale (numero de compte,
* IBAN, TVA, BIC, SIREN) a ce stade iteration ulterieure du meme ticket.
*
* Pas de FK posee sur les adresses (client_address.country / supplier_address)
* a cette etape : ces colonnes restent des chaines libres (« France »...), donc
* aucune migration de donnees ni rupture de l'existant.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) comme les
* migrations M1/M2 du module Commercial : pas de migrations_path modulaire
* configure pour Commercial, et le tri par timestamp reste garanti.
*
* Seed idempotent `ON CONFLICT (code) DO NOTHING` : la table peut deja porter
* des donnees en prod lors d'un rejeu. Chaque colonne porte un `COMMENT ON
* COLUMN` (regle ABSOLUE n°12, garde-fou ColumnsHaveSqlCommentTest) ; la table
* est aussi mirroree dans ColumnCommentsCatalog pour survivre au
* `schema:update --force` du setup de test.
*/
final class Version20260609100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-116 : table country (referentiel pays) + seed des 7 pays.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE country (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(2) NOT NULL,
name VARCHAR(80) NOT NULL,
position INT DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_country_code ON country (code)');
$this->comment('country', '_table', 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).');
$this->comment('country', 'id', 'Identifiant interne auto-incremente.');
$this->comment('country', 'code', 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.');
$this->comment('country', 'name', 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).');
$this->comment('country', 'position', 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).');
// Seed initial. France en tete (position 10) puis ordre alphabetique.
// Table fraichement creee, mais ON CONFLICT pour rejouabilite en prod.
$this->addSql(<<<'SQL'
INSERT INTO country (code, name, position) VALUES
('FR', 'France', 10),
('DE', 'Allemagne', 20),
('BE', 'Belgique', 30),
('ES', 'Espagne', 40),
('IT', 'Italie', 50),
('GB', 'Royaume-Uni', 60),
('CH', 'Suisse', 70)
ON CONFLICT (code) DO NOTHING
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE country');
}
/**
* Pose un `COMMENT ON TABLE` (colonne speciale `_table`) ou
* `COMMENT ON COLUMN`. Quoting defensif des identifiants + delimiteur $_$
* pour ne pas casser sur les apostrophes des descriptions.
*/
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,
));
}
}
+73
View File
@@ -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,
));
}
}
+218
View File
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M6.3 (ERP-124) Tournees commerciales terrain : creation des tables `tour`
* (tournee) et `tour_stop` (etape) du module FieldSales.
*
* SCOPE REDUIT (V0.2) : pas de rapport de visite -> `tour_stop` SANS report_id ni
* arrived_at / check-in.
*
* Particularites de modelisation :
* - tour.owner_id : FK -> "user".id, ON DELETE RESTRICT (tournee personnelle,
* RG-6.01 ; un user proprietaire d'une tournee ne peut etre supprime).
* - tour_stop.tier_id / address_id : entiers SANS FK. La cible d'une etape est
* polymorphe (Client M1 / Fournisseur M2 / point custom) resolue via
* tier_type ; aucune FK unique possible (RG-6.07 : pas d'unicite sur tier_id).
* - Unicite (tour_id, position) : un seul ordre par tournee (uq_tour_stop_position).
* - tour_stop.tour_id : FK -> tour.id, ON DELETE CASCADE.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
* modulaire : la migration cree des FK cross-module (vers "user"). Avec plusieurs
* migrations_paths, Doctrine Migrations 3.x trie par FQCN alphabetique un
* namespace modulaire s'executerait avant la creation de "user" sur base vide.
* Le namespace racine garantit l'ordre par timestamp.
*
* Style DDL aligne sur M1/M2 (INT GENERATED BY DEFAULT AS IDENTITY,
* TIMESTAMP(0) WITHOUT TIME ZONE car le trait T/B mappe datetime_immutable),
* pour que `schema:update` reste un no-op. Chaque colonne porte son
* `COMMENT ON COLUMN` (regle ABSOLUE n°12) ; les 4 colonnes T/B via le catalogue
* partage. Les tables sont egalement mirorees dans ColumnCommentsCatalog pour
* que `app:apply-column-comments` rejoue les COMMENT apres le schema:update du
* setup de test (qui les drope sur les tables mappees par l'ORM).
*/
final class Version20260611140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-124 (M6.3) : tables tour + tour_stop (module FieldSales), sans rapport de visite (scope reduit V0.2).';
}
public function up(Schema $schema): void
{
$this->createTourTable();
$this->createTourStopTable();
}
public function down(Schema $schema): void
{
// Ordre inverse des dependances FK : tour_stop (FK -> tour) puis tour.
$this->addSql('DROP TABLE IF EXISTS tour_stop');
$this->addSql('DROP TABLE IF EXISTS tour');
}
// =================================================================
// Table `tour`
// =================================================================
private function createTourTable(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE tour (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
owner_id INT NOT NULL,
label VARCHAR(120) NOT NULL,
tour_date DATE NOT NULL,
departure_time TIME(0) WITHOUT TIME ZONE NOT NULL,
start_latitude NUMERIC(10, 7) DEFAULT NULL,
start_longitude NUMERIC(10, 7) DEFAULT NULL,
start_label VARCHAR(180) DEFAULT NULL,
default_visit_minutes SMALLINT DEFAULT 30 NOT NULL,
status VARCHAR(20) DEFAULT 'draft' NOT NULL,
total_distance_m INT DEFAULT NULL,
total_duration_s INT DEFAULT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_tour_owner
FOREIGN KEY (owner_id) REFERENCES "user" (id) ON DELETE RESTRICT,
CONSTRAINT fk_tour_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_tour_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_tour_owner ON tour (owner_id)');
$this->addSql('CREATE INDEX idx_tour_status ON tour (status)');
$this->addSql('CREATE INDEX idx_tour_deleted_at ON tour (deleted_at)');
$this->addSql('CREATE INDEX idx_tour_created_by ON tour (created_by)');
$this->addSql('CREATE INDEX idx_tour_updated_by ON tour (updated_by)');
$this->comment('tour', '_table', 'Tournees commerciales terrain (M6 FieldSales) — personnelles (owner), soft-deletables (deleted_at).');
$this->comment('tour', 'id', 'Identifiant interne auto-incremente.');
$this->comment('tour', 'owner_id', 'Commercial proprietaire de la tournee (RG-6.01, personnelle) — FK -> "user".id, ON DELETE RESTRICT. Pose au POST par le TourProcessor.');
$this->comment('tour', 'label', 'Nom libre de la tournee (NotBlank, <= 120 caracteres).');
$this->comment('tour', 'tour_date', 'Date de realisation de la tournee (NotNull).');
$this->comment('tour', 'departure_time', 'Heure de depart, alimente les ETA (RG-6.11). Defaut applicatif 08:00 (constructeur).');
$this->comment('tour', 'start_latitude', 'Latitude WGS84 du point de depart (site commercial ou adresse libre). NULL -> depart = 1re etape.');
$this->comment('tour', 'start_longitude', 'Longitude WGS84 du point de depart. NULL -> depart = 1re etape.');
$this->comment('tour', 'start_label', 'Libelle affichable du point de depart (<= 180 caracteres). Optionnel.');
$this->comment('tour', 'default_visit_minutes', 'Duree de visite par defaut d une etape, en minutes (defaut 30) — utilisee si l etape ne fixe pas sa propre duree.');
$this->comment('tour', 'status', 'Cycle de vie (RG-6.02) : draft | planned | in_progress | done (enum TourStatus). Transitions libres en V1. Defaut draft.');
$this->comment('tour', 'total_distance_m', 'Cache d affichage : derniere distance totale calculee, en metres (RG-6.11). Lecture seule API, alimente par le moteur de trajet (M6.4).');
$this->comment('tour', 'total_duration_s', 'Cache d affichage : derniere duree totale calculee, en secondes (RG-6.11). Lecture seule API.');
$this->comment('tour', 'deleted_at', 'Horodatage du soft-delete — pose par le DELETE API. Null = tournee active.');
$this->addTimestampableBlamableComments('tour');
}
// =================================================================
// Table `tour_stop`
// =================================================================
private function createTourStopTable(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE tour_stop (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tour_id INT NOT NULL,
tier_type VARCHAR(30) NOT NULL,
tier_id INT DEFAULT NULL,
address_id INT DEFAULT NULL,
custom_label VARCHAR(180) DEFAULT NULL,
custom_address VARCHAR(255) DEFAULT NULL,
custom_latitude NUMERIC(10, 7) DEFAULT NULL,
custom_longitude NUMERIC(10, 7) DEFAULT NULL,
position SMALLINT NOT NULL,
visit_minutes SMALLINT DEFAULT NULL,
leg_distance_m INT DEFAULT NULL,
leg_duration_s INT DEFAULT NULL,
eta TIME(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_tour_stop_tour
FOREIGN KEY (tour_id) REFERENCES tour (id) ON DELETE CASCADE,
CONSTRAINT fk_tour_stop_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_tour_stop_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_tour_stop_tour ON tour_stop (tour_id)');
$this->addSql('CREATE INDEX idx_tour_stop_created_by ON tour_stop (created_by)');
$this->addSql('CREATE INDEX idx_tour_stop_updated_by ON tour_stop (updated_by)');
// RG-6.07 : pas d unicite sur tier_id (deux etapes peuvent viser le meme
// Tiers). Unicite uniquement sur l ordre dans la tournee.
$this->addSql('CREATE UNIQUE INDEX uq_tour_stop_position ON tour_stop (tour_id, position)');
$this->comment('tour_stop', '_table', 'Etapes ordonnees d une tournee (M6) — cible polymorphe (Tiers referentiel ou point custom). Pas de rapport (scope reduit V0.2).');
$this->comment('tour_stop', 'id', 'Identifiant interne auto-incremente.');
$this->comment('tour_stop', 'tour_id', 'FK -> tour.id, ON DELETE CASCADE — tournee proprietaire de l etape.');
$this->comment('tour_stop', 'tier_type', 'Type de cible : client | supplier | ... | custom (point libre). Resolu via VisitableInterface. Chaine ouverte (Assert\\Choice).');
$this->comment('tour_stop', 'tier_id', 'Identifiant du Tiers referentiel cible (NULL si custom). Sans FK (cible polymorphe). RG-6.07 : aucune unicite.');
$this->comment('tour_stop', 'address_id', 'Adresse precise visitee chez le Tiers (NULL si custom). Sans FK (client_address OU supplier_address). RG-6.03 : doit appartenir au Tiers.');
$this->comment('tour_stop', 'custom_label', 'Libelle du point libre — obligatoire ssi tier_type = custom (RG-6.12), sinon NULL.');
$this->comment('tour_stop', 'custom_address', 'Adresse texte du point libre (geocodee) — renseignee uniquement si custom.');
$this->comment('tour_stop', 'custom_latitude', 'Latitude WGS84 du point libre (pin ajustable) — obligatoire ssi custom (RG-6.12).');
$this->comment('tour_stop', 'custom_longitude', 'Longitude WGS84 du point libre — obligatoire ssi custom (RG-6.12).');
$this->comment('tour_stop', 'position', 'Ordre de l etape dans la tournee (drag & drop). Unique par tournee (uq_tour_stop_position).');
$this->comment('tour_stop', 'visit_minutes', 'Duree de visite specifique a l etape, en minutes — sinon tour.default_visit_minutes.');
$this->comment('tour_stop', 'leg_distance_m', 'Cache : distance depuis l etape precedente, en metres (calcule). Lecture seule API (M6.4).');
$this->comment('tour_stop', 'leg_duration_s', 'Cache : temps depuis l etape precedente, en secondes (calcule). Lecture seule API (M6.4).');
$this->comment('tour_stop', 'eta', 'Heure d arrivee estimee a l etape (RG-6.11, calculee). Lecture seule API.');
$this->addTimestampableBlamableComments('tour_stop');
}
// =================================================================
// Helpers
// =================================================================
/**
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
* en reutilisant le catalogue partage (source unique).
*/
private function addTimestampableBlamableComments(string $table): void
{
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
$this->comment($table, $column, $description);
}
}
/**
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
* tout echappement d apostrophe.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Supplier;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier (spec-front M2 § Onglet Comptabilite) : a la soumission
* complete de l'onglet Comptabilite, les six champs scalaires obligatoires
* doivent etre renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai
* de reglement, Type de reglement). La banque reste conditionnelle (RG-2.07) et
* les RIB aussi (RG-2.08) : ils ne sont pas couverts ici (Assert\Callback sur
* l'entite Supplier validatePaymentTypeConsistency).
*
* Parti pris (miroir ClientAccountingCompletenessValidator M1) : colonnes nullable
* en base + validateur contextuel, plutot qu'un Assert\NotBlank sur l'entite (qui
* casserait le POST de l'onglet principal, lequel n'envoie aucun champ comptable).
*
* Invoque par le SupplierProcessor uniquement quand le payload porte les six
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform (mapping inline
* front via useFormErrors, ERP-101).
*/
final class SupplierAccountingCompletenessValidator
{
public function validate(Supplier $supplier): void
{
// Map champ -> valeur courante des champs obligatoires de l'onglet.
$fields = [
'siren' => $supplier->getSiren(),
'accountNumber' => $supplier->getAccountNumber(),
'tvaMode' => $supplier->getTvaMode(),
'nTva' => $supplier->getNTva(),
'paymentDelay' => $supplier->getPaymentDelay(),
'paymentType' => $supplier->getPaymentType(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
'Ce champ est obligatoire.',
null,
[],
$supplier,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
* lorsqu'elles valent null.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Supplier;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-2.03 (completude Information cote fournisseur) :
* pour un utilisateur portant le role metier Commerciale, TOUS les champs de
* l'onglet Information sont obligatoires sur POST comme sur tout PATCH,
* independamment des champs reellement envoyes.
*
* Invoque par le SupplierProcessor des que l'utilisateur courant porte le role
* Commerciale (detection du role cote back). Pour les autres roles, ces champs
* restent optionnels le validator n'est pas appele.
*
* NEW vs Client : ajoute le champ `volumeForecast` (volume previsionnel),
* specifique fournisseur.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, chaque
* violation portant son propertyPath (consommable par extractApiViolations,
* ERP-101), par coherence avec les violations Symfony rendues par API Platform.
*/
final class SupplierInformationCompletenessValidator
{
public function validate(Supplier $supplier): void
{
// Map champ -> valeur courante de l'onglet Information.
$fields = [
'description' => $supplier->getDescription(),
'competitors' => $supplier->getCompetitors(),
'foundedAt' => $supplier->getFoundedAt(),
'employeesCount' => $supplier->getEmployeesCount(),
'revenueAmount' => $supplier->getRevenueAmount(),
'directorName' => $supplier->getDirectorName(),
'profitAmount' => $supplier->getProfitAmount(),
'volumeForecast' => $supplier->getVolumeForecast(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
// Pas de nom de champ technique dans le message : la violation est
// deja rattachee au bon champ via son propertyPath (mappe inline
// cote front par useFormErrors).
'Ce champ est obligatoire pour le rôle Commerciale.',
null,
[],
$supplier,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* zeros numeriques (employeesCount = 0, profitAmount = "0.00",
* volumeForecast = 0) sont des valeurs valides : on ne les considere pas
* manquants.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
+20 -1
View File
@@ -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
{
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineCountryRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Pays selectionnable dans les adresses (clients / fournisseurs) : referentiel
* statique seede par la migration (France, Allemagne, Belgique, Espagne, Italie,
* Royaume-Uni). Remplace la liste de pays jusqu'ici codee en dur cote front.
*
* Perimetre minimal (ticket ERP-116, 1re iteration) : code ISO + libelle + ordre
* d'affichage uniquement. AUCUNE longueur bancaire/fiscale (numero de compte,
* IBAN, TVA, BIC, SIREN) a ce stade ces colonnes feront l'objet d'une iteration
* ulterieure du meme ticket.
*
* Lecture seule : GetCollection + Get uniquement ; POST/PATCH/DELETE -> 405.
* Permission alignee sur Bank (referentiel d'adresse partage clients/fournisseurs).
* Pas de Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme Bank).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['country:read']],
// Tri par defaut : position ASC (France en tete) puis name ASC.
order: ['position' => 'ASC', 'name' => 'ASC'],
// Toggle ?pagination=false pour alimenter le select (cf. Bank).
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['country:read']],
),
],
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineCountryRepository::class)]
#[ORM\Table(name: 'country')]
#[ORM\UniqueConstraint(name: 'uq_country_code', columns: ['code'])]
class Country
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['country:read'])]
private ?int $id = null;
#[ORM\Column(length: 2)]
#[Groups(['country:read'])]
private ?string $code = null;
#[ORM\Column(length: 80)]
#[Groups(['country:read'])]
private ?string $name = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['country:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -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;
@@ -328,8 +329,11 @@ class Supplier implements TimestampableInterface, BlamableInterface
* chaque 422 porte un propertyPath exploitable par extractApiViolations
* (mapping inline sous le champ, pas un toast convention ERP-101).
* - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs`
* (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88).
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur
* `paymentType` (miroir client : `ribs` n'a pas de champ de formulaire ou
* s'ancrer quand la liste est vide ; l'erreur s'affiche donc sous le select
* « Type de règlement », bindé cote front). Le 409 sur DELETE du dernier RIB
* en LCR est porte par ERP-88.
*
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
* n'expose que supplier:write:main), la contrainte ne mord en pratique que
@@ -349,7 +353,7 @@ class Supplier implements TimestampableInterface, BlamableInterface
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
->atPath('ribs')
->atPath('paymentType')
->addViolation()
;
}
@@ -372,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)]
@@ -199,12 +230,14 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private Collection $contacts;
// RG-2.10 : categories d'adresse de type FOURNISSEUR (controle au Processor).
// RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'supplier_address_category')]
#[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private Collection $categories;
@@ -370,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
{
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\Country;
interface CountryRepositoryInterface
{
public function findById(int $id): ?Country;
/**
* Retourne tous les pays tries position ASC puis name ASC.
*
* @return list<Country>
*/
public function findAllOrdered(): array;
}
@@ -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);
}
@@ -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);
}
@@ -7,10 +7,8 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Application\Validator\SupplierAccountingCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
@@ -43,19 +41,17 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
* collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de
* restauration).
*
* Validators metier (ERP-89). Decision figee : ce processor ne porte QUE
* RG-2.03 (completude Information exigee pour le role Commerciale detection du
* role cote back, non exprimable en contrainte d'entite). Les RG inter-champs
* RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie
* de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur
* l'entite Supplier (jouees par API Platform AVANT ce processor), pour que
* chaque 422 porte un propertyPath consommable par extractApiViolations
* (mapping inline, pas un toast convention ERP-101).
* Validators metier (ERP-89). Ce processor porte la completude Comptabilite : a
* la validation complete de l'onglet (les six scalaires obligatoires presents
* dans le payload), chacun doit etre renseigne. (RG-2.03 « Information obligatoire
* pour la Commerciale » a ete retiree, miroir client M1 l'onglet Information est
* desormais entierement facultatif, quel que soit le role.)
*
* Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories,
* les Callback RG-2.07/2.08/2.10...) est jouee par API Platform AVANT ce
* processor ; on n'y traite donc que les regles non exprimables en simples
* contraintes d'entite (RG-2.03, qui depend du role de l'utilisateur courant).
* Les RG inter-champs RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et
* RG-2.10 (categorie de type FOURNISSEUR) sont portees par des Assert\Callback +
* ->atPath() sur l'entite Supplier (jouees par API Platform AVANT ce processor),
* pour que chaque 422 porte un propertyPath consommable par extractApiViolations
* (mapping inline, pas un toast convention ERP-101).
*
* @implements ProcessorInterface<Supplier, Supplier>
*/
@@ -78,6 +74,14 @@ final class SupplierProcessor implements ProcessorInterface
'paymentType', 'bank',
];
/**
* Champs comptables obligatoires a la validation complete de l'onglet
* (spec-front M2 § Onglet Comptabilite). bank est exclu : conditionnel (RG-2.07).
*/
private const array ACCOUNTING_REQUIRED_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
];
/** Champ d'archivage (groupe supplier:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
@@ -102,7 +106,7 @@ final class SupplierProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly SupplierFieldNormalizer $normalizer,
private readonly SupplierInformationCompletenessValidator $informationValidator,
private readonly SupplierAccountingCompletenessValidator $accountingValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
@@ -132,7 +136,7 @@ final class SupplierProcessor implements ProcessorInterface
// normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
$this->validateInformationCompleteness($data);
$this->validateAccountingCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
@@ -262,35 +266,23 @@ final class SupplierProcessor implements ProcessorInterface
}
/**
* RG-2.03 : si l'utilisateur porte le role metier Commerciale, TOUS les
* champs de l'onglet Information sont obligatoires sur POST comme sur TOUT
* PATCH independamment des champs reellement envoyes. Garantit qu'un
* fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet
* Information incomplet. Pour les autres roles, ces champs restent optionnels.
*
* Consequence (cf. spec § 7, miroir RG-1.04) : le POST n'exposant que
* supplier:write:main, une Commerciale obtient 422 sur tout POST tant que
* l'Information n'est pas complete -> la completude se fait via les PATCH
* supplier:write:information.
* spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet
* (les six champs obligatoires presents dans le payload le front les envoie
* toujours ensemble), chacun doit etre renseigne, sinon 422 par champ. On ne
* declenche pas sur un PATCH ciblant un sous-ensemble de champs comptables :
* ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank /
* RIB restent geres par validatePaymentTypeConsistency sur l'entite (RG-2.07 /
* RG-2.08). Miroir du ClientProcessor (M1).
*/
private function validateInformationCompleteness(Supplier $data): void
private function validateAccountingCompleteness(Supplier $data): void
{
if ($this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
// Declenche uniquement si TOUS les champs requis sont presents dans le
// payload (= soumission d'onglet, pas un PATCH partiel cible).
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
return;
}
}
/**
* Detection du role metier Commerciale cote back (jamais front), via le
* contrat BusinessRoleAwareInterface (pas d'import de User regle ABSOLUE
* n°1). Identique au ClientProcessor (M1).
*/
private function currentUserIsCommerciale(): bool
{
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
$this->accountingValidator->validate($data);
}
/**
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\DataFixtures;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
@@ -14,10 +15,11 @@ use Doctrine\Persistence\ObjectManager;
/**
* Fixtures du module Commercial : re-seed des 4 referentiels comptables
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
* (Version20260601000000).
* (Version20260601000000) + du referentiel pays (country) seede par la
* migration ERP-116 (Version20260609100000).
*
* Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces
* 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les
* Pourquoi cette fixture EN PLUS du seed de la migration : ces tables sont des
* entites managees par l'ORM, donc le purger Doctrine les
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
* referentiels seedes par la migration disparaitraient apres `make db-reset`
* (0 ligne en dev/test) cassant les FK Client -> referentiels et les tests
@@ -59,15 +61,54 @@ class CommercialReferentialFixtures extends Fixture
],
];
/**
* Referentiel pays (ERP-116) : code ISO alpha-2 => [name, position].
* Doit rester aligne sur le seed de la migration Version20260609100000.
* Traite a part car Country porte `name` (et non `label`).
*
* @var array<string, array{string, int}>
*/
private const COUNTRIES = [
'FR' => ['France', 10],
'DE' => ['Allemagne', 20],
'BE' => ['Belgique', 30],
'ES' => ['Espagne', 40],
'IT' => ['Italie', 50],
'GB' => ['Royaume-Uni', 60],
'CH' => ['Suisse', 70],
];
public function load(ObjectManager $manager): void
{
foreach (self::REFERENTIALS as $entityClass => $rows) {
$this->seedReferential($manager, $entityClass, $rows);
}
$this->seedCountries($manager);
$manager->flush();
}
/**
* Upsert idempotent du referentiel pays (lookup par code). Distinct de
* seedReferential car Country utilise setName au lieu de setLabel.
*/
private function seedCountries(ObjectManager $manager): void
{
$existingByCode = [];
foreach ($manager->getRepository(Country::class)->findAll() as $country) {
$existingByCode[$country->getCode()] = $country;
}
foreach (self::COUNTRIES as $code => [$name, $position]) {
$country = $existingByCode[$code] ?? new Country();
$country->setCode($code);
$country->setName($name);
$country->setPosition($position);
$manager->persist($country);
}
}
/**
* Upsert idempotent d'un referentiel : indexe l'existant par code puis
* cree/met a jour chaque entree. Les 4 entites partagent le meme contrat
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Repository\CountryRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Country>
*/
class DoctrineCountryRepository extends ServiceEntityRepository implements CountryRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Country::class);
}
public function findById(int $id): ?Country
{
return $this->find($id);
}
public function findAllOrdered(): array
{
return $this->createQueryBuilder('c')
->orderBy('c.position', 'ASC')
->addOrderBy('c.name', 'ASC')
->getQuery()
->getResult()
;
}
}
@@ -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,36 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Application\DTO;
/**
* DTO de sortie d'un « Tiers visitable » geolocalise (M6 § 5,
* GET /api/visitable_tiers) : un pin de la carte = une adresse geolocalisee
* d'un Tiers du referentiel (Client M1 / Fournisseur M2, extensible).
*
* Readonly : aucune mutation apres hydration. La resource API Platform expose
* directement ce DTO (pas d'entite ORM lecture DBAL pure du schema partage,
* regle ABSOLUE n°1 : FieldSales n'importe aucune classe de Commercial).
*/
final readonly class VisitableTierOutput
{
public function __construct(
/** Identifiant synthetique stable « {type}-{addressId} » (ex: client-42) pour l'IRI Hydra. */
public string $id,
/** Type de Tiers visitable : client | supplier (extensible). */
public string $tierType,
/** ID du Tiers dans son referentiel (client.id / supplier.id). */
public int $tierId,
/** ID de l'adresse precise geolocalisee (client_address.id / supplier_address.id). */
public int $addressId,
/** Raison sociale du Tiers (libelle du pin). */
public string $displayName,
/** Adresse formatee sur une ligne (rue, CP ville). */
public string $address,
/** Latitude WGS84 du pin. */
public float $latitude,
/** Longitude WGS84 du pin. */
public float $longitude,
) {}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Application\Duplication;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Entity\TourStop;
use App\Module\FieldSales\Domain\Enum\TourStatus;
use DateTimeImmutable;
/**
* Duplication d'une tournee (M6 § 13, RG-6.13). Cree une NOUVELLE tournee `draft`
* a la date fournie, copiant :
* - les parametres de la tournee source (point de depart, heure de depart, duree
* de visite par defaut, libelle) ;
* - chaque etape (cible Tiers/adresse ou point libre, position, duree de visite).
*
* Ne copie PAS les calculs (eta, leg_distance_m, leg_duration_s, totaux) : ils
* seront recalcules par /compute sur la copie. La copie appartient au meme
* proprietaire que la source (tournee personnelle, RG-6.01).
*
* Service pur : il construit et retourne l'entite ; la persistance (persist +
* flush) est a la charge du processor appelant.
*/
final class TourDuplicator
{
public function duplicate(Tour $source, DateTimeImmutable $tourDate): Tour
{
$copy = new Tour();
$copy->setOwner($source->getOwner());
$copy->setLabel($source->getLabel());
$copy->setTourDate($tourDate);
$copy->setDepartureTime($source->getDepartureTime());
$copy->setStartLatitude($source->getStartLatitude());
$copy->setStartLongitude($source->getStartLongitude());
$copy->setStartLabel($source->getStartLabel());
$copy->setDefaultVisitMinutes($source->getDefaultVisitMinutes());
// Toute copie repart en draft, quel que soit l'etat de la source.
$copy->setStatus(TourStatus::Draft->value);
foreach ($source->getStops() as $stop) {
$copy->addStop($this->duplicateStop($stop));
}
return $copy;
}
/**
* Copie d'une etape SANS les champs calcules (eta / legs), conformement a
* RG-6.13 : ils seront regeneres par /compute.
*/
private function duplicateStop(TourStop $source): TourStop
{
$copy = new TourStop();
$copy->setTierType($source->getTierType());
$copy->setTierId($source->getTierId());
$copy->setAddressId($source->getAddressId());
$copy->setCustomLabel($source->getCustomLabel());
$copy->setCustomAddress($source->getCustomAddress());
$copy->setCustomLatitude($source->getCustomLatitude());
$copy->setCustomLongitude($source->getCustomLongitude());
$copy->setPosition($source->getPosition());
$copy->setVisitMinutes($source->getVisitMinutes());
return $copy;
}
}
@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Application\Route;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Entity\TourStop;
use App\Module\FieldSales\Domain\Route\RouteEngineInterface;
use App\Module\FieldSales\Domain\Route\RoutePoint;
use App\Module\FieldSales\Infrastructure\Tier\TierAddressResolver;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
/**
* Orchestration du calcul de trajet d'une tournee (M6 § 3.4, § 5 /compute +
* /optimize). Fait le pont entre les entites (Tour / TourStop) et le moteur
* geometrique {@see RouteEngineInterface}, qui lui ignore tout du metier :
*
* 1. resout les coordonnees de chaque etape (point libre `custom` -> coords
* portees par l'etape ; Tiers referentiel -> coords de l'adresse, ERP-122) ;
* 2. exclut les etapes sans coordonnees (RG-6.05) : leurs legs/eta sont remis a
* null (signalement « a geolocaliser » cote front) ;
* 3. calcule, pour les etapes geolocalisees, les segments (leg_distance_m /
* leg_duration_s) et l'heure d'arrivee estimee (eta, RG-6.11 : depart + Σ
* trajets precedents + Σ durees de visite precedentes) ;
* 4. met a jour les totaux de la tournee (total_distance_m / total_duration_s).
*
* `compute()` respecte l'ordre courant des etapes (position) ; `optimize()`
* reordonne d'abord via le moteur (plus proche voisin) puis recompute.
*/
final class TourRouteCalculator
{
public function __construct(
private readonly RouteEngineInterface $routeEngine,
private readonly TierAddressResolver $tierAddressResolver,
private readonly EntityManagerInterface $em,
) {}
/**
* Recalcule legs + eta + totaux de la tournee, dans l'ordre courant des
* etapes. Mutation en place des entites (le flush est a la charge du
* processor appelant).
*/
public function compute(Tour $tour): void
{
$stops = $this->orderedStops($tour);
// RG-6.05 : on partitionne les etapes geolocalisees (entrent dans le
// calcul) des autres (legs/eta remis a null = « a geolocaliser »).
$routedStops = [];
$points = [];
foreach ($stops as $stop) {
$coords = $this->resolveCoordinates($stop);
if (null === $coords) {
$this->resetStop($stop);
continue;
}
$routedStops[] = $stop;
$points[] = new RoutePoint($stop->getId() ?? spl_object_id($stop), $coords['lat'], $coords['lng']);
}
if ([] === $routedStops) {
$tour->setTotalDistanceM(null);
$tour->setTotalDurationS(null);
return;
}
$start = $this->startPoint($tour);
$legs = $this->routeEngine->estimateLegDurations($start, $points);
$departureSeconds = $this->secondsOfDay($tour->getDepartureTime());
$elapsedSeconds = 0; // secondes ecoulees depuis le depart
$totalDistance = 0;
foreach ($routedStops as $index => $stop) {
$leg = $legs[$index];
// Trajet pour atteindre cette etape puis heure d'arrivee estimee.
$elapsedSeconds += $leg->durationSeconds;
$totalDistance += $leg->distanceMeters;
$stop->setLegDistanceM($leg->distanceMeters);
$stop->setLegDurationS($leg->durationSeconds);
$stop->setEta($tour->getDepartureTime()->setTime(0, 0)->modify(
sprintf('+%d seconds', $departureSeconds + $elapsedSeconds),
));
// La visite a cette etape repousse l'arrivee a l'etape suivante.
$elapsedSeconds += $this->visitSeconds($tour, $stop);
}
$tour->setTotalDistanceM($totalDistance);
// Duree totale = trajets + visites (du depart a la fin de la derniere visite).
$tour->setTotalDurationS($elapsedSeconds);
}
/**
* Reordonne les etapes geolocalisees selon le plus proche voisin
* (RouteEngine::optimizeOrder) puis recompute. Les etapes sans coordonnees
* (RG-6.05) restent rejetees en fin de tournee, ordre relatif preserve.
*
* Persiste les nouvelles positions en DEUX temps pour ne pas heurter l'unique
* (tour_id, position) en cours de flush : d'abord un offset temporaire hors
* plage, puis les positions finales 0..n-1.
*/
public function optimize(Tour $tour): void
{
$stops = $this->orderedStops($tour);
$routedStops = [];
$points = [];
$unroutedStops = [];
$stopByRef = [];
foreach ($stops as $stop) {
$coords = $this->resolveCoordinates($stop);
if (null === $coords) {
$unroutedStops[] = $stop;
continue;
}
$ref = $stop->getId() ?? spl_object_id($stop);
$stopByRef[$ref] = $stop;
$routedStops[] = $stop;
$points[] = new RoutePoint($ref, $coords['lat'], $coords['lng']);
}
// Rien a reordonner (0 ou 1 etape geolocalisee) : on recompute seulement.
if (count($routedStops) > 1) {
$orderedPoints = $this->routeEngine->optimizeOrder($this->startPoint($tour), $points);
// Etapes geolocalisees dans le nouvel ordre, puis les non geolocalisees.
$orderedStops = array_map(static fn (RoutePoint $p) => $stopByRef[$p->ref], $orderedPoints);
$orderedStops = [...$orderedStops, ...$unroutedStops];
$this->reassignPositions($orderedStops);
}
$this->compute($tour);
}
/**
* Reordonne les etapes selon la liste d'ids fournie (drag & drop cote front),
* puis recompute. Les ids inconnus sont ignores ; les etapes absentes de la
* liste sont conservees en fin (ordre courant). Persiste en deux temps
* (reassignPositions) pour ne pas heurter l'unique (tour_id, position).
*
* @param list<int> $orderedStopIds
*/
public function reorder(Tour $tour, array $orderedStopIds): void
{
$stops = $this->orderedStops($tour);
$byId = [];
foreach ($stops as $stop) {
$byId[$stop->getId()] = $stop;
}
$ordered = [];
$seen = [];
foreach ($orderedStopIds as $id) {
if (isset($byId[$id]) && !isset($seen[$id])) {
$ordered[] = $byId[$id];
$seen[$id] = true;
}
}
// Etapes non citees dans la liste : placees en fin, ordre courant preserve.
foreach ($stops as $stop) {
if (!isset($seen[$stop->getId()])) {
$ordered[] = $stop;
}
}
if (count($ordered) > 1) {
$this->reassignPositions($ordered);
}
$this->compute($tour);
}
/**
* Reattribue les positions 0..n-1 dans l'ordre fourni, en deux flushes pour
* eviter toute collision transitoire avec l'unique (tour_id, position).
*
* @param list<TourStop> $orderedStops
*/
private function reassignPositions(array $orderedStops): void
{
// Phase 1 : positions temporaires hors plage (offset > nb d'etapes
// possibles), garanties uniques entre elles.
foreach ($orderedStops as $index => $stop) {
$stop->setPosition(10_000 + $index);
}
$this->em->flush();
// Phase 2 : positions finales contiguës a partir de 0.
foreach ($orderedStops as $index => $stop) {
$stop->setPosition($index);
}
$this->em->flush();
}
/**
* Etapes de la tournee triees par position croissante.
*
* @return list<TourStop>
*/
private function orderedStops(Tour $tour): array
{
$stops = array_values($tour->getStops()->toArray());
usort($stops, static fn (TourStop $a, TourStop $b) => $a->getPosition() <=> $b->getPosition());
return $stops;
}
/**
* Coordonnees d'une etape : point libre -> coords saisies sur l'etape ; Tiers
* referentiel -> coords de l'adresse visee. Null si non geolocalisable.
*
* @return null|array{lat: float, lng: float}
*/
private function resolveCoordinates(TourStop $stop): ?array
{
if (TourStop::TIER_TYPE_CUSTOM === $stop->getTierType()) {
$lat = $stop->getCustomLatitude();
$lng = $stop->getCustomLongitude();
return null === $lat || null === $lng ? null : ['lat' => (float) $lat, 'lng' => (float) $lng];
}
$tierType = $stop->getTierType();
$addressId = $stop->getAddressId();
if (null === $tierType || null === $addressId) {
return null;
}
return $this->tierAddressResolver->findAddressCoordinates($tierType, $addressId);
}
/**
* Point de depart de la tournee : coordonnees explicites (start_*) si les deux
* sont posees, sinon null -> la 1re etape geolocalisee fait office de depart
* (cf. RouteEngine, 1er segment nul).
*/
private function startPoint(Tour $tour): ?RoutePoint
{
$lat = $tour->getStartLatitude();
$lng = $tour->getStartLongitude();
if (null === $lat || null === $lng) {
return null;
}
return new RoutePoint('start', (float) $lat, (float) $lng);
}
/**
* Duree de visite d'une etape en secondes : valeur specifique de l'etape
* sinon la duree par defaut de la tournee.
*/
private function visitSeconds(Tour $tour, TourStop $stop): int
{
return ($stop->getVisitMinutes() ?? $tour->getDefaultVisitMinutes()) * 60;
}
/**
* Nombre de secondes ecoulees depuis minuit pour une heure donnee.
*/
private function secondsOfDay(DateTimeImmutable $time): int
{
return (int) $time->format('H') * 3600
+ (int) $time->format('i') * 60
+ (int) $time->format('s');
}
/**
* Remet a null les resultats calcules d'une etape exclue du trajet (RG-6.05).
*/
private function resetStop(TourStop $stop): void
{
$stop->setLegDistanceM(null);
$stop->setLegDurationS(null);
$stop->setEta(null);
}
}
@@ -0,0 +1,416 @@
<?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\TourComputeProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourDuplicateProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourOptimizeProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourReorderProcessor;
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,
),
// Recalcule legs + ETA + totaux (HaversineRouteEngine). Sans corps :
// deserialize:false / validate:false ; la tournee est chargee par le
// provider (RG-6.01). Reponse = la tournee + ses etapes recalculees.
new Post(
uriTemplate: '/tours/{id}/compute',
status: 200,
security: "is_granted('field_sales.tours.manage')",
deserialize: false,
validate: false,
read: true,
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
provider: TourProvider::class,
processor: TourComputeProcessor::class,
),
// Reordonne les etapes selon l'ordre fourni (drag & drop) puis recompute.
// Corps {stopIds: [ids dans le nouvel ordre]}. La renumerotation est
// atomique (anti-collision unique (tour_id, position)), cf. processor.
new Post(
uriTemplate: '/tours/{id}/reorder',
status: 200,
security: "is_granted('field_sales.tours.manage')",
deserialize: false,
validate: false,
read: true,
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
provider: TourProvider::class,
processor: TourReorderProcessor::class,
),
// Reordonne les etapes (plus proche voisin) puis recompute.
new Post(
uriTemplate: '/tours/{id}/optimize',
status: 200,
security: "is_granted('field_sales.tours.manage')",
deserialize: false,
validate: false,
read: true,
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
provider: TourProvider::class,
processor: TourOptimizeProcessor::class,
),
// Duplique depart + etapes a une nouvelle date (corps {tourDate}), sans
// calculs (RG-6.13). deserialize:false : le processor lit tourDate puis
// construit une copie draft via TourDuplicator. Reponse 201 = la copie.
new Post(
uriTemplate: '/tours/{id}/duplicate',
security: "is_granted('field_sales.tours.manage')",
deserialize: false,
validate: false,
read: true,
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
provider: TourProvider::class,
processor: TourDuplicateProcessor::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,61 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Route;
/**
* Contrat du moteur de calcul de trajet (M6 § 3.4). Pose des la V1 pour brancher
* un fournisseur routier reel (OrsRouteEngine) en V2 SANS toucher au reste du
* module : « on n'ecrit jamais l'algo routier, on branche un fournisseur ».
*
* - V1 : HaversineRouteEngine distance a vol d'oiseau, vitesse moyenne
* parametrable, ordre « plus proche voisin » depuis le depart (heuristique
* gratuite, RG-6.05 / RG-6.11).
* - V2 : OrsRouteEngine matrice de temps routiers reels + optimisation TSP.
*
* Le contrat est purement geometrique : il opere sur des {@see RoutePoint} et ne
* connait aucune entite metier (Tour / TourStop). L'orchestration (resolution des
* coordonnees des etapes, ecriture des resultats, ETA) vit dans le service
* applicatif TourRouteCalculator.
*/
interface RouteEngineInterface
{
/**
* Matrice (symetrique, diagonale nulle) des distances en metres entre tous
* les points fournis. `$matrix[$i][$j]` = distance de `$points[$i]` a
* `$points[$j]`.
*
* @param list<RoutePoint> $points
*
* @return array<int, array<int, int>>
*/
public function computeMatrix(array $points): array;
/**
* Reordonne les points selon l'heuristique du plus proche voisin :
* - si `$start` est fourni, on part de `$start` et on enchaine a chaque etape
* le point restant le plus proche ;
* - si `$start` est null, le premier point de `$points` est considere comme
* le depart (il reste en tete) et seuls les suivants sont reordonnes.
*
* @param list<RoutePoint> $points
*
* @return list<RoutePoint> les memes points, dans le nouvel ordre
*/
public function optimizeOrder(?RoutePoint $start, array $points): array;
/**
* Distance + duree de chaque segment de l'itineraire
* `depart -> points[0] -> points[1] -> ...`. Retourne un {@see RouteLeg} par
* point : `$legs[$i]` est le trajet pour atteindre `$points[$i]`.
*
* Si `$start` est null, le premier point est le depart : `$legs[0]` est alors
* un segment nul (distance/duree = 0).
*
* @param list<RoutePoint> $points points DEJA ordonnes
*
* @return list<RouteLeg>
*/
public function estimateLegDurations(?RoutePoint $start, array $points): array;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Route;
/**
* Segment d'itineraire calcule par le moteur de trajet : distance et duree pour
* rejoindre un point depuis le precedent (ou depuis le point de depart pour le
* premier segment). Objet valeur immuable.
*
* Alimente `tour_stop.leg_distance_m` / `leg_duration_s` (M6 § 4.3).
*/
final readonly class RouteLeg
{
public function __construct(
public int $distanceMeters,
public int $durationSeconds,
) {}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Route;
/**
* Point geographique manipule par le moteur de trajet (M6 § 3.4). Objet valeur
* immuable, purement geometrique : il ne connait ni l'entite Tour ni TourStop.
*
* `$ref` identifie le point cote appelant (ex: id d'une etape, ou un marqueur de
* depart) pour reassocier le resultat du moteur a l'etape correspondante apres
* reordonnancement. Le moteur ne l'interprete jamais.
*/
final readonly class RoutePoint
{
public function __construct(
public int|string $ref,
public float $latitude,
public float $longitude,
) {}
}
@@ -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,53 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\FieldSales\Application\DTO\VisitableTierOutput;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider\VisitableTierProvider;
/**
* Resource API Platform en lecture seule : les « Tiers visitables » geolocalises
* (M6 § 5). Alimente les pins de la carte interactive de planification de
* tournee (M6.5).
*
* Un item = une adresse geolocalisee d'un Tiers (Client M1 / Fournisseur M2).
* Le provider lit via DBAL le schema partage (regle ABSOLUE n°1 : aucun import
* d'une classe Commercial) et retourne des `VisitableTierOutput`. Aucune entite
* ORM derriere pas d'ecriture exposee.
*
* Filtres query-param (cf. provider) :
* ?bbox=minLng,minLat,maxLng,maxLat zone visible de la carte (Leaflet getBounds().toBBoxString())
* ?q=durand recherche raison sociale / ville (ILIKE)
* ?type=client,supplier restreint les types de Tiers
*
* Pagination : standard global (10/page, max 50). La carte charge en general
* tous les pins de la bbox via l'echappatoire `?pagination=false` (la bbox borne
* deja le volume) gere par le provider, comme TourProvider.
*
* L'operation Get item (par id synthetique « {type}-{addressId} ») existe pour
* que API Platform genere l'IRI Hydra (`@id`) de chaque membre de la collection
* JSON-LD (meme contrainte que AuditLogResource).
*/
#[ApiResource(
shortName: 'VisitableTier',
operations: [
new GetCollection(
uriTemplate: '/visitable_tiers',
security: "is_granted('field_sales.tours.view')",
provider: VisitableTierProvider::class,
),
new Get(
uriTemplate: '/visitable_tiers/{id}',
requirements: ['id' => '[a-z_]+-[0-9]+'],
security: "is_granted('field_sales.tours.view')",
provider: VisitableTierProvider::class,
),
],
output: VisitableTierOutput::class,
)]
final class VisitableTierResource {}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\FieldSales\Application\Route\TourRouteCalculator;
use App\Module\FieldSales\Domain\Entity\Tour;
use Doctrine\ORM\EntityManagerInterface;
use function assert;
/**
* Processor de l'operation POST /api/tours/{id}/compute (M6 § 5).
*
* La tournee est chargee en amont par TourProvider (controle RG-6.01 + soft
* delete). L'operation ne porte pas de corps : on recalcule simplement legs +
* eta + totaux (HaversineRouteEngine via TourRouteCalculator) puis on persiste.
*
* @implements ProcessorInterface<Tour, Tour>
*/
final class TourComputeProcessor implements ProcessorInterface
{
public function __construct(
private readonly TourRouteCalculator $calculator,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
{
assert($data instanceof Tour);
$this->calculator->compute($data);
$this->em->flush();
return $data;
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\FieldSales\Application\Duplication\TourDuplicator;
use App\Module\FieldSales\Domain\Entity\Tour;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use function assert;
/**
* Processor de l'operation POST /api/tours/{id}/duplicate (M6 § 5, RG-6.13).
*
* La tournee source est chargee par TourProvider (RG-6.01). Le corps porte la
* nouvelle date (`tourDate`). On delegue la copie a {@see TourDuplicator} (sans
* calculs), on persiste la copie et on la retourne (201).
*
* Operation deserialize:false : le corps n'est pas mappe sur la source, on lit
* `tourDate` manuellement et on leve une 422 (propertyPath `tourDate`) si elle
* est absente ou invalide consommable par useFormErrors cote front.
*
* @implements ProcessorInterface<Tour, Tour>
*/
final class TourDuplicateProcessor implements ProcessorInterface
{
public function __construct(
private readonly TourDuplicator $duplicator,
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
{
assert($data instanceof Tour);
$tourDate = $this->readTourDate();
$copy = $this->duplicator->duplicate($data, $tourDate);
$this->em->persist($copy);
$this->em->flush();
return $copy;
}
/**
* Lit et valide `tourDate` depuis le corps JSON de la requete. Format attendu
* `Y-m-d`. Leve une 422 portee sur `tourDate` si absente ou invalide.
*/
private function readTourDate(): DateTimeImmutable
{
$request = $this->requestStack->getCurrentRequest();
$payload = null !== $request ? json_decode($request->getContent(), true) : null;
$raw = is_array($payload) ? ($payload['tourDate'] ?? null) : null;
if (is_string($raw) && '' !== trim($raw)) {
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($raw));
if (false !== $date) {
return $date;
}
}
$this->throwTourDateViolation(
is_string($raw) && '' !== trim($raw)
? 'La date de la tournée doit être au format AAAA-MM-JJ.'
: 'La date de la tournée dupliquée est obligatoire.',
);
}
/**
* @return never
*/
private function throwTourDateViolation(string $message): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation($message, null, [], null, 'tourDate', null));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\FieldSales\Application\Route\TourRouteCalculator;
use App\Module\FieldSales\Domain\Entity\Tour;
use Doctrine\ORM\EntityManagerInterface;
use function assert;
/**
* Processor de l'operation POST /api/tours/{id}/optimize (M6 § 5).
*
* Reordonne les etapes via le moteur (plus proche voisin) puis recompute legs +
* eta + totaux. La tournee est chargee en amont par TourProvider (RG-6.01).
*
* @implements ProcessorInterface<Tour, Tour>
*/
final class TourOptimizeProcessor implements ProcessorInterface
{
public function __construct(
private readonly TourRouteCalculator $calculator,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
{
assert($data instanceof Tour);
$this->calculator->optimize($data);
$this->em->flush();
return $data;
}
}
@@ -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);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\FieldSales\Application\Route\TourRouteCalculator;
use App\Module\FieldSales\Domain\Entity\Tour;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use function assert;
/**
* Processor de l'operation POST /api/tours/{id}/reorder (M6 § 5, drag & drop).
*
* La tournee est chargee par TourProvider (RG-6.01). Le corps porte l'ordre
* souhaite des etapes (`stopIds` : liste d'ids dans le nouvel ordre). On delegue
* la renumerotation atomique (deux flushes, anti-collision unique (tour_id,
* position)) + le recalcul a {@see TourRouteCalculator::reorder()}, puis on
* retourne la tournee recalculee (200).
*
* Operation deserialize:false : on lit `stopIds` manuellement et on leve une 422
* (propertyPath `stopIds`) si absente ou invalide consommable par useFormErrors.
*
* @implements ProcessorInterface<Tour, Tour>
*/
final class TourReorderProcessor implements ProcessorInterface
{
public function __construct(
private readonly TourRouteCalculator $calculator,
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
{
assert($data instanceof Tour);
$this->calculator->reorder($data, $this->readStopIds());
$this->em->flush();
return $data;
}
/**
* Lit et valide `stopIds` depuis le corps JSON : liste non vide d'entiers.
* Leve une 422 portee sur `stopIds` si absente ou malformee.
*
* @return list<int>
*/
private function readStopIds(): array
{
$request = $this->requestStack->getCurrentRequest();
$payload = null !== $request ? json_decode($request->getContent(), true) : null;
$raw = is_array($payload) ? ($payload['stopIds'] ?? null) : null;
if (!is_array($raw) || [] === $raw) {
$this->throwViolation('La liste ordonnée des étapes (stopIds) est obligatoire.');
}
$ids = [];
foreach ($raw as $value) {
if (!is_int($value) && !(is_string($value) && ctype_digit($value))) {
$this->throwViolation('La liste des étapes doit ne contenir que des identifiants entiers.');
}
$ids[] = (int) $value;
}
return $ids;
}
/**
* @return never
*/
private function throwViolation(string $message): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation($message, null, [], null, 'stopIds', null));
throw new ValidationException($violations);
}
}
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More