Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca79b8f8e6 |
@@ -56,10 +56,7 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
# gd requis par phpoffice/phpspreadsheet (export XLSX). Doit etre explicite :
|
||||
# sinon `composer install` echoue sur la verification de plateforme des que
|
||||
# le runner ne fournit pas l'extension par defaut (ext-gd manquante).
|
||||
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd
|
||||
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
|
||||
+18
-13
@@ -78,6 +78,23 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Transport" (M4, ERP-153) : pole logistique, porte le repertoire
|
||||
// transporteurs. L'item est gate par `transport.carriers.view` ; la section
|
||||
// disparait automatiquement (SidebarProvider) si le module `transport` est
|
||||
// desactive ou si l'user n'a pas la permission (Compta / Usine).
|
||||
[
|
||||
'label' => 'sidebar.transport.section',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.transport.carriers',
|
||||
'to' => '/carriers',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'module' => 'transport',
|
||||
'permission' => 'transport.carriers.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Administration" : regroupe toutes les pages de configuration
|
||||
// applicative (RBAC, users, sites, audit log).
|
||||
//
|
||||
@@ -100,20 +117,8 @@ return [
|
||||
// individuelles"), ajouter : 'permission' => 'core.admin.access'.
|
||||
[
|
||||
'label' => 'sidebar.administration.section',
|
||||
'icon' => 'mdi:file-settings-cog-outline',
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'items' => [
|
||||
// Transport — Repertoire transporteurs (M4, ERP-164). Rattache a
|
||||
// l'Administration (premier item) plutot qu'a une section dediee :
|
||||
// referentiel global de configuration applicative, sans cloisonnement
|
||||
// par site. Reste gate par sa propre permission `transport.carriers.view`
|
||||
// (Admin / Bureau / Commerciale) et son module owner `transport`.
|
||||
[
|
||||
'label' => 'sidebar.transport.carriers',
|
||||
'to' => '/carriers',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'module' => 'transport',
|
||||
'permission' => 'transport.carriers.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.roles',
|
||||
'to' => '/admin/roles',
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.134'
|
||||
app.version: '0.1.129'
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Donnees extraites de Mixgraine : raisons sociales, contacts, adresses,
|
||||
# n TVA et coordonnees bancaires (IBAN/BIC) reelles. NE JAMAIS committer.
|
||||
# Reproductible localement via `MIXGRAINE_JWT=... ./run.sh`.
|
||||
mixgraine-export/
|
||||
@@ -0,0 +1,80 @@
|
||||
# Migration Mixgraine → Starseed — Outils
|
||||
|
||||
Boîte à outils pour migrer les tiers (clients / fournisseurs / prestataires) depuis
|
||||
le CRM Mixgraine vers Starseed. Tout est autonome : 2 scripts Python + ce README.
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `mixgraine-migration-analysis.md` | Rapport d'analyse + mapping des champs (à lire en premier) |
|
||||
| `extract_mixgraine.py` | Récupère les tiers depuis l'API Mixgraine et les normalise en JSON |
|
||||
| `build_tiers_xlsx.py` | Produit un Excel avec **un onglet par type** + filtre « Site manquant » |
|
||||
| `run.sh` | Enchaîne les deux scripts |
|
||||
|
||||
---
|
||||
|
||||
## Prérequis
|
||||
|
||||
- **Python 3** (déjà présent sur un poste Linux/Mac) — vérifier : `python3 --version`
|
||||
- **openpyxl** (pour les Excel) : `pip install openpyxl` (ou `pip install --break-system-packages openpyxl`)
|
||||
- Un **token Mixgraine** (JWT) valide — voir ci-dessous.
|
||||
|
||||
### Récupérer le token Mixgraine
|
||||
|
||||
1. Ouvrir `https://liot.mixsuite.fr` dans Chrome, connecté.
|
||||
2. Ouvrir les outils dev (F12) → onglet **Network**.
|
||||
3. Cliquer sur n'importe quelle liste (clients…) pour déclencher un appel `api/customer`.
|
||||
4. Cliquer sur la requête → **Headers** → copier la valeur après `authorization: Bearer ` (la longue chaîne `eyJ0eXAi...`).
|
||||
|
||||
> Le token expire au bout de quelques jours. S'il est expiré, en récupérer un nouveau de la même façon. **Ne jamais le committer dans git.**
|
||||
|
||||
---
|
||||
|
||||
## Lancement
|
||||
|
||||
### Méthode simple (script tout-en-un)
|
||||
|
||||
```bash
|
||||
cd docs/migration
|
||||
export MIXGRAINE_JWT="eyJ0eXAi...colle-ton-token-ici..."
|
||||
./run.sh
|
||||
```
|
||||
|
||||
### Ou étape par étape
|
||||
|
||||
```bash
|
||||
cd docs/migration
|
||||
export MIXGRAINE_JWT="eyJ0eXAi..."
|
||||
|
||||
# 1) Extraction + normalisation (lent : ~1 requête/seconde, soyez patient)
|
||||
python3 extract_mixgraine.py
|
||||
# -> écrit dans ./mixgraine-export/ : clients.json, suppliers.json,
|
||||
# providers.json, referentials.json, extraction-report.txt
|
||||
# Test rapide sur 20 tiers : python3 extract_mixgraine.py --limit-ids 20
|
||||
|
||||
# 2) Génération de l'Excel de relecture (un onglet par type)
|
||||
python3 build_tiers_xlsx.py --in mixgraine-export
|
||||
# -> mixgraine-export/mixgraine-tiers.xlsx
|
||||
# onglets Clients / Fournisseurs / Prestataires + Synthèse,
|
||||
# colonne « Site manquant » filtrable, lignes à problème surlignées
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ce que tu obtiens
|
||||
|
||||
Tout est écrit dans `docs/migration/mixgraine-export/` :
|
||||
|
||||
- **`*.json`** — données normalisées au format Starseed (utilisées plus tard par les commandes d'import).
|
||||
- **`mixgraine-tiers.xlsx`** — un onglet par type (**Clients / Fournisseurs / Prestataires**) + un onglet **Synthèse**. Chaque onglet liste toutes les données (une ligne par adresse), avec une colonne **Site manquant** (OUI/vide) et une colonne **Problèmes**. Le **filtre automatique** est activé : clique l'entonnoir de la colonne « Site manquant » → coche `OUI` pour isoler les adresses sans site.
|
||||
- **`extraction-report.txt`** — compteurs + avertissements.
|
||||
- **`cache/`** — réponses brutes par tiers (permet de relancer sans tout refetch).
|
||||
|
||||
**Workflow conseillé** : ouvrir `mixgraine-tiers.xlsx`, filtrer « Site manquant = OUI » dans chaque onglet, corriger la donnée **à la source dans Mixgraine** (re-cocher les organisations/sites, compléter les emails de facturation), puis relancer (le cache accélère).
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- L'extraction est **reprenable** : si elle s'interrompt, relance-la, elle repart du cache.
|
||||
- Le débit est volontairement lent (`--delay 1.0` par défaut) pour ne pas saturer le serveur. Pour aller plus vite (à tes risques) : `--delay 0.3`.
|
||||
- Le dossier `mixgraine-export/` n'a pas vocation à être committé (données + token-sensible). Pense à l'ajouter au `.gitignore` si besoin.
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Construit UN classeur Excel de relecture a partir des JSON produits par
|
||||
extract_mixgraine.py, avec UN ONGLET PAR TYPE :
|
||||
|
||||
- Clients
|
||||
- Fournisseurs
|
||||
- Prestataires
|
||||
- Synthese (compteurs)
|
||||
|
||||
Chaque onglet liste TOUTES les donnees, une ligne par adresse (les colonnes du
|
||||
tiers sont repetees). Une colonne « Site manquant » (OUI / vide) + le filtre
|
||||
automatique Excel permettent de trier en un clic les adresses sans site
|
||||
(obligatoire cote Starseed — RG-1.10 / 2.06 / 3.03). Les lignes a probleme sont
|
||||
surlignees en rouge clair.
|
||||
|
||||
Usage :
|
||||
pip install openpyxl
|
||||
python3 build_tiers_xlsx.py --in mixgraine-export
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from openpyxl.utils import get_column_letter
|
||||
except ImportError:
|
||||
sys.exit("Dependance manquante : pip install openpyxl")
|
||||
|
||||
|
||||
# Compatibilite : les JSON deja generes portent la cle "lauttreeId" ;
|
||||
# les extractions ulterieures porteront "mixgraineId".
|
||||
def tid(t):
|
||||
return t.get("mixgraineId") or t.get("lauttreeId")
|
||||
|
||||
|
||||
def address_problems(addr, target):
|
||||
pb = []
|
||||
if not addr.get("sites"):
|
||||
pb.append("aucun site")
|
||||
if not addr.get("postalCode"):
|
||||
pb.append("code postal absent/invalide")
|
||||
if not addr.get("city"):
|
||||
pb.append("ville absente")
|
||||
if not addr.get("street"):
|
||||
pb.append("rue absente")
|
||||
if target == "Client" and addr.get("isBilling") and not addr.get("billingEmail"):
|
||||
pb.append("facturation sans email")
|
||||
return pb
|
||||
|
||||
|
||||
def tiers_problems(t, target):
|
||||
pb = []
|
||||
if not t.get("companyName"):
|
||||
pb.append("nom absent")
|
||||
if t.get("paymentType") == "VIREMENT" and not t.get("bank"):
|
||||
pb.append("VIREMENT sans banque")
|
||||
if t.get("paymentType") == "LCR" and not t.get("ribs"):
|
||||
pb.append("LCR sans RIB")
|
||||
if target == "Provider" and not t.get("sites"):
|
||||
pb.append("prestataire sans site")
|
||||
if t.get("categories") == ["A QUALIFIER"]:
|
||||
pb.append("categorie a qualifier")
|
||||
return pb
|
||||
|
||||
|
||||
# Colonnes communes (tiers). La colonne « Sites prestataire » n'existe QUE dans
|
||||
# l'onglet Prestataires (le site y est porte par le tiers, RG-3.03). Pour Clients
|
||||
# et Fournisseurs, le site est uniquement au niveau adresse (« Sites adresse »).
|
||||
TIERS_COLS = [
|
||||
"Réf.", "Société", "Catégories", "Mode paiement", "Banque", "N° TVA",
|
||||
"N° compte", "Distributeur", "Courtier", "Nb contacts", "Nb RIB",
|
||||
]
|
||||
ADDR_COLS = [
|
||||
"Rue", "Complément", "Code postal", "Ville", "Pays", "Sites adresse",
|
||||
"Facturation", "Email facturation", "Site manquant", "Problèmes tiers",
|
||||
]
|
||||
|
||||
|
||||
def headers_for(target):
|
||||
cols = list(TIERS_COLS)
|
||||
if target == "Provider":
|
||||
cols.append("Sites prestataire")
|
||||
return cols + ADDR_COLS
|
||||
|
||||
|
||||
HEADER_FILL = PatternFill("solid", fgColor="222783")
|
||||
HEADER_FONT = Font(bold=True, color="FFFFFF")
|
||||
BAD_FILL = PatternFill("solid", fgColor="FCE4E4") # rouge clair (probleme)
|
||||
WARN_FILL = PatternFill("solid", fgColor="FFF4D6") # orange clair (site manquant)
|
||||
|
||||
|
||||
def row_for(t, target, addr):
|
||||
pbt = " ; ".join(tiers_problems(t, target))
|
||||
base = [
|
||||
tid(t), t.get("companyName"), ", ".join(t.get("categories") or []),
|
||||
t.get("paymentType") or "", t.get("bank") or "", t.get("nTva") or "",
|
||||
t.get("accountNumber") or "", t.get("distributorName") or "",
|
||||
t.get("brokerName") or "", len(t.get("contacts", [])), len(t.get("ribs", [])),
|
||||
]
|
||||
if target == "Provider":
|
||||
base.append(", ".join(t.get("sites") or []))
|
||||
if addr is None:
|
||||
return base + ["", "", "", "", "", "", "", "", "(pas d'adresse)", pbt]
|
||||
site_missing = "OUI" if not addr.get("sites") else ""
|
||||
return base + [
|
||||
addr.get("street") or "", addr.get("streetComplement") or "",
|
||||
addr.get("postalCode") or "", addr.get("city") or "",
|
||||
addr.get("country") or "", ", ".join(addr.get("sites") or []),
|
||||
"oui" if addr.get("isBilling") else "", addr.get("billingEmail") or "",
|
||||
site_missing, pbt,
|
||||
]
|
||||
|
||||
|
||||
def build_sheet(wb, title, data, target):
|
||||
headers = headers_for(target)
|
||||
site_col = headers.index("Site manquant") + 1
|
||||
prob_col = len(headers)
|
||||
ws = wb.create_sheet(title=title)
|
||||
ws.append(headers)
|
||||
for c in range(1, len(headers) + 1):
|
||||
cell = ws.cell(row=1, column=c)
|
||||
cell.fill = HEADER_FILL
|
||||
cell.font = HEADER_FONT
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
rows = []
|
||||
for t in data:
|
||||
for a in (t.get("addresses") or [None]):
|
||||
rows.append((row_for(t, target, a), address_problems(a, target) if a else ["aucune adresse"]))
|
||||
for values, pbs in rows:
|
||||
ws.append(values)
|
||||
r = ws.max_row
|
||||
if values[site_col - 1] == "OUI":
|
||||
ws.cell(row=r, column=site_col).fill = WARN_FILL
|
||||
if pbs or values[-1]:
|
||||
ws.cell(row=r, column=prob_col).fill = BAD_FILL
|
||||
# filtre + gel des en-tetes + largeurs
|
||||
ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{ws.max_row}"
|
||||
ws.freeze_panes = "A2"
|
||||
for c, h in enumerate(headers, 1):
|
||||
sample = [len(str(h))] + [len(str(v[c - 1])) for v, _ in rows[:300]]
|
||||
ws.column_dimensions[get_column_letter(c)].width = min(max(max(sample) + 2, 10), 50)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Excel par type (Clients/Fournisseurs/Prestataires) + filtre site manquant")
|
||||
ap.add_argument("--in", dest="indir", default="mixgraine-export", help="dossier des JSON")
|
||||
ap.add_argument("--out", default=None, help="chemin du xlsx (defaut: <in>/mixgraine-tiers.xlsx)")
|
||||
args = ap.parse_args()
|
||||
|
||||
def load(name):
|
||||
path = os.path.join(args.indir, name)
|
||||
return json.load(open(path, encoding="utf-8")) if os.path.exists(path) else []
|
||||
|
||||
sources = [
|
||||
("Clients", load("clients.json"), "Client"),
|
||||
("Fournisseurs", load("suppliers.json"), "Supplier"),
|
||||
("Prestataires", load("providers.json"), "Provider"),
|
||||
]
|
||||
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
summary = []
|
||||
for title, data, target in sources:
|
||||
n_rows = build_sheet(wb, title, data, target)
|
||||
n_tiers = len(data)
|
||||
n_addr_missing = sum(1 for t in data for a in (t.get("addresses") or []) if not a.get("sites"))
|
||||
n_nocat = sum(1 for t in data if t.get("categories") == ["A QUALIFIER"])
|
||||
summary.append((title, n_tiers, n_rows, n_addr_missing, n_nocat))
|
||||
|
||||
# onglet Synthese
|
||||
ws = wb.create_sheet(title="Synthèse")
|
||||
sh = ["Type", "Tiers", "Lignes (adresses)", "Adresses sans site", "Sans catégorie"]
|
||||
ws.append(sh)
|
||||
for c in range(1, len(sh) + 1):
|
||||
ws.cell(row=1, column=c).fill = HEADER_FILL
|
||||
ws.cell(row=1, column=c).font = HEADER_FONT
|
||||
for r in summary:
|
||||
ws.append(list(r))
|
||||
for c in range(1, len(sh) + 1):
|
||||
ws.column_dimensions[get_column_letter(c)].width = 20
|
||||
# place la synthese en premier
|
||||
wb.move_sheet("Synthèse", -(len(wb.sheetnames) - 1))
|
||||
|
||||
out = args.out or os.path.join(args.indir, "mixgraine-tiers.xlsx")
|
||||
wb.save(out)
|
||||
print(f"Ecrit : {out}")
|
||||
print(f"{'Type':14}{'Tiers':>7}{'Lignes':>8}{'AdrSansSite':>13}{'SansCat':>9}")
|
||||
for title, n_tiers, n_rows, n_miss, n_nocat in summary:
|
||||
print(f"{title:14}{n_tiers:7}{n_rows:8}{n_miss:13}{n_nocat:9}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Extraction + normalisation des tiers (clients / fournisseurs) depuis le CRM
|
||||
Mixgraine (https://liot.mixsuite.fr) vers le format des entites Client / Supplier
|
||||
de Starseed.
|
||||
|
||||
Principe :
|
||||
1. Pagine GET /api/customer/?...&page=N pour collecter tous les id.
|
||||
2. Pour chaque id, recupere la fiche COMPLETE via
|
||||
PUT /api/customer/{id} body {"__data": true}
|
||||
(c'est l'appel que fait le front pour PRECHARGER le formulaire d'edition :
|
||||
il NE MODIFIE RIEN, il renvoie le schema + les valeurs courantes).
|
||||
3. Resout les selects (paymentType, banque, pays, distributeur, sites...) via
|
||||
le schema renvoye, puis normalise chaque tiers au format Starseed.
|
||||
4. Ecrit clients.json, suppliers.json, referentials.json + un rapport.
|
||||
|
||||
Caracteristiques :
|
||||
- Zero dependance (stdlib uniquement).
|
||||
- Cache disque par id (reprise apres interruption, pas de refetch).
|
||||
- Debit volontairement lent (--delay) pour ne pas saturer le serveur.
|
||||
- Backoff automatique sur erreur reseau / 429 / 5xx.
|
||||
|
||||
Usage :
|
||||
export MIXGRAINE_JWT="eyJ0eXAi..." # ton token Bearer (NE PAS committer)
|
||||
python3 extract_mixgraine.py # extraction complete
|
||||
python3 extract_mixgraine.py --delay 1.0 # encore plus doux
|
||||
python3 extract_mixgraine.py --limit-ids 20 # test rapide sur 20 tiers
|
||||
|
||||
Le JWT est un secret de session : passe-le par variable d'environnement,
|
||||
ne l'ecris jamais en dur ici.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ.get("MIXGRAINE_BASE", "https://liot.mixsuite.fr")
|
||||
JWT = os.environ.get("MIXGRAINE_JWT") or os.environ.get("LAUTTREE_JWT", "")
|
||||
|
||||
# --- Tables de correspondance Mixgraine -> codes referentiels Starseed ---------
|
||||
|
||||
TVA_MODE = {
|
||||
"France (ventes)": "FRANCE_VENTES",
|
||||
"Export (ventes)": "EXPORT_VENTES",
|
||||
"Intracom (ventes)": "INTRACOM_VENTES",
|
||||
"France (achats)": "FRANCE_VENTES", # pas de mode "achats" au seed -> a trancher
|
||||
}
|
||||
PAYMENT_DELAY = {
|
||||
"15 jours": "J15",
|
||||
"20 jours": "J20", # absent du seed Starseed -> a creer
|
||||
"30 jours": "J30",
|
||||
"A reception": "A_RECEPTION",
|
||||
"A réception": "A_RECEPTION",
|
||||
}
|
||||
PAYMENT_TYPE = {
|
||||
"LCR non soumise": "NON_SOUMISE", # pas LCR : on n'a pas toujours de RIB (RG-1.13)
|
||||
"Virement": "VIREMENT",
|
||||
"Cheque": "CHEQUE",
|
||||
"Chèque": "CHEQUE",
|
||||
}
|
||||
BANK = {
|
||||
"CIC": "CIC",
|
||||
"SOCIETE GENERALE": "SG",
|
||||
"CREDIT AGRICOLE": "CA",
|
||||
}
|
||||
|
||||
CIVILITES = ("Mme", "Mlle", "Mle", "M.", "Mr", "M") # ordre : plus long d'abord
|
||||
|
||||
|
||||
# --- Petites fonctions utilitaires -------------------------------------------
|
||||
|
||||
def http(method, path, body=None, tries=5):
|
||||
"""Appel HTTP avec retry/backoff. Renvoie le JSON decode."""
|
||||
url = BASE + path
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
headers = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": "Bearer " + JWT,
|
||||
}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
delay = 2.0
|
||||
for attempt in range(1, tries + 1):
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (429, 500, 502, 503, 504) and attempt < tries:
|
||||
print(f" ! HTTP {e.code} sur {path} -> retry dans {delay:.0f}s", file=sys.stderr)
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
raise
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
if attempt < tries:
|
||||
print(f" ! reseau ({e}) -> retry dans {delay:.0f}s", file=sys.stderr)
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError("echec apres %d tentatives : %s" % (tries, path))
|
||||
|
||||
|
||||
def choices_map(field):
|
||||
"""Construit {value: label} depuis un champ select du schema."""
|
||||
out = {}
|
||||
try:
|
||||
for c in field["type"]["choices"]:
|
||||
out[c["value"]] = c["label"]
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def first_id(val):
|
||||
"""Mixgraine renvoie soit un id, soit [] (vide), soit [{id:..}]."""
|
||||
if isinstance(val, list):
|
||||
return val[0]["id"] if val and isinstance(val[0], dict) else None
|
||||
return val if val not in ("", None) else None
|
||||
|
||||
|
||||
def parse_contact_name(name):
|
||||
"""'M.ROBERT Florian' -> (lastName='ROBERT', firstName='Florian')."""
|
||||
if not name:
|
||||
return None, None
|
||||
s = name.strip()
|
||||
for civ in CIVILITES:
|
||||
if s.upper().startswith(civ.upper()):
|
||||
s = s[len(civ):].strip(" .")
|
||||
break
|
||||
parts = [p for p in s.split() if p]
|
||||
if not parts:
|
||||
return None, None
|
||||
if len(parts) == 1:
|
||||
return parts[0], None # un seul mot -> nom de famille
|
||||
return parts[0], " ".join(parts[1:]) # 1er = nom, reste = prenom
|
||||
|
||||
|
||||
def clean_phone(p):
|
||||
"""Tronque/nettoie pour tenir dans 20 caracteres (limite Starseed)."""
|
||||
if not p:
|
||||
return None, None
|
||||
raw = str(p).strip()
|
||||
# garde le 1er numero si plusieurs ('... (direct) / ... (standard)')
|
||||
candidate = re.split(r"[/(]", raw)[0].strip()
|
||||
cleaned = candidate if candidate else raw
|
||||
flag = None
|
||||
if len(cleaned) > 20:
|
||||
flag = f"tel tronque ({raw!r})"
|
||||
cleaned = cleaned[:20].strip()
|
||||
return cleaned, flag
|
||||
|
||||
|
||||
POSTCODE_RE = re.compile(r"^\d{4,5}$")
|
||||
|
||||
|
||||
def clean_postcode(p):
|
||||
if not p:
|
||||
return None, None
|
||||
s = str(p).strip()
|
||||
if POSTCODE_RE.match(s):
|
||||
return s, None
|
||||
return None, f"code postal invalide ({p!r})"
|
||||
|
||||
|
||||
# --- Normalisation d'un tiers -------------------------------------------------
|
||||
|
||||
def normalize(record, warnings):
|
||||
"""record = reponse PUT {__data:true}. Renvoie un dict normalise Starseed."""
|
||||
fields = record.get("fields", {})
|
||||
d = record.get("__data", {})
|
||||
details = record.get("details", {})
|
||||
geo = details.get("geo", {}) or {}
|
||||
|
||||
tid = d.get("id")
|
||||
name = d.get("name") or d.get("reference")
|
||||
|
||||
# --- resolveurs depuis le schema de CE record ---
|
||||
liab = choices_map(fields.get("liability", {}))
|
||||
pdelay = choices_map(fields.get("paymentDelay", {}))
|
||||
ptype = choices_map(fields.get("paymentType", {}))
|
||||
bank = choices_map(fields.get("accountingBank", {}))
|
||||
distrib = choices_map(fields.get("distributor", {}))
|
||||
courtier = choices_map(fields.get("courtier", {}))
|
||||
cats = choices_map(fields.get("categories", {}))
|
||||
addr_fields = fields.get("addresses", {}).get("type", {}).get("fields", {})
|
||||
country_map = choices_map(addr_fields.get("country", {}))
|
||||
addr_cats = choices_map(addr_fields.get("categories", {}))
|
||||
carrier_map = choices_map(addr_fields.get("carrierType", {}))
|
||||
# libelles des sites (organisations)
|
||||
site_labels = {
|
||||
"organization_1": addr_fields.get("organization_1", {}).get("label"),
|
||||
"organization_2": addr_fields.get("organization_2", {}).get("label"),
|
||||
"organization_3": addr_fields.get("organization_3", {}).get("label"),
|
||||
}
|
||||
|
||||
def map_ref(table, label, what):
|
||||
if label is None:
|
||||
return None
|
||||
code = table.get(label)
|
||||
if code is None:
|
||||
warnings.append(f"tiers {tid} ({name}): {what} non mappe : {label!r}")
|
||||
return code
|
||||
|
||||
# --- referentiels comptables ---
|
||||
tva = map_ref(TVA_MODE, liab.get(first_id(d.get("liability"))), "tvaMode")
|
||||
delay = map_ref(PAYMENT_DELAY, pdelay.get(first_id(d.get("paymentDelay"))), "paymentDelay")
|
||||
pay = map_ref(PAYMENT_TYPE, ptype.get(first_id(d.get("paymentType"))), "paymentType")
|
||||
bnk = map_ref(BANK, bank.get(first_id(d.get("accountingBank"))), "bank")
|
||||
|
||||
# --- categories tiers ---
|
||||
categories = []
|
||||
for c in d.get("categories", []) or []:
|
||||
lbl = cats.get(c.get("id"))
|
||||
if lbl:
|
||||
categories.append(lbl)
|
||||
if not categories:
|
||||
categories = ["A QUALIFIER"] # contrainte min 1 cote Starseed
|
||||
warnings.append(f"tiers {tid} ({name}): aucune categorie -> 'A QUALIFIER'")
|
||||
|
||||
# --- contacts ---
|
||||
contacts = []
|
||||
contact_phones = set()
|
||||
for c in d.get("contacts", []) or []:
|
||||
last, first = parse_contact_name(c.get("name"))
|
||||
phone, f1 = clean_phone(c.get("phone"))
|
||||
mobile, f2 = clean_phone(c.get("mobile"))
|
||||
if f1:
|
||||
warnings.append(f"tiers {tid} ({name}): {f1}")
|
||||
if f2:
|
||||
warnings.append(f"tiers {tid} ({name}): {f2}")
|
||||
if not last and not first:
|
||||
last = "Standard" # RG-1.05/2.04 : au moins un nom
|
||||
for ph in (phone, mobile):
|
||||
if ph:
|
||||
contact_phones.add(re.sub(r"\D", "", ph))
|
||||
contacts.append({
|
||||
"mixgraineId": c.get("id"),
|
||||
"lastName": last,
|
||||
"firstName": first,
|
||||
"jobTitle": c.get("function"),
|
||||
"email": (c.get("email") or None),
|
||||
"phonePrimary": phone,
|
||||
"phoneSecondary": mobile,
|
||||
})
|
||||
|
||||
# tel porte par l'objet de base -> dans la liste de contacts (jamais a la racine)
|
||||
base_phone, fb = clean_phone(d.get("phone"))
|
||||
if fb:
|
||||
warnings.append(f"tiers {tid} ({name}): {fb}")
|
||||
if base_phone and re.sub(r"\D", "", base_phone) not in contact_phones:
|
||||
if contacts:
|
||||
# complete le 1er contact sans tel secondaire
|
||||
for c in contacts:
|
||||
if not c["phoneSecondary"]:
|
||||
c["phoneSecondary"] = base_phone
|
||||
break
|
||||
else:
|
||||
contacts[0]["phoneSecondary"] = base_phone
|
||||
else:
|
||||
contacts.append({
|
||||
"mixgraineId": None, "lastName": "Standard", "firstName": None,
|
||||
"jobTitle": None, "email": None,
|
||||
"phonePrimary": base_phone, "phoneSecondary": None,
|
||||
})
|
||||
|
||||
# --- emails de facturation (mails[] avec invoice=true) ---
|
||||
billing_mails = [m["mail"] for m in (d.get("mails") or []) if m.get("invoice") and m.get("mail")]
|
||||
|
||||
# --- adresses ---
|
||||
addresses = []
|
||||
for a in d.get("addresses", []) or []:
|
||||
pc, fp = clean_postcode(a.get("postcode"))
|
||||
if fp:
|
||||
warnings.append(f"tiers {tid} ({name}): {fp}")
|
||||
# sites depuis les booleens organization_n
|
||||
sites = [site_labels[k] for k in ("organization_1", "organization_2", "organization_3")
|
||||
if a.get(k) and site_labels[k]]
|
||||
# categories d'adresse
|
||||
acats = [addr_cats.get(c.get("id")) for c in (a.get("categories") or []) if addr_cats.get(c.get("id"))]
|
||||
# type d'adresse fournisseur (Rendu/Depart) depuis carrierType
|
||||
carrier = carrier_map.get(a.get("carrierType"))
|
||||
supplier_addr_type = {"Rendu": "RENDU", "Départ": "DEPART", "Depart": "DEPART"}.get(carrier)
|
||||
latlng = geo.get(str(a.get("id"))) or geo.get(a.get("id"))
|
||||
lat, lng = (latlng.split(",") + [None, None])[:2] if isinstance(latlng, str) else (None, None)
|
||||
addresses.append({
|
||||
"mixgraineId": a.get("id"),
|
||||
"street": a.get("street1"),
|
||||
"streetComplement": a.get("street2"),
|
||||
"postalCode": pc,
|
||||
"city": a.get("city"),
|
||||
"country": country_map.get(a.get("country"), "France"),
|
||||
# flags client
|
||||
"isBilling": bool(a.get("billing")),
|
||||
"isDelivery": bool(a.get("sales")),
|
||||
"isProspect": bool(a.get("salesTrip")),
|
||||
# type fournisseur
|
||||
"supplierAddressType": supplier_addr_type or "PROSPECT",
|
||||
"bennes": a.get("benneCount"),
|
||||
"sites": sites,
|
||||
"categories": acats,
|
||||
"billingEmail": (billing_mails[0] if (a.get("billing") and billing_mails) else None),
|
||||
"contactMixgraineIds": [c.get("id") for c in (a.get("contacts") or [])],
|
||||
"lat": lat, "lng": lng, # conserve pour info (pas de cible Starseed)
|
||||
})
|
||||
|
||||
# --- RIB (banks[]) ---
|
||||
ribs = [{
|
||||
"label": b.get("label") or "Compte principal",
|
||||
"iban": b.get("iban"),
|
||||
"bic": b.get("bic"),
|
||||
} for b in (d.get("banks") or []) if b.get("iban")]
|
||||
|
||||
return {
|
||||
"mixgraineId": tid,
|
||||
"companyName": name,
|
||||
"isCustomer": bool(d.get("customer")),
|
||||
"isSupplier": bool(d.get("supplier")),
|
||||
"accountNumber": d.get("billingAccount") or None,
|
||||
"nTva": d.get("vatNumber") or None,
|
||||
"tvaMode": tva,
|
||||
"paymentDelay": delay,
|
||||
"paymentType": pay,
|
||||
"bank": bnk,
|
||||
"distributorName": distrib.get(first_id(d.get("distributor"))),
|
||||
"brokerName": courtier.get(first_id(d.get("courtier"))),
|
||||
"categories": categories,
|
||||
"contacts": contacts,
|
||||
"addresses": addresses,
|
||||
"ribs": ribs,
|
||||
}
|
||||
|
||||
|
||||
# --- Recuperation de la liste des id -----------------------------------------
|
||||
|
||||
def fetch_ids_for(filters, limit, delay):
|
||||
"""Pagine /api/customer/ pour un jeu de filtres donne et renvoie la liste d'id."""
|
||||
fields = urllib.parse.quote('["name"]')
|
||||
fstr = urllib.parse.quote(json.dumps(filters)) if filters else ""
|
||||
ids, page, count = [], 0, None
|
||||
while True:
|
||||
path = f"/api/customer/?fields={fields}&limit={limit}&order=name&page={page}"
|
||||
if fstr:
|
||||
path += f"&filters={fstr}"
|
||||
resp = http("GET", path)
|
||||
if count is None:
|
||||
count = resp.get("count", 0)
|
||||
batch = resp.get("data", [])
|
||||
if not batch:
|
||||
break
|
||||
ids.extend(r["id"] for r in batch)
|
||||
page += 1
|
||||
if count and len(ids) >= count:
|
||||
break
|
||||
time.sleep(delay)
|
||||
return ids
|
||||
|
||||
|
||||
def fetch_all_ids(limit, delay):
|
||||
"""Collecte les id par groupe (client / fournisseur / prestataire) + union.
|
||||
|
||||
On s'appuie sur l'APPARTENANCE aux listes filtrees pour classer chaque tiers,
|
||||
plus fiable que les flags parfois absents du formulaire __data.
|
||||
"""
|
||||
print(" - clients (customer=true)")
|
||||
customer_ids = set(fetch_ids_for({"customer": True}, limit, delay))
|
||||
print(f" {len(customer_ids)}")
|
||||
print(" - fournisseurs (supplier=true)")
|
||||
supplier_ids = set(fetch_ids_for({"supplier": True}, limit, delay))
|
||||
print(f" {len(supplier_ids)}")
|
||||
print(" - prestataires (prestataire=true) -> ranges en fournisseurs")
|
||||
prestataire_ids = set(fetch_ids_for({"prestataire": True}, limit, delay))
|
||||
print(f" {len(prestataire_ids)}")
|
||||
|
||||
all_ids = sorted(customer_ids | supplier_ids | prestataire_ids)
|
||||
print(f" total tiers distincts : {len(all_ids)}")
|
||||
return all_ids, customer_ids, supplier_ids, prestataire_ids
|
||||
|
||||
|
||||
# --- Main ---------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Extraction Mixgraine -> format Starseed")
|
||||
ap.add_argument("--out", default="mixgraine-export", help="dossier de sortie")
|
||||
ap.add_argument("--delay", type=float, default=1.0, help="pause (s) entre chaque fiche (defaut 1 req/s)")
|
||||
ap.add_argument("--limit", type=int, default=200, help="taille de page pour la liste")
|
||||
ap.add_argument("--limit-ids", type=int, default=0, help="ne traiter que N tiers (test)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not JWT:
|
||||
sys.exit("ERREUR : export MIXGRAINE_JWT='<ton token>' avant de lancer.")
|
||||
|
||||
cache_dir = os.path.join(args.out, "cache")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
print("== Etape 1 : liste des id (par groupe) ==")
|
||||
ids, customer_ids, supplier_ids, prestataire_ids = fetch_all_ids(args.limit, args.delay)
|
||||
if args.limit_ids:
|
||||
ids = ids[:args.limit_ids]
|
||||
print(f"{len(ids)} tiers a recuperer.\n")
|
||||
|
||||
print("== Etape 2 : fiches detaillees (lent, soyez patient) ==")
|
||||
raw_by_id = {}
|
||||
for n, tid in enumerate(ids, 1):
|
||||
cache_file = os.path.join(cache_dir, f"{tid}.json")
|
||||
if os.path.exists(cache_file):
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
raw_by_id[tid] = json.load(f)
|
||||
continue
|
||||
rec = http("PUT", f"/api/customer/{tid}", body={"__data": True})
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(rec, f, ensure_ascii=False)
|
||||
raw_by_id[tid] = rec
|
||||
if n % 25 == 0 or n == len(ids):
|
||||
print(f" {n}/{len(ids)} fiches")
|
||||
time.sleep(args.delay)
|
||||
|
||||
print("\n== Etape 3 : normalisation ==")
|
||||
warnings = []
|
||||
clients, suppliers, providers = [], [], []
|
||||
cat_set, site_set = set(), set()
|
||||
for tid in ids:
|
||||
norm = normalize(raw_by_id[tid], warnings)
|
||||
# classification par APPARTENANCE aux listes filtrees (source fiable) :
|
||||
# customer -> Client (module Commercial)
|
||||
# supplier -> Supplier (module Commercial)
|
||||
# prestataire -> Provider (module Technique) — entite dediee, PAS un Supplier
|
||||
is_customer = tid in customer_ids or norm["isCustomer"]
|
||||
is_supplier = tid in supplier_ids or norm["isSupplier"]
|
||||
is_prestataire = tid in prestataire_ids
|
||||
norm["isCustomer"] = is_customer
|
||||
norm["isSupplier"] = is_supplier
|
||||
norm["isPrestataire"] = is_prestataire
|
||||
# Provider porte les sites DIRECTEMENT (RG-3.03) : on agrege les sites des adresses.
|
||||
norm["sites"] = sorted({s for a in norm["addresses"] for s in a["sites"]})
|
||||
cat_set.update(norm["categories"])
|
||||
for a in norm["addresses"]:
|
||||
site_set.update(a["sites"])
|
||||
cat_set.update(a["categories"])
|
||||
# un tiers peut cumuler plusieurs roles -> cree dans chaque table concernee
|
||||
if is_customer:
|
||||
clients.append(norm)
|
||||
if is_supplier:
|
||||
suppliers.append(norm)
|
||||
if is_prestataire:
|
||||
providers.append(norm)
|
||||
if not (is_customer or is_supplier or is_prestataire):
|
||||
clients.append(norm) # filet de securite : client par defaut
|
||||
warnings.append(f"tiers {tid} ({norm['companyName']}): aucun flag -> client par defaut")
|
||||
|
||||
referentials = {
|
||||
"categories": sorted(cat_set),
|
||||
"sites": sorted(site_set),
|
||||
"tvaModes": sorted(set(TVA_MODE.values())),
|
||||
"paymentDelays": sorted(set(PAYMENT_DELAY.values())),
|
||||
"paymentTypes": sorted(set(PAYMENT_TYPE.values())),
|
||||
"banks": sorted(set(BANK.values())),
|
||||
}
|
||||
|
||||
def dump(fname, obj):
|
||||
with open(os.path.join(args.out, fname), "w", encoding="utf-8") as f:
|
||||
json.dump(obj, f, ensure_ascii=False, indent=2)
|
||||
|
||||
dump("clients.json", clients)
|
||||
dump("suppliers.json", suppliers)
|
||||
dump("providers.json", providers)
|
||||
dump("referentials.json", referentials)
|
||||
with open(os.path.join(args.out, "extraction-report.txt"), "w", encoding="utf-8") as f:
|
||||
f.write(f"Tiers traites : {len(ids)}\n")
|
||||
f.write(f"Clients : {len(clients)}\n")
|
||||
f.write(f"Fournisseurs : {len(suppliers)}\n")
|
||||
f.write(f"Prestataires : {len(providers)}\n")
|
||||
f.write(f"Categories uniques : {len(referentials['categories'])}\n")
|
||||
f.write(f"Sites uniques : {referentials['sites']}\n")
|
||||
f.write(f"Avertissements : {len(warnings)}\n\n")
|
||||
f.write("\n".join(warnings))
|
||||
|
||||
print(f"\nTermine.")
|
||||
print(f" clients.json : {len(clients)}")
|
||||
print(f" suppliers.json : {len(suppliers)}")
|
||||
print(f" providers.json : {len(providers)} (prestataires)")
|
||||
print(f" referentials.json : {len(referentials['categories'])} categories, sites {referentials['sites']}")
|
||||
print(f" avertissements : {len(warnings)} (voir extraction-report.txt)")
|
||||
print(f"Sortie dans : {os.path.abspath(args.out)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,306 @@
|
||||
# Migration Mixgraine → Starseed — Analyse des tiers (clients / fournisseurs)
|
||||
|
||||
> Sources analysées (exports Mixgraine triés par groupe) :
|
||||
> - `client.json.json` — **1023 clients** (`customer=true`)
|
||||
> - `fournisseur.json` — **306 fournisseurs** (`supplier=true`)
|
||||
> - `fournisseur-client.json` — **106 tiers mixtes** (`customer=true` ET `supplier=true`)
|
||||
>
|
||||
> Cible : module `Commercial` de Starseed — entités `Client` / `Supplier` et leurs sous-entités `*Contact`, `*Address`, `*Rib`.
|
||||
> Date d'analyse : 2026-06-16. (Remplace l'analyse initiale faite sur `customer.json`, 737 tiers, où la classification était absente.)
|
||||
>
|
||||
> 🔑 **Source de migration recommandée : l'endpoint unitaire, pas l'export en masse.**
|
||||
> Les exports CSV/JSON en masse sont **incomplets** : ni RIB/IBAN, ni N° TVA, ni banque de virement, ni rattachement aux sites. Toutes ces données existent dans l'endpoint détail `GET /api/customer/{id}` et surtout dans `PUT /api/customer/{id}` avec `{"__data":true}` (renvoie le **schéma de formulaire complet + les valeurs**). Procédure : paginer `GET /api/customer/?...&limit=...&page=N` pour récupérer les `id`, puis appeler l'endpoint détail pour chaque id. Cf. § 9.
|
||||
|
||||
---
|
||||
|
||||
## 1. Synthèse
|
||||
|
||||
L'export est désormais **trié par groupe** : la distinction client / fournisseur est nette, ce qui lève le principal point bloquant de l'analyse précédente. **1435 tiers distincts** (aucun recoupement d'id entre les trois fichiers).
|
||||
|
||||
| Groupe source | Tiers | Devient dans Starseed |
|
||||
|---|---|---|
|
||||
| `client.json.json` (`customer=true`) | 1023 | `Client` |
|
||||
| `fournisseur.json` (`supplier=true`) | 306 | `Supplier` |
|
||||
| `fournisseur-client.json` (`customer=true` + `supplier=true`) | 106 | **`Client` ET `Supplier`** (cf. § 4) |
|
||||
| **prestataires** (`prestataire=true`, vue séparée Mixgraine) | à extraire via l'API (filtre `prestataire:true`) | **`Provider`** (module Technique, M3) |
|
||||
|
||||
> ✅ Les **prestataires** sont un 4e type de tiers, géré dans une vue dédiée de Mixgraine mais sur le **même endpoint** `/api/customer/` (filtre `{"prestataire":true}`). Côté Starseed, ils ont une **entité dédiée** : `Provider` (module **Technique**, `src/Module/Technique/Domain/Entity/Provider.php`), jumelle du `Supplier`, avec `ProviderContact` / `ProviderAddress` / `ProviderRib`. **Ils ne deviennent donc PAS des `Supplier`.** Particularités vs Supplier : pas d'onglet Information (entité minimale nom + comptabilité) et les **sites sont rattachés directement au prestataire** (M2M `provider_site`, min 1, RG-3.03) — le script agrège donc les sites des adresses au niveau du prestataire. Catégories de type **PRESTATAIRE** exigées (RG-3.09). Le script collecte les prestataires via leur liste dédiée et produit `providers.json` (§ 9).
|
||||
|
||||
Volumétrie migrable (tous groupes confondus) :
|
||||
|
||||
| Objet source | Quantité | Cible Starseed |
|
||||
|---|---|---|
|
||||
| Tiers racine | 1435 | `Client` (1129) + `Supplier` (412) = **1541 entités** créées (les 106 mixtes comptent double) |
|
||||
| Adresses | **2282** | `ClientAddress` / `SupplierAddress` |
|
||||
| Contacts | **2704** | `ClientContact` / `SupplierContact` |
|
||||
| RIB | **0** | *rien à migrer (ni IBAN ni BIC dans l'export)* |
|
||||
|
||||
Détail par groupe :
|
||||
|
||||
| Groupe | Tiers | Adresses | Contacts | Avec tél | Avec catégorie | Avec type paiement |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Clients | 1023 | 1125 | 1279 | 1023 (100 %) | 648 (63 %) | 599 |
|
||||
| Fournisseurs | 306 | 585 | 758 | 306 (100 %) | 92 (30 %) | 283 |
|
||||
| Mixtes | 106 | 572 | 667 | 106 (100 %) | 37 (35 %) | 95 |
|
||||
|
||||
Points de décision restants (détaillés plus bas) :
|
||||
|
||||
1. **Les 106 tiers mixtes** doivent être dédoublés en un `Client` + un `Supplier` (le modèle Starveed sépare les deux entités). § 4.
|
||||
2. **Référentiels à compléter** : `paymentDelay` (« 20 jours » absent du seed), `tvaMode` (Intracom/Export/achats), mapping des ~100 catégories. § 5.
|
||||
3. **Site obligatoire par adresse** (min 1) — notion toujours absente de l'export. § 6.
|
||||
|
||||
---
|
||||
|
||||
## 2. Structure de l'export
|
||||
|
||||
Chaque ligne = un tiers. Les champs `addresses.*` et `contacts.*` sont des **listes parallèles** (un tiers peut avoir plusieurs adresses / contacts).
|
||||
|
||||
Qualité des données (tous groupes) :
|
||||
|
||||
- **Téléphone racine** : rempli sur **100 %** des tiers (1435/1435).
|
||||
- **Emails contacts** : 1257 présents, **1256 valides** (1 invalide).
|
||||
- **Codes postaux** : 2282 présents, **10 invalides** à nettoyer : `.` (×3), `B.5300`, `?`, `684`, `854300`, etc. (sinon 422 sur la regex 4-5 chiffres RG-1.09/2.05).
|
||||
- **Catégories** : 55 % des tiers en ont au moins une (45 % sans) ; **92 % des adresses** ont une catégorie (2104/2282).
|
||||
- **Noms de contact** : format « `CIVILITÉ NOM PRÉNOM` » (`M `, `Mme`, `M.`) → à parser pour `lastName` / `firstName`.
|
||||
|
||||
Pays des adresses : FR (2266), ES (11), BE (2), NL (2), PT (1).
|
||||
|
||||
---
|
||||
|
||||
## 3. Mapping champ par champ
|
||||
|
||||
Légende : ✅ champ existant · ⚠️ migrable avec transformation · ❌ pas de cible (donnée perdue) · 🔒 contrainte de validation à gérer.
|
||||
|
||||
### 3.1 Niveau Tiers → `Client` / `Supplier`
|
||||
|
||||
| Champ Mixgraine | Cible Starseed | Statut | Note de transformation |
|
||||
|---|---|---|---|
|
||||
| `name` / `reference` | `companyName` (≤180) | ✅ | Vérifier l'unicité (index partiel `LOWER(company_name)`). Risque : un même nom présent en client ET fournisseur reste OK (tables distinctes) ; doublons intra-table à contrôler. |
|
||||
| `phone` (tél de l'objet de base) | `*Contact.phonePrimary` | ⚠️ | **Migré dans la liste de contacts** (cf. § 3.3bis), jamais au niveau racine. Rempli à 100 %. |
|
||||
| `categoriesStr` / `category.name` | `categories` (M2M `Category`) | ⚠️🔒 | **Min 1 obligatoire**. ~100 valeurs distinctes à mapper vers le référentiel `Category` (§ 5.1). Attention `Courtier`/`Distributeur` (cf. broker/distributor). |
|
||||
| `liability.name` | `tvaMode` | ⚠️ | `France (ventes)`→`FRANCE_VENTES` (1426), `Intracom (ventes)`→`INTRACOM_VENTES` (6), `Export (ventes)`→`EXPORT_VENTES` (2), `France (achats)`→**pas de mode « achats » au seed** (1 cas). |
|
||||
| `paymentDelay.label` | `paymentDelay` | ⚠️🔒 | `15 jours`→`J15` (1376), `30 jours`→`J30` (43), `A réception`→`A_RECEPTION` (1), **`20 jours` (15)→ code `J20` à créer** (absent du seed). |
|
||||
| `paymentType.label` | `paymentType` | ⚠️🔒 | `LCR non soumise`→**à trancher** (`LCR` impose ≥1 RIB, or 0 RIB → préférer `NON_SOUMISE`), `Virement`→`VIREMENT` (95, **exige `bank`** absente de la source), `Chèque`→`CHEQUE` (28). |
|
||||
| `billingAccount` | `accountNumber` (≤40) | ✅ | Peu rempli. |
|
||||
| `distributor.name` | `distributor` (FK auto-réf. `Client`) | ⚠️ | Suppose que le distributeur existe comme `Client`. Pas d'équivalent côté `Supplier`. |
|
||||
| `courtier.id` / `courtier.name` | `broker` (FK auto-réf.) | ⚠️ | À vérifier sur les nouveaux exports (vide dans l'ancien). Mutuellement exclusif avec `distributor` (RG-1.03). |
|
||||
| `customer` / `supplier` | choix de l'entité cible (`Client` vs `Supplier`) | ⚠️ | **Sert au routage**, pas stocké tel quel. |
|
||||
| `tiersIndexable`, `prestataire`, `coreLocked`, `isOgm`, `maxOwed`, `autoGenerateInvoice` | — | ❌ | Flags Mixgraine sans équivalent métier. |
|
||||
| `organization1..3` / `organizationsStr` | — | ❌ | Notion d'organisation/agence absente du modèle. |
|
||||
| `articlesSold.*` | — | ❌ | Relève du catalogue, hors périmètre Client/Supplier. |
|
||||
| `siren`, `nTva`, `bank` | `siren`, `nTva`, `bank` | ❌ | **Non présents dans l'export** → resteront vides. |
|
||||
|
||||
### 3.2 Niveau Adresse → `ClientAddress` / `SupplierAddress`
|
||||
|
||||
| Champ Mixgraine | Cible Starseed | Statut | Note |
|
||||
|---|---|---|---|
|
||||
| `addresses.street1` | `street` (≤255) | ✅ | Obligatoire. |
|
||||
| `addresses.street2` | `streetComplement` (≤255) | ✅ | |
|
||||
| `addresses.postcode` | `postalCode` (≤20, regex 4-5 chiffres) | ⚠️ | 10 valeurs à nettoyer. |
|
||||
| `addresses.city` | `city` (≤120) | ✅ | Obligatoire. |
|
||||
| `addresses.country` | `country` (≤80, défaut « France ») | ⚠️ | Convertir code ISO → libellé (`FR`→France, `ES`→Espagne, `BE`→Belgique, `NL`→Pays-Bas, `PT`→Portugal). |
|
||||
| `addresses.lat` / `addresses.lng` | — | ❌ | Coordonnées GPS perdues (pas de champ géo). |
|
||||
| `addresses.billing` (true) | `isBilling` (Client) | ⚠️🔒 | Si `true`, `billingEmail` **obligatoire** (RG-1.11), absent de la source → soit ne pas marquer facturation, soit collecter l'email. |
|
||||
| `addresses.sales` | `isDelivery` (Client) | ⚠️ | |
|
||||
| `addresses.purchase` | type adresse (Supplier : `DEPART`/`RENDU`) | ⚠️ | Côté fournisseur, l'adresse a un enum `PROSPECT`/`DEPART`/`RENDU`. |
|
||||
| `addresses.salesTrip` | `isProspect` (?) | ⚠️ | Sémantique « tournée commerciale » à confirmer. |
|
||||
| `addresses.benneCount` | `bennes` (SupplierAddress) | ⚠️ | Souvent vide. |
|
||||
| `addresses.carrierTypeStr`, `addresses.name`, `addresses.distances` | — | ❌ | Sans cible. |
|
||||
| `addresses.categories.name` | `categories` (M2M adresse) | ⚠️🔒 | 92 % des adresses en ont une. **Min 1 obligatoire** côté `ClientAddress`. ⚠️ Codes `DISTRIBUTEUR`/`COURTIER` **interdits** au niveau adresse (RG-1.29). |
|
||||
| — (aucun) | `sites` (M2M, **min 1**) | 🔒 | **Notion absente de l'export** — bloquant majeur (§ 6). |
|
||||
|
||||
### 3.3 Niveau Contact → `ClientContact` / `SupplierContact`
|
||||
|
||||
| Champ Mixgraine | Cible Starseed | Statut | Note |
|
||||
|---|---|---|---|
|
||||
| `contacts.name` | `lastName` / `firstName` (≤120) | ⚠️ | Parser « CIVILITÉ NOM PRÉNOM » : retirer civilité, 1er token = `lastName`, reste = `firstName`. Au moins l'un des deux requis (RG-1.05/2.04). |
|
||||
| `contacts.function` | `jobTitle` (≤120) | ✅ | |
|
||||
| `contacts.email` | `email` (≤180) | ✅ | 99,9 % valides. |
|
||||
| `contacts.phone` | `phonePrimary` (≤20) | ✅ | |
|
||||
| `contacts.mobile` | `phoneSecondary` (≤20) | ✅ | |
|
||||
| `contacts.fax` | — | ❌ | Pas de champ fax dans les contacts Starseed. |
|
||||
| `contacts.siege` | — | ❌ | Notion « contact siège » non modélisée. |
|
||||
| `addresses.contacts.*` | `*Address.contacts` (M2M) | ⚠️ | Rattachement contact↔adresse via les ids `addresses.contacts.id`. |
|
||||
|
||||
### 3.3bis — Règle : le contact porté par l'objet de base va dans la liste de contacts
|
||||
|
||||
Starseed n'a **aucun champ contact au niveau racine** de `Client`/`Supplier`. Tout doit atterrir dans la collection `*Contact` :
|
||||
|
||||
- Le bloc `contacts.*` (nom, fonction, email, tél, mobile) → une entrée `*Contact` par contact.
|
||||
- Le `phone` de l'objet de base (rempli à 100 %) → reporté sur un contact :
|
||||
- si le numéro figure déjà sur un contact → ne rien créer (déduplication) ;
|
||||
- s'il diffère → l'ajouter comme `phoneSecondary` d'un contact existant ;
|
||||
- si le tiers **n'a aucun contact** → **créer un contact « Standard »** (`lastName = "Standard"` pour respecter RG-1.05/2.04) portant ce `phonePrimary`.
|
||||
|
||||
Conséquence : aucun tiers ne perd son téléphone, et rien n'est stocké au niveau racine (conforme au modèle Starseed).
|
||||
|
||||
### 3.4 Niveau RIB → `ClientRib` / `SupplierRib`
|
||||
|
||||
**Disponible via l'endpoint unitaire** (champ `banks[]`), absent de l'export en masse. Mapping direct :
|
||||
|
||||
| Champ Mixgraine (`banks[]`) | Cible Starseed (`*Rib`) | Statut |
|
||||
|---|---|---|
|
||||
| `banks[].label` | `label` (≤120) | ✅ |
|
||||
| `banks[].iban` | `iban` (≤34) | ✅ (validé Assert\Iban) |
|
||||
| `banks[].bic` | `bic` (≤20) | ✅ (validé Assert\Bic) |
|
||||
|
||||
⚠️ Toujours mapper `paymentType="LCR non soumise"` vers `NON_SOUMISE` (et pas `LCR`) : les tiers sans `banks[]` violeraient sinon RG-1.13/2.08 (min 1 RIB).
|
||||
|
||||
### 3.5 Champs supplémentaires de l'endpoint unitaire (absents de l'export en masse)
|
||||
|
||||
| Champ Mixgraine (détail / `__data`) | Cible Starseed | Statut | Note |
|
||||
|---|---|---|---|
|
||||
| `vatNumber` | `nTva` (≤40) | ✅ | N° TVA. |
|
||||
| `accountingBank` (1=CIC, 2=SOCIETE GENERALE, 3=CREDIT AGRICOLE) | `bank` (FK) | ✅ | **Correspond au seed Starseed** (CIC/SG/CA). Banque de virement. |
|
||||
| `courtier` (FK select, 18 valeurs) | `broker` (FK auto-réf.) | ✅ | Courtier réel (≠ catégorie « Courtier »). Exclusif avec `distributor` (RG-1.03). |
|
||||
| `distributor` (FK select, 14 valeurs) | `distributor` (FK auto-réf.) | ✅ | Distributeur réel. |
|
||||
| `mails[]` {`mail`, `invoice`} | `ClientAddress.billingEmail` / secondaire | ⚠️ | Emails d'envoi de documents ; `invoice=true` → email de facturation (RG-1.11). |
|
||||
| address `carrierType` (1=Rendu, 2=Départ) | `SupplierAddress.addressType` (RENDU/DEPART) | ⚠️ | Radio exclusif côté fournisseur (RG-2.09). |
|
||||
| address `organization_1/2/3` | `*Address.sites` (M2M) | ✅ | **Les sites** (cf. § 6). |
|
||||
| address `hasBenne` / `benneCount` | `SupplierAddress.bennes` | ⚠️ | Nombre de bennes. |
|
||||
| address `validated` | — | ❌ | « Adresse vérifiée », pas de cible. |
|
||||
| `isDistributor`, `directBilling`, `distributorDirectOrder`, `newGuarantees`, `commentMail`, `zoneChartering(2)`, `emailDocument*` | — | ❌ | Champs Mixgraine sans équivalent métier. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Tiers mixtes (client + fournisseur) — 106 cas
|
||||
|
||||
Les 106 tiers du fichier `fournisseur-client.json` sont **à la fois client et fournisseur**. Le modèle Starseed sépare strictement `Client` et `Supplier` (deux tables, deux jeux de sous-entités). Deux options :
|
||||
|
||||
- **(Recommandé)** Créer **deux entités** par tiers mixte : un `Client` et un `Supplier`, chacun avec sa copie des adresses/contacts. C'est cohérent avec le modèle, au prix d'une duplication (à reconcilier plus tard si un lien inter-entités est ajouté).
|
||||
- Choisir une seule face (client OU fournisseur) selon l'usage dominant — risque de perte d'information.
|
||||
|
||||
Impact volumétrie : 1023 + 106 = **1129 `Client`**, 306 + 106 = **412 `Supplier`**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Référentiels à préparer avant import
|
||||
|
||||
### 5.1 Catégories (`Category`)
|
||||
|
||||
Situation nettement meilleure que l'export initial : **55 % des tiers** et **92 % des adresses** portent une catégorie. L'export contient **~100 valeurs distinctes** (top : Eleveur 392, Semencier 61, Coopérative 53, Courtier 38, Négociant 30, …, puis une longue traîne de catégories « fournisseur » métier : Transports, Informatique, Garagistes, Banques…).
|
||||
|
||||
À faire :
|
||||
- **Construire le référentiel `Category`** depuis ces valeurs (dédoublonner casse/accents, ex. `Courtier`/`COURTIERS`).
|
||||
- **Politique pour les 45 % sans catégorie** : attribuer une catégorie par défaut (ex. `À QUALIFIER`) pour satisfaire la contrainte min 1.
|
||||
- **Niveau adresse** : exclure `DISTRIBUTEUR`/`COURTIER` (interdits RG-1.29) — les router vers les FK `distributor`/`broker` ou la catégorie tiers.
|
||||
- Distinguer catégories **type CLIENT** vs **type FOURNISSEUR** (RG-2.10 : une catégorie fournisseur est exigée sur un `Supplier`).
|
||||
|
||||
### 5.2 Référentiels comptables
|
||||
|
||||
| Référentiel | Valeurs export | Action |
|
||||
|---|---|---|
|
||||
| `paymentDelay` | 15 jours, 30 jours, **20 jours**, A réception | Le seed n'a que `J15`/`J30`/`A_RECEPTION` → **créer `J20`**. |
|
||||
| `paymentType` | LCR non soumise, Virement, Chèque | Mapper `LCR non soumise`→`NON_SOUMISE` (éviter la contrainte RIB) ; `Virement`→`VIREMENT` (mais `bank` absente → soit la laisser vide en assouplissant RG-1.12, soit collecter). |
|
||||
| `tvaMode` | France (ventes), Intracom (ventes), Export (ventes), **France (achats)** | Mapper sur `FRANCE_VENTES`/`INTRACOM_VENTES`/`EXPORT_VENTES` ; pas de mode « achats » au seed (1 cas) → décision. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Sites — résolu via les « organisations » Mixgraine
|
||||
|
||||
Chaque `ClientAddress`/`SupplierAddress` doit référencer **au moins un `Site`** (RG-1.10/2.06). Cette notion **existe dans Mixgraine** sous le nom d'**organisations**, visible dans l'endpoint unitaire :
|
||||
|
||||
- au niveau tiers : `organizationsStr` (ex. `"Châtellerault, Saint-Jean, Pommevic"`) ;
|
||||
- au niveau adresse : trois booléens `organization_1` / `organization_2` / `organization_3` étiquetés **Châtellerault / Saint-Jean / Pommevic** ;
|
||||
- le bloc `addresses.distances` donne même la distance de l'adresse vers chacun de ces 3 sites.
|
||||
|
||||
**Les 3 sites à créer dans Starseed** : `Châtellerault`, `Saint-Jean`, `Pommevic`. Le rattachement adresse↔site se reconstruit depuis les booléens `organization_n=true`. Pour une adresse sans aucune organisation cochée, prévoir un site par défaut (à décider).
|
||||
|
||||
⚠️ Ce rattachement n'est lisible que via l'endpoint unitaire — raison de plus pour migrer depuis le détail (cf. § 9).
|
||||
|
||||
---
|
||||
|
||||
## 7. Synthèse des pertes de données
|
||||
|
||||
Perdus si non traités en amont : coordonnées GPS (`lat`/`lng`), fax des contacts, contact siège, articles vendus, « adresse vérifiée », et divers flags Mixgraine (`isOgm`, `maxOwed`, `directBilling`, `zoneChartering`, `commentMail`).
|
||||
|
||||
Champs cibles qui resteront vides (vraiment absents de la source, même au détail) : `siren` (le `vatNumber` alimente `nTva`, mais pas de SIREN distinct).
|
||||
|
||||
> Note : `nTva`, `bank` et les `*Rib` ne sont **plus** des pertes — ils sont disponibles via l'endpoint unitaire (§ 3.4 / § 3.5). Ils n'étaient absents que de l'export en masse.
|
||||
|
||||
---
|
||||
|
||||
## 7bis. Excel de relecture (un onglet par type)
|
||||
|
||||
Beaucoup d'adresses n'ont **aucun site** rattaché (les organisations ne sont pas cochées dans Mixgraine), et d'autres trous existent (CP invalide, facturation sans email…). Avant tout import, on produit un classeur via `build_tiers_xlsx.py` : **`mixgraine-tiers.xlsx`**.
|
||||
|
||||
- Un onglet **Synthèse** (compteurs) + un onglet par type : **Clients**, **Fournisseurs**, **Prestataires**.
|
||||
- Chaque onglet liste toutes les données, **une ligne par adresse** (colonnes du tiers répétées).
|
||||
- Colonne **Site manquant** (OUI/vide) + **filtre automatique** → un clic pour isoler les adresses sans site. Colonne **Problèmes** pour le reste. Lignes à problème surlignées.
|
||||
|
||||
Chiffres réels (extraction 2026-06-16, 1442 tiers) :
|
||||
|
||||
| Type | Tiers | Lignes (adresses) | Adresses sans site | Sans catégorie |
|
||||
|---|---|---|---|---|
|
||||
| Clients | 1129 | 1697 | 1111 | 444 (39 %) |
|
||||
| Fournisseurs | 412 | 1157 | 612 | 283 (68 %) |
|
||||
| Prestataires | 300 | 333 | 0 | 8 (2 %) |
|
||||
| **Total** | **1841\*** | **3187** | **1723** | **735** |
|
||||
|
||||
\* mixtes comptés en Client + Fournisseur. RIB extraits : 90. Tiers totalement bloqués : 90.
|
||||
|
||||
Motifs de blocage détectés :
|
||||
|
||||
| Niveau | Motif bloquant |
|
||||
|---|---|
|
||||
| Adresse | **aucun site rattaché** (RG-1.10/2.06/3.03), code postal absent/invalide, ville absente, rue absente, facturation sans email (client) |
|
||||
| Tiers | nom absent, `VIREMENT` sans banque, `LCR` sans RIB, **prestataire sans aucun site** (RG-3.03), catégorie `À QUALIFIER` |
|
||||
|
||||
Filtrer « Site manquant = OUI » dans chaque onglet permet de corriger la donnée à la source (re-cocher les organisations dans Mixgraine, compléter les emails) **avant** de relancer l'import (le cache accélère).
|
||||
|
||||
---
|
||||
|
||||
## 8. Séquence d'import recommandée
|
||||
|
||||
1. **Extraire la donnée complète** depuis l'endpoint unitaire (§ 9) — pas depuis l'export en masse.
|
||||
2. **Relecture** : `build_tiers_xlsx.py` → `mixgraine-tiers.xlsx` (un onglet par type, filtre « Site manquant »), faire corriger à la source (§ 7bis).
|
||||
2. **Sites** : créer les 3 sites (Châtellerault, Saint-Jean, Pommevic) + un site par défaut éventuel (§ 6).
|
||||
3. **Référentiels** : créer/compléter `Category` (~100 valeurs + défaut `À QUALIFIER`), `paymentDelay` (ajouter `J20`), `tvaMode`, vérifier `paymentType`, `bank` (CIC/SG/CA déjà OK) (§ 5).
|
||||
4. **Nettoyer** : 10 codes postaux invalides, parser les noms de contacts (civilité), convertir codes pays ISO → libellés (table fournie par le schéma `country`).
|
||||
5. **Importer dans l'ordre** : référentiels + sites → tiers → adresses (+ rattachement site via `organization_n`) → contacts (+ rattachement adresse + tél de l'objet de base) → RIB (`banks[]`).
|
||||
6. **Tiers mixtes** (106) : créer Client + Supplier (§ 4).
|
||||
7. **Gérer les 422 attendus** : adresses `isBilling=true` sans `billingEmail` ; `paymentType=VIREMENT` sans `bank`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Stratégie d'extraction via l'API Mixgraine
|
||||
|
||||
L'export en masse est insuffisant (§ entête). La donnée complète s'obtient en deux temps depuis `https://liot.mixsuite.fr` (auth : `Authorization: Bearer <JWT>`) :
|
||||
|
||||
1. **Lister les ids par groupe, page par page** :
|
||||
`GET /api/customer/?fields=["name"]&filters={...}&limit=200&order=name&page=N`
|
||||
La réponse porte `count` (total) et `data[].id`. On pagine **trois listes filtrées** puis on fait l'union dédupliquée :
|
||||
- `{"customer":true}` → clients (`Client`),
|
||||
- `{"supplier":true}` → fournisseurs (`Supplier`),
|
||||
- `{"prestataire":true}` → prestataires (`Provider`, module Technique).
|
||||
|
||||
La **classification se fait par appartenance** à ces listes (plus fiable que les flags du formulaire `__data`) : un id dans `customer` ET `supplier` = mixte (Client + Supplier) ; un id dans `prestataire` = `Provider`. Le script produit `clients.json`, `suppliers.json`, `providers.json` + `referentials.json`.
|
||||
|
||||
2. **Récupérer le détail complet de chaque tiers** :
|
||||
`GET /api/customer/{id}` (objet riche : contacts, addresses, banks, liability, paymentType…)
|
||||
ou `PUT /api/customer/{id}` avec corps `{"__data":true}` qui renvoie en plus le **schéma de formulaire** (libellés des organisations/sites, choix des référentiels, FK distributor/courtier) **et** les valeurs sous `__data`.
|
||||
|
||||
Champs clés présents uniquement au détail : `banks[]` (RIB), `vatNumber`, `accountingBank`, `courtier`, `distributor`, `mails[]`, `addresses[].organization_1/2/3` (sites), `addresses[].carrierType`, `details.geo` (lat/lng par adresse).
|
||||
|
||||
> ⚠️ Le JWT fourni est un secret de session : ne pas le committer dans le repo. À passer en variable d'environnement au script d'extraction.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
TaskGroup Lesstime : **#32 — Migration tiers Mixgraine → Starseed** (projet STARSEED / ERP)
|
||||
|
||||
| # | Ticket | Réf. | Effort | Tag |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 | Extraire et normaliser les tiers Mixgraine en JSON | ERP-174 | M | Backend |
|
||||
| 1.2 | Seeder les référentiels et les 3 sites | ERP-175 | M | Backend |
|
||||
| 1.3 | Importer les clients (Commercial) + adresses/contacts/RIB | ERP-176 | L | Backend |
|
||||
| 1.4 | Importer les fournisseurs (Commercial) + adresses/contacts/RIB | ERP-177 | M | Backend |
|
||||
| 1.5 | Importer les prestataires (Technique/Provider) | ERP-178 | M | Backend |
|
||||
| 1.6 | Relier distributeurs/courtiers, traiter les mixtes et vérifier | ERP-179 | M | Backend |
|
||||
|
||||
Tous au statut **Prêt à dev**. Prochaine action : lancer le 1.1 (le script `extract_mixgraine.py` est déjà écrit, reste à le tester avec un vrai token).
|
||||
|
||||
---
|
||||
|
||||
*Document basé sur l'analyse des exports en masse `client.json.json` / `fournisseur.json` / `fournisseur-client.json` et sur les réponses des endpoints `/api/customer/` (liste), `/api/customer/{id}` (détail) et `PUT /api/customer/{id}` (`__data`). Aucune donnée n'a été importée — rapport d'analyse préalable.*
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Enchaine extraction Mixgraine + generation des Excel de relecture.
|
||||
# Usage : export MIXGRAINE_JWT="eyJ..." && ./run.sh
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [[ -z "${MIXGRAINE_JWT:-}" ]]; then
|
||||
echo "ERREUR : export MIXGRAINE_JWT='<ton token>' avant de lancer." >&2
|
||||
echo " (Chrome -> F12 -> Network -> requete api/customer -> header authorization: Bearer ...)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT="${1:-mixgraine-export}"
|
||||
|
||||
echo "==> 1/2 Extraction Mixgraine (lent, ~1 req/s)..."
|
||||
python3 extract_mixgraine.py --out "$OUT"
|
||||
|
||||
echo "==> 2/2 Generation de l'Excel par type..."
|
||||
python3 build_tiers_xlsx.py --in "$OUT"
|
||||
|
||||
echo
|
||||
echo "Termine. Resultats dans : $OUT/"
|
||||
echo " - mixgraine-tiers.xlsx (1 onglet par type + Synthese, filtre 'Site manquant')"
|
||||
echo " - *.json + extraction-report.txt"
|
||||
@@ -495,111 +495,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport": {
|
||||
"carriers": {
|
||||
"title": "Répertoire transporteurs",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun transporteur pour l'instant.",
|
||||
"column": {
|
||||
"name": "Nom",
|
||||
"certification": "Certification",
|
||||
"validityDate": "Date de validité",
|
||||
"lastActivity": "Dernière activité"
|
||||
},
|
||||
"certification": {
|
||||
"QUALIMAT": "QUALIMAT",
|
||||
"GMP_PLUS": "GMP+",
|
||||
"OVOCOM": "OVOCOM",
|
||||
"COMPTE_PROPRE": "Compte-propre",
|
||||
"AUTRE": "Autre"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"certification": "Certification",
|
||||
"status": "Statut",
|
||||
"archivedOnly": "Voir 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 transporteurs a échoué. Réessayez.",
|
||||
"createSuccess": "Transporteur créé avec succès",
|
||||
"integrateSuccess": "Transporteur QUALIMAT intégré",
|
||||
"addressSaved": "Adresse enregistrée"
|
||||
},
|
||||
"containerType": {
|
||||
"BENNE": "Benne",
|
||||
"FOND_MOUVANT": "Fond mouvant"
|
||||
},
|
||||
"tab": {
|
||||
"qualimat": "Qualimat",
|
||||
"addresses": "Adresses",
|
||||
"contacts": "Contacts",
|
||||
"prices": "Prix"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un transporteur",
|
||||
"back": "Retour au répertoire",
|
||||
"submit": "Valider",
|
||||
"comingSoon": "À venir",
|
||||
"duplicateName": "Un transporteur actif portant ce nom existe déjà.",
|
||||
"main": {
|
||||
"name": "Nom",
|
||||
"certificationType": "Certification transport",
|
||||
"isChartered": "Affréter",
|
||||
"indexationRate": "Indexation %",
|
||||
"containerType": "Benne / Fond mouvant",
|
||||
"volumeM3": "Volume m³",
|
||||
"discharge": "Décharge",
|
||||
"liotPlates": "Immatriculations LIOT",
|
||||
"liotPlatesHint": "Séparées par « ; »"
|
||||
},
|
||||
"qualimat": {
|
||||
"empty": "Aucun transporteur QUALIMAT trouvé.",
|
||||
"searchHint": "Saisissez le nom du transporteur pour lancer la recherche.",
|
||||
"columns": {
|
||||
"name": "Nom",
|
||||
"address": "Adresse",
|
||||
"validityDate": "Date de validité"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Intégration QUALIMAT",
|
||||
"message": "Êtes-vous sûr de vouloir intégrer ce transporteur ?",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Intégrer"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Le nom du transporteur est obligatoire.",
|
||||
"certificationRequired": "Le type de certification est obligatoire.",
|
||||
"dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».",
|
||||
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
|
||||
"containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.",
|
||||
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété."
|
||||
},
|
||||
"address": {
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"street": "Adresse",
|
||||
"streetComplement": "Adresse complémentaire",
|
||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||
"add": "Nouvelle adresse",
|
||||
"remove": "Supprimer l'adresse",
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Supprimer ce bloc",
|
||||
"message": "Cette suppression est définitive. Confirmer ?",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"logout": "Deconnexion",
|
||||
|
||||
@@ -41,10 +41,9 @@ export interface 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, archives) sont pilotes par la page
|
||||
* via `setFilters` du composable partage — la remise en page 1 est garantie.
|
||||
* Cocher « Voir les archivés » envoie `archivedOnly=true` → seules les archives
|
||||
* sont listees (aligne sur Client).
|
||||
* 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
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
<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('transport.carriers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- Pays : prerempli « France » (RG-4.05). -->
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('transport.carriers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.country"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('transport.carriers.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('transport.carriers.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('transport.carriers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de la ligne suivante sur la grille. -->
|
||||
<div aria-hidden="true" />
|
||||
|
||||
<!-- 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('transport.carriers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('transport.carriers.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('transport.carriers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('transport.carriers.form.address.streetComplement')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
|
||||
interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: CarrierAddressFormDraft
|
||||
/** 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: CarrierAddressFormDraft]
|
||||
'remove': []
|
||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||
'degraded': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// 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 CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** 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>
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyCarrierAddress } from '~/modules/transport/types/carrierForm'
|
||||
import CarrierAddressBlock from '../CarrierAddressBlock.vue'
|
||||
|
||||
/**
|
||||
* Tests de l'autocomplétion BAN du bloc Adresse transporteur (ERP-167) — réutilise
|
||||
* `useAddressAutocomplete` (M1/M2/M3). On vérifie le NOMINAL (CP → ville) et le
|
||||
* DÉGRADÉ (BAN indisponible → saisie libre + event `degraded`).
|
||||
*/
|
||||
|
||||
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
||||
searchCityMock: vi.fn(),
|
||||
searchAddressMock: vi.fn(),
|
||||
}))
|
||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||
useAddressAutocomplete: () => ({
|
||||
searchCity: searchCityMock,
|
||||
searchAddress: searchAddressMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
|
||||
const MalioInputTextStub = defineComponent({
|
||||
name: 'MalioInputText',
|
||||
props: { modelValue: { default: null }, label: { type: String, default: '' }, error: { type: String, default: '' } },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': 'input-text', 'data-label': props.label, 'data-error': props.error })
|
||||
},
|
||||
})
|
||||
|
||||
const MalioSelectStub = defineComponent({
|
||||
name: 'MalioSelect',
|
||||
props: { modelValue: { default: null }, options: { type: Array as () => { value: string }[], default: () => [] }, label: { type: String, default: '' }, error: { type: String, default: '' } },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': 'select', 'data-label': props.label, 'data-options': JSON.stringify(props.options.map(o => o.value)) })
|
||||
},
|
||||
})
|
||||
|
||||
const MalioInputAutocompleteStub = defineComponent({
|
||||
name: 'MalioInputAutocomplete',
|
||||
props: { modelValue: { default: null }, options: { type: Array as () => { value: string }[], default: () => [] }, loading: { 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> = {}) {
|
||||
return mount(CarrierAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyCarrierAddress(), ...overrides },
|
||||
countryOptions: [{ value: 'France', label: 'France' }],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioInputText: MalioInputTextStub,
|
||||
MalioSelect: MalioSelectStub,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Récupère le composant MalioInputText d'un label donné. */
|
||||
function inputTextByLabel(wrapper: ReturnType<typeof mountBlock>, label: string) {
|
||||
return wrapper.findAllComponents(MalioInputTextStub).find(c => c.props('label') === label)
|
||||
}
|
||||
|
||||
describe('CarrierAddressBlock — autocomplétion ville (BAN) NOMINAL', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchAddressMock.mockReset()
|
||||
})
|
||||
|
||||
it('saisie d\'un CP à 5 chiffres → searchCity + peuple le select Ville', async () => {
|
||||
searchCityMock.mockResolvedValueOnce([{ city: 'Poitiers', postalCode: '86000' }])
|
||||
const wrapper = mountBlock()
|
||||
|
||||
const cp = inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')
|
||||
cp?.vm.$emit('update:modelValue', '86000')
|
||||
await flushPromises()
|
||||
|
||||
expect(searchCityMock).toHaveBeenCalledWith('86000')
|
||||
const citySelect = wrapper.findAllComponents(MalioSelectStub).find(c => c.props('label') === 'transport.carriers.form.address.city')
|
||||
const options = JSON.parse(citySelect?.attributes('data-options') ?? '[]')
|
||||
expect(options).toContain('Poitiers')
|
||||
expect(wrapper.emitted('degraded')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('n\'interroge pas la BAN sous 5 chiffres', async () => {
|
||||
const wrapper = mountBlock()
|
||||
inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')?.vm.$emit('update:modelValue', '860')
|
||||
await flushPromises()
|
||||
expect(searchCityMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CarrierAddressBlock — autocomplétion DÉGRADÉE', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchAddressMock.mockReset()
|
||||
})
|
||||
|
||||
it('BAN ville indisponible → bascule en saisie libre + émet « degraded »', async () => {
|
||||
searchCityMock.mockRejectedValueOnce(new Error('BAN indisponible'))
|
||||
const wrapper = mountBlock()
|
||||
|
||||
inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')?.vm.$emit('update:modelValue', '86000')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
||||
// En dégradé, la Ville devient un MalioInputText (plus de MalioSelect ville).
|
||||
const citySelect = wrapper.findAllComponents(MalioSelectStub).find(c => c.props('label') === 'transport.carriers.form.address.city')
|
||||
expect(citySelect).toBeUndefined()
|
||||
expect(inputTextByLabel(wrapper, 'transport.carriers.form.address.city')).toBeDefined()
|
||||
})
|
||||
|
||||
it('autocomplétion adresse : pas d\'appel BAN sous 3 caractères', async () => {
|
||||
const wrapper = mountBlock()
|
||||
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
|
||||
await flushPromises()
|
||||
expect(searchAddressMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('autocomplétion adresse : émet « degraded » une seule fois malgré 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)
|
||||
})
|
||||
})
|
||||
@@ -1,557 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Tests du workflow « Ajouter un transporteur » (M4 Transport, ERP-165).
|
||||
*
|
||||
* `useCarrierForm` porte le formulaire principal (Nom + Certification + Affréter)
|
||||
* et l'orchestration des onglets de création. On vérifie ici le CONTRAT propre à
|
||||
* la création :
|
||||
* - pré-check front : nom requis → POST bloqué, erreur inline, aucun appel réseau ;
|
||||
* - POST /carriers (groupe carrier:write:main) : payload + Accept ld+json +
|
||||
* toast:false ; au succès, verrouillage + bascule sur l'onglet Qualimat +
|
||||
* réaffichage du nom normalisé ;
|
||||
* - 409 doublon (RG-4.12) → erreur inline dédiée sur `name` ;
|
||||
* - 422 → mapping inline par champ (propertyPath) ;
|
||||
* - onglets : 4 onglets (Qualimat/Adresses/Contacts/Prix), completeTab
|
||||
* déverrouille/avance et signale le dernier onglet ;
|
||||
* - patchCarrier : PATCH partiel, no-op avant création.
|
||||
*/
|
||||
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockDelete = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: vi.fn(),
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: mockDelete,
|
||||
}))
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}))
|
||||
|
||||
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
|
||||
|
||||
describe('useCarrierForm', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
})
|
||||
|
||||
it('front : nom vide → erreur inline sur name, pas de POST', async () => {
|
||||
const form = useCarrierForm()
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('front : nom en espaces uniquement → erreur inline, pas de POST', async () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = ' '
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired')
|
||||
})
|
||||
|
||||
it('front : certification vide (hors LIOT) → erreur inline sur certificationType, pas de POST', async () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
// certificationType laissé null → bloqué côté front (RG-4.01).
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.certificationType).toBe('transport.carriers.form.errors.certificationRequired')
|
||||
})
|
||||
|
||||
it('front : cas LIOT → certification non requise (aucune erreur de certification)', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 5, name: 'LIOT', certificationType: null })
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'LIOT'
|
||||
form.main.liotPlates = 'AA-123-BB'
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(form.mainErrors.errors.certificationType).toBeUndefined()
|
||||
})
|
||||
|
||||
it('front RG-4.02 : certification AUTRE sans décharge → erreur inline, pas de POST', async () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'AUTRE'
|
||||
// dischargeDocumentIri null (upload non fourni).
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.dischargeDocument).toBe('transport.carriers.form.errors.dischargeRequired')
|
||||
})
|
||||
|
||||
it('front RG-4.03 : affrété sans indexation / contenant / volume → 3 erreurs inline, pas de POST', async () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
form.main.isChartered = true
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.indexationRate).toBe('transport.carriers.form.errors.indexationRequired')
|
||||
expect(form.mainErrors.errors.containerType).toBe('transport.carriers.form.errors.containerTypeRequired')
|
||||
expect(form.mainErrors.errors.volumeM3).toBe('transport.carriers.form.errors.volumeRequired')
|
||||
})
|
||||
|
||||
it('front RG-4.03 : affrété avec tous les champs remplis → POST envoyé', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 8, name: 'ACME', certificationType: 'GMP_PLUS' })
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
form.main.isChartered = true
|
||||
form.main.indexationRate = '5'
|
||||
form.main.containerType = 'BENNE'
|
||||
form.main.volumeM3 = '30'
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
expect(mockPost.mock.calls[0]?.[1]).toMatchObject({
|
||||
isChartered: true,
|
||||
indexationRate: '5',
|
||||
containerType: 'BENNE',
|
||||
volumeM3: '30',
|
||||
})
|
||||
})
|
||||
|
||||
it('POST /carriers avec Accept ld+json, verrouille et bascule sur Qualimat', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 42, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' })
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Transports Acme'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/carriers')
|
||||
expect(body).toEqual({
|
||||
name: 'Transports Acme',
|
||||
certificationType: 'GMP_PLUS',
|
||||
isChartered: false,
|
||||
})
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
|
||||
expect(form.carrierId.value).toBe(42)
|
||||
// RG-4.13 : réaffiche le nom normalisé (UPPERCASE) renvoyé par le serveur.
|
||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
||||
expect(form.mainLocked.value).toBe(true)
|
||||
// L'onglet Qualimat était déjà accessible (saisie assistée) ; le POST
|
||||
// déverrouille Adresses (index 1) et bascule dessus.
|
||||
expect(form.activeTab.value).toBe('addresses')
|
||||
expect(form.unlockedIndex.value).toBe(1)
|
||||
})
|
||||
|
||||
it('buildMainPayload : omet certificationType vide, garde isChartered', () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'X'
|
||||
|
||||
const body = form.buildMainPayload()
|
||||
expect(body).toEqual({ name: 'X', isChartered: false })
|
||||
expect('certificationType' in body).toBe(false)
|
||||
})
|
||||
|
||||
it('409 doublon (RG-4.12) : erreur inline dédiée sur name, pas de verrouillage', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Doublon'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.duplicateName')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('422 : mappe les violations serveur inline par champ', async () => {
|
||||
// Contrainte re-validée uniquement back (ex. longueur du nom) : le pré-check
|
||||
// front passe (nom rempli, certif choisie, non affrété), la 422 mappe inline
|
||||
// sur le champ via son propertyPath.
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'name', message: 'Le nom du transporteur ne peut dépasser 255 caractères.' }] },
|
||||
},
|
||||
})
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(form.mainErrors.errors.name).toBe('Le nom du transporteur ne peut dépasser 255 caractères.')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('onglets : 4 clés Qualimat/Adresses/Contacts/Prix', () => {
|
||||
expect(CARRIER_TAB_KEYS).toEqual(['qualimat', 'addresses', 'contacts', 'prices'])
|
||||
const form = useCarrierForm()
|
||||
expect(form.tabKeys.value).toEqual(['qualimat', 'addresses', 'contacts', 'prices'])
|
||||
// L'onglet Qualimat (index 0) est accessible dès le départ (saisie assistée) ;
|
||||
// Adresses / Contacts / Prix restent verrouillés jusqu'au POST principal.
|
||||
expect(form.unlockedIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('completeTab : déverrouille/avance, et signale le dernier onglet du flux', () => {
|
||||
const form = useCarrierForm()
|
||||
|
||||
// Qualimat → Adresses (pas le dernier).
|
||||
expect(form.completeTab('qualimat')).toBe(false)
|
||||
expect(form.isValidated('qualimat')).toBe(true)
|
||||
expect(form.activeTab.value).toBe('addresses')
|
||||
expect(form.unlockedIndex.value).toBe(1)
|
||||
|
||||
expect(form.completeTab('addresses')).toBe(false)
|
||||
expect(form.activeTab.value).toBe('contacts')
|
||||
|
||||
expect(form.completeTab('contacts')).toBe(false)
|
||||
expect(form.activeTab.value).toBe('prices')
|
||||
|
||||
// Prix = dernier onglet → true (création terminée).
|
||||
expect(form.completeTab('prices')).toBe(true)
|
||||
expect(form.isValidated('prices')).toBe(true)
|
||||
})
|
||||
|
||||
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
|
||||
const form = useCarrierForm()
|
||||
form.editMode.value = true
|
||||
form.activeTab.value = 'qualimat'
|
||||
|
||||
expect(form.completeTab('qualimat')).toBe(false)
|
||||
expect(form.isValidated('qualimat')).toBe(false)
|
||||
expect(form.activeTab.value).toBe('qualimat')
|
||||
})
|
||||
|
||||
it('patchCarrier : PATCH /carriers/{id} en mode strict, no-op avant création', async () => {
|
||||
const form = useCarrierForm()
|
||||
|
||||
await form.patchCarrier({ liotPlates: 'AA-123-BB' })
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
|
||||
mockPost.mockResolvedValueOnce({ id: 9, name: 'ACME', certificationType: 'OVOCOM' })
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'OVOCOM'
|
||||
await form.submitMain()
|
||||
|
||||
await form.patchCarrier({ liotPlates: 'AA-123-BB' })
|
||||
expect(mockPatch).toHaveBeenCalledWith('/carriers/9', { liotPlates: 'AA-123-BB' }, { toast: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCarrierForm — champs conditionnels (ERP-166)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
})
|
||||
|
||||
it('cas LIOT (insensible à la casse) : masque la certification, payload réduit', () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'liot'
|
||||
|
||||
expect(form.isLiot.value).toBe(true)
|
||||
expect(form.showCertification.value).toBe(false)
|
||||
|
||||
form.main.liotPlates = 'AA-123-BB ; CC-456-DD'
|
||||
expect(form.buildMainPayload()).toEqual({
|
||||
name: 'liot',
|
||||
isChartered: false,
|
||||
liotPlates: 'AA-123-BB ; CC-456-DD',
|
||||
})
|
||||
})
|
||||
|
||||
it('LIOT masque les champs conditionnels (affrètement / décharge)', () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'LIOT'
|
||||
form.main.isChartered = true
|
||||
form.main.certificationType = 'AUTRE'
|
||||
|
||||
expect(form.showCharteredFields.value).toBe(false)
|
||||
expect(form.showDischarge.value).toBe(false)
|
||||
})
|
||||
|
||||
it('RG-4.03 affrété : indexation / contenant / volume visibles et dans le payload', () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
form.main.isChartered = true
|
||||
|
||||
expect(form.showCharteredFields.value).toBe(true)
|
||||
|
||||
form.main.indexationRate = '5'
|
||||
form.main.containerType = 'BENNE'
|
||||
form.main.volumeM3 = '30'
|
||||
|
||||
expect(form.buildMainPayload()).toEqual({
|
||||
name: 'Acme',
|
||||
certificationType: 'GMP_PLUS',
|
||||
isChartered: true,
|
||||
indexationRate: '5',
|
||||
containerType: 'BENNE',
|
||||
volumeM3: '30',
|
||||
})
|
||||
})
|
||||
|
||||
it('RG-4.03 affrété mais champs vides : omis du payload (422 NotBlank back)', () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
form.main.isChartered = true
|
||||
|
||||
expect(form.buildMainPayload()).toEqual({
|
||||
name: 'Acme',
|
||||
certificationType: 'GMP_PLUS',
|
||||
isChartered: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('RG-4.02 AUTRE : décharge visible + dischargeDocument dans le payload si IRI résolu', () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'AUTRE'
|
||||
|
||||
expect(form.showDischarge.value).toBe(true)
|
||||
|
||||
form.main.dischargeDocumentIri = '/api/uploaded_documents/7'
|
||||
expect(form.buildMainPayload()).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
|
||||
const QUALIMAT_ROW = {
|
||||
'@id': '/api/qualimat_carriers/42',
|
||||
id: '42',
|
||||
name: 'TRANSPORTS QUALIMAT',
|
||||
siret: '12345678900012',
|
||||
address: '1 rue du Port',
|
||||
postalCode: '86000',
|
||||
city: 'Poitiers',
|
||||
validityDate: '2027-01-15',
|
||||
status: 'VALIDE',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
})
|
||||
|
||||
it('copie name + certificationType=QUALIMAT (readonly) + IRI + adresse, sans PATCH avant création', async () => {
|
||||
const form = useCarrierForm()
|
||||
|
||||
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
expect(form.main.name).toBe('TRANSPORTS QUALIMAT')
|
||||
expect(form.main.certificationType).toBe('QUALIMAT')
|
||||
expect(form.main.qualimatCarrierIri).toBe('/api/qualimat_carriers/42')
|
||||
expect(form.isQualimat.value).toBe(true)
|
||||
expect(form.certificationReadonly.value).toBe(true)
|
||||
expect(form.qualimatAddress.value).toEqual({
|
||||
country: 'France',
|
||||
postalCode: '86000',
|
||||
city: 'Poitiers',
|
||||
street: '1 rue du Port',
|
||||
})
|
||||
})
|
||||
|
||||
it('après création : PATCH /carriers/{id} avec qualimatCarrier + name + certification', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 9, name: 'X', certificationType: 'GMP_PLUS' })
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'X'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
await form.submitMain()
|
||||
|
||||
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/carriers/9',
|
||||
{
|
||||
qualimatCarrier: '/api/qualimat_carriers/42',
|
||||
name: 'TRANSPORTS QUALIMAT',
|
||||
certificationType: 'QUALIMAT',
|
||||
},
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('après création : PATCH en échec → pas de copie locale (rollback) et retour false', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 9, name: 'X', certificationType: 'GMP_PLUS' })
|
||||
mockPatch.mockRejectedValueOnce({ response: { status: 500, _data: {} } })
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'X'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
await form.submitMain()
|
||||
|
||||
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
|
||||
|
||||
// Échec serveur : l'UI ne doit pas refléter une intégration QUALIMAT non persistée.
|
||||
expect(ok).toBe(false)
|
||||
expect(form.main.name).toBe('X')
|
||||
expect(form.main.certificationType).toBe('GMP_PLUS')
|
||||
expect(form.main.qualimatCarrierIri).toBeNull()
|
||||
})
|
||||
|
||||
it('buildMainPayload inclut qualimatCarrier + certificationType QUALIMAT après intégration', async () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
await form.applyQualimatSelection(QUALIMAT_ROW)
|
||||
|
||||
expect(form.buildMainPayload()).toMatchObject({
|
||||
qualimatCarrier: '/api/qualimat_carriers/42',
|
||||
certificationType: 'QUALIMAT',
|
||||
})
|
||||
})
|
||||
|
||||
it('applyQualimatSelection pré-remplit le 1er bloc d\'adresse (RG-4.05)', async () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
|
||||
await form.applyQualimatSelection(QUALIMAT_ROW)
|
||||
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
expect(form.addresses.value[0]).toEqual({
|
||||
id: null,
|
||||
country: 'France',
|
||||
postalCode: '86000',
|
||||
city: 'Poitiers',
|
||||
street: '1 rue du Port',
|
||||
streetComplement: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockDelete.mockReset()
|
||||
})
|
||||
|
||||
/** Transporteur créé, onglet Adresses accessible. */
|
||||
function createdForm() {
|
||||
const form = useCarrierForm()
|
||||
form.carrierId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit un bloc adresse complet (CP + ville + rue). */
|
||||
function fillAddress(form: ReturnType<typeof useCarrierForm>, index = 0): void {
|
||||
const a = form.addresses.value[index]
|
||||
if (a) {
|
||||
a.postalCode = '86100'
|
||||
a.city = 'Châtellerault'
|
||||
a.street = '1 rue du Test'
|
||||
}
|
||||
}
|
||||
|
||||
it('canAddAddress : désactivé tant que la dernière adresse est incomplète', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddAddress.value).toBe(false)
|
||||
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(1) // no-op tant qu'incomplète
|
||||
|
||||
fillAddress(form)
|
||||
expect(form.canAddAddress.value).toBe(true)
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('submitAddresses : POST des nouvelles adresses, capture l\'id, finalise l\'onglet', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 88 })
|
||||
const form = createdForm()
|
||||
fillAddress(form)
|
||||
|
||||
const ok = await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/carriers/7/addresses')
|
||||
expect(body).toEqual({
|
||||
country: 'France',
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
streetComplement: null,
|
||||
})
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(form.addresses.value[0]?.id).toBe(88)
|
||||
expect(form.isValidated('addresses')).toBe(true)
|
||||
})
|
||||
|
||||
it('submitAddresses : PATCH des adresses existantes sur /carrier_addresses/{id}', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillAddress(form)
|
||||
const first = form.addresses.value[0]
|
||||
if (first) first.id = 88
|
||||
|
||||
await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith('/carrier_addresses/88', expect.objectContaining({ city: 'Châtellerault' }), { toast: false })
|
||||
})
|
||||
|
||||
it('submitAddresses : mappe les 422 PAR LIGNE et ne finalise pas l\'onglet (RG-4.05)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire pour un transporteur affrété.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
fillAddress(form)
|
||||
|
||||
const ok = await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire pour un transporteur affrété.')
|
||||
expect(form.isValidated('addresses')).toBe(false)
|
||||
})
|
||||
|
||||
it('removeAddress : DELETE /carrier_addresses/{id} puis retrait du bloc', async () => {
|
||||
mockDelete.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillAddress(form)
|
||||
const first = form.addresses.value[0]
|
||||
if (first) first.id = 88
|
||||
form.addAddress()
|
||||
fillAddress(form, 1)
|
||||
|
||||
await form.removeAddress(0)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/carrier_addresses/88', {}, { toast: false })
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useCarriersRepository, type Carrier } from '../useCarriersRepository'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests du repertoire transporteurs (ERP-164).
|
||||
*
|
||||
* `useCarriersRepository` est une fine enveloppe de `usePaginatedList<Carrier>`
|
||||
* sur `/carriers`. Les invariants generiques de pagination sont deja couverts par
|
||||
* `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
|
||||
* - la ressource ciblee est bien `/carriers` ;
|
||||
* - l'enveloppe Hydra (member / totalItems) est consommee ;
|
||||
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
||||
* renvoie un tableau plat sans pagination) ;
|
||||
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` n'est envoye
|
||||
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
|
||||
* archives) ; le filtre « Voir les archivés » est bien transmis une fois
|
||||
* applique (aligne sur Client / Fournisseur / Prestataire).
|
||||
*/
|
||||
describe('useCarriersRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
})
|
||||
|
||||
/** Une page de transporteurs Hydra, avec qualimatCarrier embarque (RG-4.04). */
|
||||
const PAGE: Carrier[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TRANSPORTS ACME',
|
||||
certificationType: 'QUALIMAT',
|
||||
qualimatCarrier: {
|
||||
id: '42',
|
||||
name: 'TRANSPORTS ACME',
|
||||
validityDate: '2027-01-15',
|
||||
status: 'VALIDE',
|
||||
},
|
||||
updatedAt: '2026-06-15T08:12:01+02:00',
|
||||
isArchived: false,
|
||||
},
|
||||
]
|
||||
|
||||
it('cible /carriers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useCarriersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/carriers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
})
|
||||
expect(repo.items.value).toEqual(PAGE)
|
||||
expect(repo.totalItems.value).toBe(1)
|
||||
})
|
||||
|
||||
it('exclut les archives par defaut : aucun archivedOnly au premier fetch', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useCarriersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(query.archivedOnly).toBeUndefined()
|
||||
})
|
||||
|
||||
it('transmet archivedOnly une fois le filtre applique (retour page 1)', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useCarriersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
await repo.setFilters({ archivedOnly: true })
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(query.archivedOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('transmet les certifications multiples + la recherche', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useCarriersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
await repo.setFilters({ search: 'acme', 'certificationType[]': ['QUALIMAT', 'AUTRE'] })
|
||||
|
||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(query.search).toBe('acme')
|
||||
expect(query['certificationType[]']).toEqual(['QUALIMAT', 'AUTRE'])
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useQualimatSearch, type QualimatCarrierRow } from '../useQualimatSearch'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests de la saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01).
|
||||
*
|
||||
* `useQualimatSearch` est une fine enveloppe de `usePaginatedList<QualimatCarrierRow>`
|
||||
* sur `/qualimat_carriers`. La pagination générique est couverte par
|
||||
* `usePaginatedList.test.ts` ; on vérifie ici le CONTRAT propre à la recherche :
|
||||
* - ressource ciblée `/qualimat_carriers` + enveloppe Hydra + `Accept: application/ld+json` ;
|
||||
* - le filtre `search` (branché sur le nom du transporteur) est transmis et
|
||||
* retombe en page 1.
|
||||
*/
|
||||
describe('useQualimatSearch', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
})
|
||||
|
||||
const PAGE: QualimatCarrierRow[] = [
|
||||
{
|
||||
'@id': '/api/qualimat_carriers/1',
|
||||
id: '1',
|
||||
name: 'TRANSPORTS ACME',
|
||||
siret: '12345678900012',
|
||||
address: '1 rue du Port',
|
||||
postalCode: '86000',
|
||||
city: 'Poitiers',
|
||||
validityDate: '2027-01-15',
|
||||
status: 'VALIDE',
|
||||
},
|
||||
]
|
||||
|
||||
it('cible /qualimat_carriers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useQualimatSearch()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/qualimat_carriers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(repo.items.value).toEqual(PAGE)
|
||||
expect(repo.totalItems.value).toBe(1)
|
||||
})
|
||||
|
||||
it('transmet le filtre `search` (nom du transporteur) et retombe en page 1', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useQualimatSearch()
|
||||
await repo.fetch()
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
await repo.setFilters({ search: 'acme' })
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(query.search).toBe('acme')
|
||||
})
|
||||
})
|
||||
@@ -1,493 +0,0 @@
|
||||
import { computed, reactive, ref, type Ref } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
import {
|
||||
emptyCarrierAddress,
|
||||
emptyCarrierAddressCopy,
|
||||
emptyCarrierMain,
|
||||
type CarrierAddressCopy,
|
||||
type CarrierAddressFormDraft,
|
||||
type CarrierMainDraft,
|
||||
type CarrierMainResponse,
|
||||
} from '~/modules/transport/types/carrierForm'
|
||||
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
|
||||
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||
|
||||
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
|
||||
const LIOT_NAME = 'LIOT'
|
||||
|
||||
/**
|
||||
* Workflow de l'écran « Ajouter un transporteur » (M4 Transport, ERP-165) —
|
||||
* miroir conceptuel de `useSupplierForm` (M2) / `useProviderForm` (M3).
|
||||
*
|
||||
* Périmètre ERP-165 : le formulaire PRINCIPAL (pré-onglets) et l'orchestration de
|
||||
* la barre d'onglets. La création est INCRÉMENTALE (spec-front § Écran Ajouter) :
|
||||
* - on POST d'abord le formulaire principal (`POST /api/carriers`) ;
|
||||
* - au succès le bloc principal passe en lecture seule, le 1er onglet (Qualimat)
|
||||
* se déverrouille et devient actif ;
|
||||
* - chaque onglet validé déverrouille le suivant (PATCH partiels par groupe de
|
||||
* sérialisation) et passe en lecture seule.
|
||||
*
|
||||
* Les champs conditionnels du formulaire principal (indexation / benne / volume
|
||||
* si affrété, décharge si AUTRE, cas LIOT) et la saisie assistée QUALIMAT arrivent
|
||||
* à ERP-166 ; le contenu des onglets Adresses / Contacts / Prix aux tickets
|
||||
* suivants. Ce composable pose le POST principal, le PATCH partiel et le gating
|
||||
* des onglets.
|
||||
*
|
||||
* État 100 % local à l'instance (refs / reactive) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clés des onglets du flux de création, dans l'ordre de la barre (spec-front
|
||||
* § Écran Ajouter). Toujours les 4 (pas de gating par permission au M4, ≠ l'onglet
|
||||
* Comptabilité du M3).
|
||||
*/
|
||||
export const CARRIER_TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices'] as const
|
||||
|
||||
export function useCarrierForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||
const mainErrors = useFormErrors()
|
||||
|
||||
// ── État du transporteur créé ─────────────────────────────────────────────
|
||||
const carrierId = ref<number | null>(null)
|
||||
const mainLocked = ref(false)
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
|
||||
// ── Formulaire principal ──────────────────────────────────────────────────
|
||||
const main = reactive<CarrierMainDraft>(emptyCarrierMain())
|
||||
|
||||
// Adresse copiée depuis QUALIMAT à la sélection (alimente l'onglet Adresses,
|
||||
// ticket ultérieur). Vide tant qu'aucun transporteur QUALIMAT n'est intégré.
|
||||
const qualimatAddress = ref<CarrierAddressCopy>(emptyCarrierAddressCopy())
|
||||
|
||||
// ── Affichage conditionnel du formulaire principal (RG-4.01 / 4.02 / 4.03) ──
|
||||
// Cas LIOT : nom == « LIOT » → seul `liotPlates` est pertinent, le reste masqué.
|
||||
const isLiot = computed(() => main.name.trim().toUpperCase() === LIOT_NAME)
|
||||
// Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ».
|
||||
const isQualimat = computed(() => main.qualimatCarrierIri !== null)
|
||||
// Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé).
|
||||
const showCertification = computed(() => !isLiot.value)
|
||||
const certificationReadonly = computed(() => isQualimat.value || mainLocked.value)
|
||||
// RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et
|
||||
// obligatoires si « Affréter » coché — masqués en cas LIOT.
|
||||
const showCharteredFields = computed(() => main.isChartered && !isLiot.value)
|
||||
// RG-4.02 : décharge visible et obligatoire si certification == AUTRE (hors LIOT).
|
||||
const showDischarge = computed(() => main.certificationType === 'AUTRE' && !isLiot.value)
|
||||
|
||||
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
||||
const tabKeys = ref<string[]>([...CARRIER_TAB_KEYS])
|
||||
// Index du dernier onglet déverrouillé. L'onglet Qualimat (index 0) est la saisie
|
||||
// assistée du formulaire principal : accessible DÈS LE DÉPART (≠ Adresses /
|
||||
// Contacts / Prix, déverrouillés seulement après le POST principal).
|
||||
const unlockedIndex = ref(0)
|
||||
const activeTab = ref<string>(CARRIER_TAB_KEYS[0])
|
||||
// Onglets validés (passent en lecture seule).
|
||||
const validated = reactive<Record<string, boolean>>({})
|
||||
// Mode MODIFICATION (ticket ultérieur) : navigation libre, pas de verrouillage
|
||||
// ni de bascule automatique d'onglet à la validation (cf. completeTab).
|
||||
const editMode = ref(false)
|
||||
|
||||
function isValidated(key: string): boolean {
|
||||
return validated[key] === true
|
||||
}
|
||||
|
||||
function tabIndex(key: string): number {
|
||||
return tabKeys.value.indexOf(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation FRONT du formulaire principal : seul le nom est requis côté front
|
||||
* (ERP-101) : feedback immédiat sur tous les champs obligatoires (y compris
|
||||
* conditionnels), alignés sur les RG du back (qui reste autoritaire) :
|
||||
* - RG-4.01 : nom requis ; certification requise hors cas LIOT (où tout est masqué) ;
|
||||
* - RG-4.02 : décharge requise si certification AUTRE ;
|
||||
* - RG-4.03 : indexation + contenant + volume requis si « Affréter ».
|
||||
*/
|
||||
function validateMainFront(): boolean {
|
||||
let valid = true
|
||||
if (!main.name?.trim()) {
|
||||
mainErrors.setError('name', t('transport.carriers.form.errors.nameRequired'))
|
||||
valid = false
|
||||
}
|
||||
|
||||
// Cas LIOT : seul le nom compte, les autres champs sont masqués (RG-4.01).
|
||||
if (isLiot.value) {
|
||||
return valid
|
||||
}
|
||||
|
||||
// RG-4.01 : certification obligatoire hors LIOT.
|
||||
if (!main.certificationType) {
|
||||
mainErrors.setError('certificationType', t('transport.carriers.form.errors.certificationRequired'))
|
||||
valid = false
|
||||
}
|
||||
|
||||
// RG-4.02 : décharge obligatoire si certification AUTRE.
|
||||
if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri) {
|
||||
mainErrors.setError('dischargeDocument', t('transport.carriers.form.errors.dischargeRequired'))
|
||||
valid = false
|
||||
}
|
||||
|
||||
// RG-4.03 : indexation / contenant / volume obligatoires si affrété.
|
||||
if (main.isChartered) {
|
||||
if (!main.indexationRate.trim()) {
|
||||
mainErrors.setError('indexationRate', t('transport.carriers.form.errors.indexationRequired'))
|
||||
valid = false
|
||||
}
|
||||
if (!main.containerType) {
|
||||
mainErrors.setError('containerType', t('transport.carriers.form.errors.containerTypeRequired'))
|
||||
valid = false
|
||||
}
|
||||
if (!main.volumeM3.trim()) {
|
||||
mainErrors.setError('volumeM3', t('transport.carriers.form.errors.volumeRequired'))
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
||||
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
||||
* violation métier (NotBlank sur le nom, « certification obligatoire » sur la
|
||||
* certification) sur le champ plutôt qu'une erreur de type.
|
||||
*/
|
||||
function buildMainPayload(): Record<string, unknown> {
|
||||
// Cas LIOT (RG-4.01) : seul `liotPlates` est pertinent ; les autres champs
|
||||
// sont masqués côté front et non envoyés (le back stocke ce qu'il reçoit).
|
||||
if (isLiot.value) {
|
||||
const payload: Record<string, unknown> = { name: main.name, isChartered: false }
|
||||
if (main.liotPlates.trim()) {
|
||||
payload.liotPlates = main.liotPlates
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = { isChartered: main.isChartered }
|
||||
if (main.name.trim()) {
|
||||
payload.name = main.name
|
||||
}
|
||||
if (main.certificationType) {
|
||||
payload.certificationType = main.certificationType
|
||||
}
|
||||
// FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée.
|
||||
if (main.qualimatCarrierIri) {
|
||||
payload.qualimatCarrier = main.qualimatCarrierIri
|
||||
}
|
||||
// RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand
|
||||
// absente pour que la 422 « obligatoire » porte sur le champ.
|
||||
if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) {
|
||||
payload.dischargeDocument = main.dischargeDocumentIri
|
||||
}
|
||||
// RG-4.03 : indexation / contenant / volume envoyés seulement si affrété ;
|
||||
// omis quand vides pour déclencher la 422 NotBlank inline sur le champ.
|
||||
if (main.isChartered) {
|
||||
if (main.indexationRate.trim()) {
|
||||
payload.indexationRate = main.indexationRate
|
||||
}
|
||||
if (main.containerType) {
|
||||
payload.containerType = main.containerType
|
||||
}
|
||||
if (main.volumeM3.trim()) {
|
||||
payload.volumeM3 = main.volumeM3
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /carriers (groupe `carrier:write:main`). Pré-check front, puis création.
|
||||
* Au succès : verrouille le bloc principal, déverrouille l'onglet Adresses et
|
||||
* bascule dessus (l'onglet Qualimat, saisie assistée, était déjà accessible).
|
||||
* Retourne true si créé, false sinon.
|
||||
*/
|
||||
async function submitMain(): Promise<boolean> {
|
||||
if (mainSubmitting.value) return false
|
||||
mainErrors.clearErrors()
|
||||
if (!validateMainFront()) return false
|
||||
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
const created = await api.post<CarrierMainResponse>('/carriers', buildMainPayload(), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
|
||||
carrierId.value = created.id
|
||||
// Réaffiche les valeurs normalisées renvoyées par le serveur (nom en
|
||||
// UPPERCASE — RG-4.13 ; certification éventuellement forcée).
|
||||
main.name = created.name ?? main.name
|
||||
main.certificationType = created.certificationType ?? main.certificationType
|
||||
|
||||
mainLocked.value = true
|
||||
// Déverrouille l'onglet suivant (Adresses, index 1) et bascule dessus.
|
||||
unlockedIndex.value = Math.max(unlockedIndex.value, 1)
|
||||
activeTab.value = tabKeys.value[1] ?? CARRIER_TAB_KEYS[1]
|
||||
toast.success({ title: t('transport.carriers.toast.createSuccess') })
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
// 409 = doublon de nom (RG-4.12) → erreur inline dédiée + 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('transport.carriers.form.duplicateName')
|
||||
mainErrors.setError('name', message)
|
||||
toast.error({ title: t('transport.carriers.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH partiel du transporteur (mode strict : un seul groupe de sérialisation
|
||||
* par appel — spec-back § 2.9). Servira les onglets à champs scalaires des
|
||||
* tickets suivants. No-op tant que le transporteur n'existe pas.
|
||||
*/
|
||||
async function patchCarrier(payload: Record<string, unknown>): Promise<void> {
|
||||
if (carrierId.value === null) return
|
||||
await api.patch(`/carriers/${carrierId.value}`, payload, { toast: false })
|
||||
}
|
||||
|
||||
/** Remontée d'erreur de suppression immédiate d'une sous-ressource (toast dédié). */
|
||||
function notifyRemovalError(error: unknown): void {
|
||||
toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('transport.carriers.toast.error'),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
|
||||
* on n'arrête pas au premier bloc en échec (décision ERP-101). Réinitialise la
|
||||
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou délègue le
|
||||
* fallback à `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
|
||||
* true si au moins un bloc a échoué. Miroir de `useProviderForm.submitRows`.
|
||||
*/
|
||||
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) {
|
||||
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
|
||||
}
|
||||
else {
|
||||
onUnmappedError(error, index)
|
||||
}
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
return hasError
|
||||
}
|
||||
|
||||
// ── Onglet Adresses (ERP-167) ─────────────────────────────────────────────
|
||||
const addresses = ref<CarrierAddressFormDraft[]>([emptyCarrierAddress()])
|
||||
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
||||
const addressErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouvelle adresse » désactivé tant que la dernière adresse n'est pas
|
||||
// complète (CP + ville + rue — RG-4.05, gate d'ajout).
|
||||
const canAddAddress = computed(() => {
|
||||
const last = addresses.value[addresses.value.length - 1]
|
||||
return last !== undefined && isCarrierAddressValid(last)
|
||||
})
|
||||
|
||||
function addAddress(): void {
|
||||
if (canAddAddress.value) {
|
||||
addresses.value.push(emptyCarrierAddress())
|
||||
}
|
||||
}
|
||||
|
||||
/** Suppression immédiate d'une adresse existante (DELETE /carrier_addresses/{id}). */
|
||||
async function removeAddress(index: number): Promise<void> {
|
||||
await removeCollectionRow({
|
||||
rows: addresses.value,
|
||||
errors: addressErrors.value,
|
||||
index,
|
||||
endpoint: '/carrier_addresses',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyCarrierAddress,
|
||||
onError: notifyRemovalError,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Adresses : POST des nouvelles adresses sur
|
||||
* /carriers/{id}/addresses, PATCH des existantes sur /carrier_addresses/{id}
|
||||
* (groupe carrier:write:addresses). Erreurs 422 collectées par ligne (RG-4.05
|
||||
* « obligatoire si affrété » re-validée back). Retourne true si l'onglet a été
|
||||
* validé (avancé/terminé).
|
||||
*/
|
||||
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (carrierId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
addressErrors,
|
||||
async (address) => {
|
||||
const body = buildCarrierAddressPayload(address)
|
||||
if (address.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/carriers/${carrierId.value}/addresses`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
address.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/carrier_addresses/${address.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onError,
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
}
|
||||
completeTab('addresses')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intègre une ligne QUALIMAT sélectionnée dans l'onglet Qualimat (RG-4.01 /
|
||||
* § 2.5) : copie le nom, force la certification à « QUALIMAT » (lecture seule),
|
||||
* pose la FK `qualimatCarrier` (IRI) et copie l'adresse (pour l'onglet Adresses).
|
||||
* Si le transporteur existe déjà (post-POST, cas nominal de l'onglet), persiste
|
||||
* d'abord la copie via un PATCH partiel `carrier:write:main` : la copie locale
|
||||
* (nom, certification figée « QUALIMAT », FK, adresse) n'est appliquée qu'en cas
|
||||
* de succès, pour ne pas laisser l'UI dans un état QUALIMAT non sauvegardé si le
|
||||
* PATCH échoue. Retourne true si l'intégration a abouti.
|
||||
*/
|
||||
async function applyQualimatSelection(row: QualimatCarrierRow): Promise<boolean> {
|
||||
// Transporteur déjà créé : on persiste avant de refléter localement.
|
||||
if (carrierId.value !== null) {
|
||||
try {
|
||||
await patchCarrier({
|
||||
qualimatCarrier: row['@id'],
|
||||
name: row.name,
|
||||
certificationType: 'QUALIMAT',
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
main.name = row.name ?? ''
|
||||
main.certificationType = 'QUALIMAT'
|
||||
main.qualimatCarrierIri = row['@id']
|
||||
qualimatAddress.value = {
|
||||
country: 'France',
|
||||
postalCode: row.postalCode ?? '',
|
||||
city: row.city ?? '',
|
||||
street: row.address ?? '',
|
||||
}
|
||||
// RG-4.05 : pré-remplit le 1er bloc de l'onglet Adresses par copie (la FK
|
||||
// QUALIMAT survit, les champs restent éditables — § 2.5).
|
||||
addresses.value = [{
|
||||
id: null,
|
||||
country: 'France',
|
||||
postalCode: row.postalCode || null,
|
||||
city: row.city || null,
|
||||
street: row.address || null,
|
||||
streetComplement: null,
|
||||
}]
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un onglet validé (passe en lecture seule), déverrouille et avance à
|
||||
* l'onglet suivant. Retourne true si c'était le dernier onglet du flux (création
|
||||
* terminée), false sinon.
|
||||
*/
|
||||
function completeTab(key: string): boolean {
|
||||
// En modification : navigation libre, l'onglet reste éditable après validation.
|
||||
if (editMode.value) {
|
||||
return false
|
||||
}
|
||||
validated[key] = true
|
||||
const index = tabIndex(key)
|
||||
const next = tabKeys.value[index + 1]
|
||||
if (next === undefined) {
|
||||
return true
|
||||
}
|
||||
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
|
||||
activeTab.value = next
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
// état
|
||||
main,
|
||||
qualimatAddress,
|
||||
carrierId,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
tabSubmitting,
|
||||
mainErrors,
|
||||
// affichage conditionnel
|
||||
isLiot,
|
||||
isQualimat,
|
||||
showCertification,
|
||||
certificationReadonly,
|
||||
showCharteredFields,
|
||||
showDischarge,
|
||||
// onglets
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
validated,
|
||||
editMode,
|
||||
isValidated,
|
||||
// adresses
|
||||
addresses,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
// actions
|
||||
validateMainFront,
|
||||
buildMainPayload,
|
||||
submitMain,
|
||||
patchCarrier,
|
||||
applyQualimatSelection,
|
||||
completeTab,
|
||||
submitRows,
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Vue MINIMALE du referentiel QUALIMAT embarque (groupe `qualimat:read`) dans la
|
||||
* LISTE des transporteurs. Seuls les champs consommes par le Repertoire sont
|
||||
* types : `validityDate` alimente la colonne « Date de validité » (fond rouge si
|
||||
* perimee — RG-4.04). L'id QUALIMAT est une chaine (colonne BIGINT cote back).
|
||||
*/
|
||||
export interface CarrierQualimat {
|
||||
id: string
|
||||
name: string | null
|
||||
/** Date ISO de validite de l'agrement QUALIMAT (date_immutable) — RG-4.04. */
|
||||
validityDate: string | null
|
||||
status: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'un transporteur pour le Repertoire (datatable). Volontairement
|
||||
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
||||
* Le detail complet (onglets Adresses / Contacts / Prix) est hors perimetre de
|
||||
* cet ecran (ERP-164, ticket #9).
|
||||
*
|
||||
* `certificationType` : QUALIMAT | GMP_PLUS | OVOCOM | COMPTE_PROPRE | AUTRE, ou
|
||||
* `null` dans le cas LIOT (compte-propre interne sans certification — RG-4.01).
|
||||
* Le libelle affiche est resolu cote front (cle i18n `transport.carriers.certification.*`).
|
||||
*/
|
||||
export interface Carrier {
|
||||
id: number
|
||||
name: string | null
|
||||
certificationType: string | null
|
||||
/** Lien editable vers le referentiel QUALIMAT (null si transporteur non QUALIMAT). */
|
||||
qualimatCarrier: CarrierQualimat | null
|
||||
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||
updatedAt: string | null
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtres du Repertoire transporteurs, branches sur les query params de
|
||||
* `GET /api/carriers` (spec-back § 4.1). Pilotes par la page via `setFilters` :
|
||||
* - `search` : recherche fuzzy sur le nom ;
|
||||
* - `certificationType[]` : multi-valeurs (OR cote back) ;
|
||||
* - `archivedOnly` : n'affiche QUE les archives (toggle « Voir les archivés »,
|
||||
* aligne sur les autres repertoires M1/M2/M3).
|
||||
*/
|
||||
export interface CarrierFilters {
|
||||
search?: string
|
||||
'certificationType[]'?: string[]
|
||||
archivedOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Repertoire transporteurs (M4, ERP-164) — simple enveloppe de
|
||||
* `usePaginatedList<Carrier>` sur la ressource `/carriers` (regle ABSOLUE n°13 :
|
||||
* pagination serveur obligatoire ; jamais de chargement integral en memoire).
|
||||
* Miroir de `useSuppliersRepository` (M2) / `useProvidersRepository` (M3).
|
||||
*
|
||||
* Les filtres (recherche, certifications, archives) sont pilotes par la page via
|
||||
* `setFilters` du composable partage — la remise en page 1 est garantie. Par
|
||||
* defaut AUCUN `archivedOnly` n'est envoye : le back masque alors les archives
|
||||
* (§ 2.4). Cocher « Voir les archivés » envoie `archivedOnly=true` (seules les
|
||||
* archives sont listees, aligne sur Client / Fournisseur / Prestataire).
|
||||
*
|
||||
* 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 useCarriersRepository() {
|
||||
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers' })
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Ligne du référentiel QUALIMAT renvoyée par la saisie assistée (groupe
|
||||
* `qualimat:read`). `@id` est l'IRI conservée comme FK `carrier.qualimatCarrier`
|
||||
* (RG-4.01 / § 2.5) ; `validityDate` pilote le fond rouge de la colonne « Date de
|
||||
* validité » (RG-4.04).
|
||||
*/
|
||||
export interface QualimatCarrierRow {
|
||||
'@id': string
|
||||
id: string
|
||||
name: string | null
|
||||
siret: string | null
|
||||
address: string | null
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
validityDate: string | null
|
||||
status: string | null
|
||||
}
|
||||
|
||||
/** Filtre de la recherche QUALIMAT (branché sur le nom du transporteur). */
|
||||
export interface QualimatSearchFilters {
|
||||
search?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01 / spec-back § 4.7).
|
||||
*
|
||||
* `GET /api/qualimat_carriers?search=` : référentiel en LECTURE SEULE, lignes
|
||||
* actives uniquement (filtré côté serveur), recherche fuzzy nom + siret. Simple
|
||||
* enveloppe de `usePaginatedList` (règle frontend : toute GetCollection passe par
|
||||
* ce composable — pagination Hydra, état 100 % local) consommée par le
|
||||
* `MalioDataTable` de l'onglet Qualimat. Le filtre `search` est piloté par le nom
|
||||
* saisi dans le formulaire principal (pas de champ de recherche dédié).
|
||||
*
|
||||
* Volontairement PAR INSTANCE (état local à l'écran d'ajout).
|
||||
*/
|
||||
export function useQualimatSearch() {
|
||||
return usePaginatedList<QualimatCarrierRow, QualimatSearchFilters>({ url: '/qualimat_carriers' })
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
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 specs M1/M2/M3.
|
||||
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('useCarriersRepository', () => ({
|
||||
items: ref([
|
||||
{
|
||||
id: 7,
|
||||
name: 'TRANSPORTS ACME',
|
||||
certificationType: 'QUALIMAT',
|
||||
qualimatCarrier: { id: '42', name: 'TRANSPORTS ACME', validityDate: '2027-01-15', status: 'VALIDE' },
|
||||
updatedAt: '2026-01-15T10:00:00+00:00',
|
||||
isArchived: false,
|
||||
},
|
||||
]),
|
||||
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 CarriersIndex = (await import('../carriers/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(CarriersIndex, {
|
||||
global: {
|
||||
stubs: {
|
||||
PageHeader: PageHeaderStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioDataTable: DataTableStub,
|
||||
MalioDrawer: DrawerStub,
|
||||
MalioAccordion: SlotStub,
|
||||
MalioAccordionItem: SlotStub,
|
||||
MalioInputText: InputTextStub,
|
||||
MalioCheckbox: CheckboxStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Répertoire transporteurs (page /carriers)', () => {
|
||||
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 === 'transport.carriers.manage')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="transport.carriers.add"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'transport.carriers.view')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="transport.carriers.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('/carriers/7')
|
||||
})
|
||||
|
||||
it('appelle l\'export XLSX sur /carriers/export.xlsx en blob', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-label="transport.carriers.export"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/carriers/export.xlsx',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
// Coche « Voir les archivés » puis applique les filtres.
|
||||
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
||||
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ archivedOnly: true },
|
||||
{ replace: true },
|
||||
)
|
||||
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repercute les certifications cochees dans setFilters (filtre multi)', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
// Coche deux certifications via les cases a cocher (pattern repertoire clients).
|
||||
await wrapper.find('input[data-id="filter-certification-QUALIMAT"]').setValue(true)
|
||||
await wrapper.find('input[data-id="filter-certification-AUTRE"]').setValue(true)
|
||||
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ 'certificationType[]': ['QUALIMAT', 'AUTRE'] },
|
||||
{ replace: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
||||
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
|
||||
|
||||
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||
expect(wrapper.find('[data-label="transport.carriers.filters.title (1)"]').exists()).toBe(true)
|
||||
|
||||
// Réinitialiser → query propre (setFilters avec objet vide).
|
||||
await wrapper.find('[data-label="transport.carriers.filters.reset"]').trigger('click')
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
|
||||
})
|
||||
})
|
||||
@@ -1,389 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('transport.carriers.title') }}
|
||||
<template #actions>
|
||||
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Bouton Filtrer 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('transport.carriers.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList via useCarriersRepository :
|
||||
pagination serveur, tri name ASC par defaut (cote back). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="t('transport.carriers.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Certification : libelle i18n (le back renvoie le code enum). -->
|
||||
<template #cell-certificationType="{ item }">
|
||||
{{ formatCertification(item) }}
|
||||
</template>
|
||||
|
||||
<!-- Date de validite QUALIMAT : fond rouge si perimee (< aujourd'hui — RG-4.04). -->
|
||||
<template #cell-validityDate="{ item }">
|
||||
<span
|
||||
v-if="getValidityDate(item)"
|
||||
:class="isValidityExpired(item) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
||||
>
|
||||
{{ formatDateFr(getValidityDate(item)) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Derniere activite : date de derniere modification (updatedAt). -->
|
||||
<template #cell-lastActivity="{ item }">
|
||||
{{ formatDateFr(item.updatedAt as string | null) }}
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Voir les résultats ». Meme pattern que les repertoires M1/M2/M3.
|
||||
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('transport.carriers.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche : nom du transporteur (param `search`). -->
|
||||
<MalioAccordionItem :title="t('transport.carriers.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Certification : cases a cocher (multi). Valeur = code enum.
|
||||
Meme pattern que le filtre Categories du repertoire clients. -->
|
||||
<MalioAccordionItem :title="t('transport.carriers.filters.certification')" value="certification">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in certificationOptions"
|
||||
:id="`filter-certification-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftCertificationTypes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleCertification(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Statut : voir uniquement les archives (sinon actifs uniquement). -->
|
||||
<MalioAccordionItem :title="t('transport.carriers.filters.status')" value="status">
|
||||
<MalioCheckbox
|
||||
id="filter-archived-only"
|
||||
:label="t('transport.carriers.filters.archivedOnly')"
|
||||
:model-value="draftArchivedOnly"
|
||||
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('transport.carriers.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('transport.carriers.title') })
|
||||
|
||||
// Bouton « Ajouter » reserve a `manage` (Admin + Bureau). « Exporter » et
|
||||
// « Filtrer » suivent `view` (Admin / Bureau / Commerciale). Compta et Usine
|
||||
// n'ont aucun acces (item sidebar masque cote back).
|
||||
const canManage = computed(() => can('transport.carriers.manage'))
|
||||
const canView = computed(() => can('transport.carriers.view'))
|
||||
|
||||
const {
|
||||
items: carriers,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadCarriers,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = useCarriersRepository()
|
||||
|
||||
// Mappe les transporteurs en objets « plats » pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||
// implicite, contrairement a l'interface Carrier. Meme pattern que M1/M2/M3.
|
||||
const rows = computed(() => carriers.value.map(carrier => ({
|
||||
id: carrier.id,
|
||||
name: carrier.name,
|
||||
certificationType: carrier.certificationType,
|
||||
validityDate: carrier.qualimatCarrier?.validityDate ?? null,
|
||||
updatedAt: carrier.updatedAt,
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('transport.carriers.column.name') },
|
||||
{ key: 'certificationType', label: t('transport.carriers.column.certification') },
|
||||
{ key: 'validityDate', label: t('transport.carriers.column.validityDate') },
|
||||
{ key: 'lastActivity', label: t('transport.carriers.column.lastActivity') },
|
||||
]
|
||||
|
||||
// Codes de certification (miroir de l'enum back) + cas LIOT (null). Le libelle
|
||||
// est resolu par i18n ; un code inconnu retombe sur le code brut.
|
||||
const CERTIFICATION_CODES = ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
||||
|
||||
const certificationOptions = computed<FilterOption[]>(() =>
|
||||
CERTIFICATION_CODES.map(code => ({
|
||||
value: code,
|
||||
label: t(`transport.carriers.certification.${code}`),
|
||||
})),
|
||||
)
|
||||
|
||||
/** Libelle i18n de la certification (vide en cas LIOT — certificationType null). */
|
||||
function formatCertification(item: Record<string, unknown>): string {
|
||||
const code = item.certificationType as string | null | undefined
|
||||
if (!code) {
|
||||
return ''
|
||||
}
|
||||
return t(`transport.carriers.certification.${code}`)
|
||||
}
|
||||
|
||||
/** Date de validite QUALIMAT de la ligne (null si transporteur non QUALIMAT). */
|
||||
function getValidityDate(item: Record<string, unknown>): string | null {
|
||||
return (item.validityDate as string | null | undefined) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.04 : un agrement QUALIMAT est perime si sa date de validite est anterieure
|
||||
* a la date du jour (comparaison jour a jour, sans l'heure).
|
||||
*/
|
||||
function isValidityExpired(item: Record<string, unknown>): boolean {
|
||||
const value = getValidityDate(item)
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return false
|
||||
}
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime() < today.getTime()
|
||||
}
|
||||
|
||||
/** Format court francais JJ-MM-AAAA (spec M4). Chaine vide si date absente / invalide. */
|
||||
function formatDateFr(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}-${month}-${date.getFullYear()}`
|
||||
}
|
||||
|
||||
/** Clic sur une ligne → ecran Consultation (route a plat /carriers/{id}). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/carriers/${item.id}`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/carriers/new')
|
||||
}
|
||||
|
||||
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern repertoires M1/M2/M3) :
|
||||
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||
const filterDrawerOpen = ref(false)
|
||||
|
||||
const draftSearch = ref('')
|
||||
const draftCertificationTypes = ref<string[]>([])
|
||||
const draftArchivedOnly = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCertificationTypes = ref<string[]>([])
|
||||
const appliedArchivedOnly = ref(false)
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCertificationTypes.value.length > 0) count++
|
||||
if (appliedArchivedOnly.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('transport.carriers.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
|
||||
draftCertificationTypes.value = [...appliedCertificationTypes.value]
|
||||
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
/** Coche / decoche une certification dans le brouillon (filtre multi). */
|
||||
function toggleCertification(code: string, selected: boolean): void {
|
||||
draftCertificationTypes.value = selected
|
||||
? [...draftCertificationTypes.value, code]
|
||||
: draftCertificationTypes.value.filter(c => c !== code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
|
||||
* `certificationType[]` pour que PHP la parse en tableau (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 (appliedCertificationTypes.value.length > 0) payload['certificationType[]'] = [...appliedCertificationTypes.value]
|
||||
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||
return payload
|
||||
}
|
||||
|
||||
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
||||
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCertificationTypes.value = [...draftCertificationTypes.value]
|
||||
appliedArchivedOnly.value = draftArchivedOnly.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 = ''
|
||||
draftCertificationTypes.value = []
|
||||
draftArchivedOnly.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCertificationTypes.value = []
|
||||
appliedArchivedOnly.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
|
||||
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 (meme pattern M2/M3).
|
||||
const blob = await api.get<Blob>('/carriers/export.xlsx', buildFilterPayload(), {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'repertoire-transporteurs.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: t('transport.carriers.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(() => {
|
||||
loadCarriers()
|
||||
})
|
||||
</script>
|
||||
@@ -1,579 +0,0 @@
|
||||
<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('transport.carriers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('transport.carriers.form.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
||||
Champs conditionnels (ERP-166) : cas LIOT (nom == LIOT) → seul
|
||||
« immatriculations » ; certification AUTRE → champ Decharge ; Affreter
|
||||
coche → indexation / contenant / volume. La certification est en lecture
|
||||
seule pour un transporteur QUALIMAT (saisie assistee, onglet Qualimat). -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.name"
|
||||
:label="t('transport.carriers.form.main.name')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.name"
|
||||
/>
|
||||
|
||||
<!-- Cas LIOT : seul le champ immatriculations est pertinent. -->
|
||||
<MalioInputText
|
||||
v-if="isLiot"
|
||||
v-model="main.liotPlates"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
:hint="t('transport.carriers.form.main.liotPlatesHint')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.liotPlates"
|
||||
/>
|
||||
|
||||
<!-- Cas standard : certification + affretement + champs conditionnels. -->
|
||||
<template v-if="!isLiot">
|
||||
<MalioSelect
|
||||
:model-value="main.certificationType"
|
||||
:options="certificationOptions"
|
||||
:label="t('transport.carriers.form.main.certificationType')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="certificationReadonly"
|
||||
:error="mainErrors.errors.certificationType"
|
||||
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
|
||||
/>
|
||||
|
||||
<!-- Colonne 3 RÉSERVÉE à la Décharge (RG-4.02 : visible et obligatoire
|
||||
si certification AUTRE). Si elle n'apparaît pas, on garde la colonne
|
||||
vide (xl) pour qu'« Affréter » reste en colonne 4 de la ligne 1.
|
||||
L'upload reel (File → IRI via useUpload) arrive a ERP-171. -->
|
||||
<!-- TODO ERP-171 : brancher useUpload pour resoudre le File en IRI
|
||||
(main.dischargeDocumentIri). Le champ est deja visible/obligatoire. -->
|
||||
<MalioInputUpload
|
||||
v-if="showDischarge"
|
||||
:label="t('transport.carriers.form.main.discharge')"
|
||||
accept="application/pdf,image/*"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:clearable="true"
|
||||
:error="mainErrors.errors.dischargeDocument"
|
||||
@clear="main.dischargeDocumentIri = null"
|
||||
/>
|
||||
<div v-else class="hidden xl:block"></div>
|
||||
|
||||
<!-- « Affréter » : toujours en colonne 4 de la ligne 1 (colonne 3
|
||||
réservée à la décharge ci-dessus). Wrapper h-12 + centrage vertical
|
||||
pour aligner la case sur la ligne de champ des inputs/selects. -->
|
||||
<div class="flex h-12 items-center">
|
||||
<MalioCheckbox
|
||||
id="carrier-is-chartered"
|
||||
:label="t('transport.carriers.form.main.isChartered')"
|
||||
:model-value="main.isChartered"
|
||||
:readonly="mainLocked"
|
||||
:reserve-message-space="false"
|
||||
@update:model-value="(val: boolean) => main.isChartered = val"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RG-4.03 : champs d'affretement (ligne 2) visibles + obligatoires si
|
||||
« Affreter ». La ligne 1 étant pleine (4 colonnes), ils démarrent
|
||||
naturellement en colonne 1 de la ligne 2. -->
|
||||
<template v-if="showCharteredFields">
|
||||
<MalioInputNumber
|
||||
v-model="main.indexationRate"
|
||||
:label="t('transport.carriers.form.main.indexationRate')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.indexationRate"
|
||||
/>
|
||||
|
||||
<!-- Contenant : Benne / Fond mouvant (RG-4.03). -->
|
||||
<MalioSelect
|
||||
:model-value="main.containerType"
|
||||
:options="containerOptions"
|
||||
:label="t('transport.carriers.form.main.containerType')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.containerType"
|
||||
@update:model-value="(v: string | number | null) => main.containerType = v === null ? null : String(v)"
|
||||
/>
|
||||
|
||||
<MalioInputNumber
|
||||
v-model="main.volumeM3"
|
||||
:label="t('transport.carriers.form.main.volumeM3')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.volumeM3"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.form.submit')"
|
||||
:disabled="mainSubmitting"
|
||||
@click="onSubmitMain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets a validation incrementale ─────────────────────────────
|
||||
Barre Qualimat · Adresses · Contacts · Prix. Onglet Qualimat = saisie
|
||||
assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux
|
||||
tickets suivants (placeholders « A venir »). -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Onglet Qualimat : datatable paginé filtré par le NOM du transporteur
|
||||
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
|
||||
<template #qualimat>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<MalioDataTable
|
||||
:columns="qualimatColumns"
|
||||
:items="qualimatRows"
|
||||
:total-items="qualimatTotalDisplay"
|
||||
:page="qualimatPage"
|
||||
:per-page="qualimatPerPage"
|
||||
:per-page-options="qualimatPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="qualimatEmptyMessage"
|
||||
@row-click="onQualimatRowClick"
|
||||
@update:page="qualimatGoToPage"
|
||||
@update:per-page="qualimatSetPerPage"
|
||||
>
|
||||
<!-- Radio reflétant la ligne QUALIMAT intégrée (lecture). -->
|
||||
<template #cell-select="{ item }">
|
||||
<MalioRadioButton
|
||||
:model-value="main.qualimatCarrierIri"
|
||||
name="qualimat-row"
|
||||
:value="item.iri"
|
||||
group-class="mt-0"
|
||||
/>
|
||||
</template>
|
||||
<!-- Date de validité : fond rouge si périmée (RG-4.04). -->
|
||||
<template #cell-validityDate="{ item }">
|
||||
<span
|
||||
v-if="item.validityDate"
|
||||
:class="isExpired(item.validityDate as string) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
||||
>
|
||||
{{ formatDateFr(item.validityDate as string) }}
|
||||
</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Adresses (ERP-167) : un bloc par adresse + BAN. RG-4.05
|
||||
préremplissage si QUALIMAT ; RG-4.07 pas de Valider si QUALIMAT. -->
|
||||
<template #addresses>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<CarrierAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isQualimat || isValidated('addresses')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<!-- RG-4.07 : pas de bouton Valider pour un transporteur QUALIMAT
|
||||
(adresse copiée et persistée automatiquement). -->
|
||||
<div v-if="!isQualimat && !isValidated('addresses')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('transport.carriers.form.address.add')"
|
||||
:disabled="!canAddAddress"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.form.submit')"
|
||||
:disabled="tabSubmitting || carrierId === null"
|
||||
@click="onSubmitAddresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Contacts / Prix : contenu aux tickets suivants. -->
|
||||
<template
|
||||
v-for="key in placeholderTabs"
|
||||
:key="key"
|
||||
#[key]
|
||||
>
|
||||
<div class="mt-12 flex justify-center text-m-muted">
|
||||
{{ t('transport.carriers.form.comingSoon') }}
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
|
||||
<!-- Modal de confirmation d'integration QUALIMAT (RG-4.01). -->
|
||||
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.qualimat.confirm.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ t('transport.carriers.form.qualimat.confirm.message') }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('transport.carriers.form.qualimat.confirm.cancel')"
|
||||
@click="confirmOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="flex-1"
|
||||
:label="t('transport.carriers.form.qualimat.confirm.confirm')"
|
||||
@click="confirmIntegrate"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
|
||||
<!-- Modal de confirmation de suppression (bloc adresse). -->
|
||||
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ t('transport.carriers.form.confirmDelete.message') }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('transport.carriers.form.confirmDelete.cancel')"
|
||||
@click="deleteConfirm.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="t('transport.carriers.form.confirmDelete.confirm')"
|
||||
@click="runDeleteConfirm"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { debounce } from '~/shared/utils/debounce'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('transport.carriers.form.title') })
|
||||
|
||||
// Gating de la route : la creation est reservee a `manage` (Admin / Bureau).
|
||||
// Commerciale (consultation seule), Compta et Usine sont rediriges vers le repertoire.
|
||||
if (!can('transport.carriers.manage')) {
|
||||
await navigateTo('/carriers')
|
||||
}
|
||||
|
||||
const {
|
||||
main,
|
||||
carrierId,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
tabSubmitting,
|
||||
mainErrors,
|
||||
isLiot,
|
||||
isQualimat,
|
||||
certificationReadonly,
|
||||
showCharteredFields,
|
||||
showDischarge,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
isValidated,
|
||||
addresses,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
submitMain,
|
||||
applyQualimatSelection,
|
||||
} = useCarrierForm()
|
||||
|
||||
const {
|
||||
items: qualimatItems,
|
||||
totalItems: qualimatTotal,
|
||||
currentPage: qualimatPage,
|
||||
itemsPerPage: qualimatPerPage,
|
||||
itemsPerPageOptions: qualimatPerPageOptions,
|
||||
goToPage: qualimatGoToPage,
|
||||
setItemsPerPage: qualimatSetPerPage,
|
||||
setFilters: qualimatSetFilters,
|
||||
} = useQualimatSearch()
|
||||
|
||||
// Certifications selectionnables manuellement (spec § Formulaire principal) :
|
||||
// GMP+ / OVOCOM / Compte-propre / Autre. QUALIMAT n'y figure PAS — il est posé par
|
||||
// la saisie assistee (onglet Qualimat). On l'ajoute cependant aux options QUAND il
|
||||
// est deja selectionne (transporteur QUALIMAT integre), uniquement pour AFFICHER
|
||||
// son libelle dans le select en lecture seule.
|
||||
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
||||
|
||||
const certificationOptions = computed<SelectOption[]>(() => {
|
||||
const codes: string[] = [...SELECTABLE_CERTIFICATIONS]
|
||||
if (main.certificationType === 'QUALIMAT') {
|
||||
codes.unshift('QUALIMAT')
|
||||
}
|
||||
return codes.map(code => ({
|
||||
value: code,
|
||||
label: t(`transport.carriers.certification.${code}`),
|
||||
}))
|
||||
})
|
||||
|
||||
// Colonnes du datatable de selection QUALIMAT (radio / Nom / Adresse / Validite).
|
||||
const qualimatColumns = [
|
||||
{ key: 'select', label: '' },
|
||||
{ key: 'name', label: t('transport.carriers.form.qualimat.columns.name') },
|
||||
{ key: 'address', label: t('transport.carriers.form.qualimat.columns.address') },
|
||||
{ key: 'validityDate', label: t('transport.carriers.form.qualimat.columns.validityDate') },
|
||||
]
|
||||
|
||||
// Le datatable n'affiche QUE des résultats de recherche : vide tant que le Nom n'est
|
||||
// pas saisi (pas de liste complète par défaut). `main.name` pilote l'affichage.
|
||||
const hasQualimatSearch = computed(() => main.name.trim() !== '')
|
||||
|
||||
// Lignes « plates » pour MalioDataTable (l'IRI sert au radio + à retrouver la ligne
|
||||
// source au clic). Le détail QUALIMAT complet reste dans `qualimatItems`.
|
||||
const qualimatRows = computed(() => {
|
||||
if (!hasQualimatSearch.value) {
|
||||
return []
|
||||
}
|
||||
return qualimatItems.value.map(row => ({
|
||||
id: row.id,
|
||||
iri: row['@id'],
|
||||
name: row.name,
|
||||
address: formatQualimatAddress(row),
|
||||
validityDate: row.validityDate,
|
||||
}))
|
||||
})
|
||||
|
||||
// Total / message vide alignés sur « tableau vide tant qu'on n'a pas recherché ».
|
||||
const qualimatTotalDisplay = computed(() => (hasQualimatSearch.value ? qualimatTotal.value : 0))
|
||||
const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
|
||||
? t('transport.carriers.form.qualimat.empty')
|
||||
: t('transport.carriers.form.qualimat.searchHint'))
|
||||
|
||||
// Contenant (RG-4.03) : Benne / Fond mouvant — select simple.
|
||||
const CONTAINER_TYPES = ['BENNE', 'FOND_MOUVANT'] as const
|
||||
|
||||
const containerOptions = computed<SelectOption[]>(() =>
|
||||
CONTAINER_TYPES.map(code => ({
|
||||
value: code,
|
||||
label: t(`transport.carriers.containerType.${code}`),
|
||||
})),
|
||||
)
|
||||
|
||||
// Icone (Iconify) affichee dans chaque onglet, par cle.
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
qualimat: 'mdi:truck-check-outline',
|
||||
addresses: 'mdi:map-marker-outline',
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
prices: 'mdi:currency-eur',
|
||||
}
|
||||
|
||||
// Onglets desactives tant que le formulaire principal n'est pas valide
|
||||
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
|
||||
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
||||
key,
|
||||
label: t(`transport.carriers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
disabled: index > unlockedIndex.value,
|
||||
})))
|
||||
|
||||
// Onglets dont le contenu arrive aux tickets suivants (Contacts / Prix).
|
||||
const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat' && key !== 'addresses'))
|
||||
|
||||
// ── Onglet Adresses (ERP-167) ────────────────────────────────────────────────
|
||||
// Pays : France garantie en tete meme si /countries echoue (resilience), pour
|
||||
// rester preselectionnable par defaut sur chaque adresse (RG-4.05).
|
||||
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
|
||||
|
||||
/** Charge le referentiel pays (/api/countries) ; conserve France par defaut si echec. */
|
||||
async function loadCountries(): Promise<void> {
|
||||
try {
|
||||
const data = await api.get<{ member?: { name: string }[] }>(
|
||||
'/countries',
|
||||
{ pagination: 'false' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
const list = (data.member ?? []).map(c => ({ value: c.name, label: c.name }))
|
||||
countryOptions.value = list.some(c => c.value === 'France')
|
||||
? list
|
||||
: [{ value: 'France', label: 'France' }, ...list]
|
||||
}
|
||||
catch {
|
||||
// Reste sur le fallback France (non bloquant).
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCountries().catch(() => {})
|
||||
})
|
||||
|
||||
// Avertissement unique quand l'autocompletion d'adresse bascule en degrade.
|
||||
const addressDegradedNotified = ref(false)
|
||||
|
||||
function onAddressDegraded(): void {
|
||||
if (addressDegradedNotified.value) {
|
||||
return
|
||||
}
|
||||
addressDegradedNotified.value = true
|
||||
toast.warning({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: t('transport.carriers.form.address.degraded'),
|
||||
})
|
||||
}
|
||||
|
||||
/** Message d'erreur affichable (toast) extrait d'une erreur API — jamais undefined. */
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const data = (error as { response?: { _data?: unknown } })?.response?._data
|
||||
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
|
||||
}
|
||||
|
||||
/** Valide l'onglet Adresses (POST/PATCH par ligne ; avance gere par le composable). */
|
||||
async function onSubmitAddresses(): Promise<void> {
|
||||
const ok = await submitAddresses(error => toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
if (ok) {
|
||||
toast.success({ title: t('transport.carriers.toast.addressSaved') })
|
||||
}
|
||||
}
|
||||
|
||||
// Modal de confirmation de suppression (bloc adresse).
|
||||
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
deleteConfirm.action = () => { void removeAddress(index) }
|
||||
deleteConfirm.open = true
|
||||
}
|
||||
|
||||
function runDeleteConfirm(): void {
|
||||
deleteConfirm.action?.()
|
||||
deleteConfirm.action = null
|
||||
deleteConfirm.open = false
|
||||
}
|
||||
|
||||
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
|
||||
const confirmOpen = ref(false)
|
||||
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
||||
|
||||
// Le datatable QUALIMAT est filtré par le NOM saisi dans le formulaire principal
|
||||
// (RG-4.01) — pas de champ de recherche dédié. Aucune recherche tant que le Nom est
|
||||
// vide (tableau vide par défaut) ; sinon re-filtrage debouncé à chaque frappe.
|
||||
const filterQualimatByName = debounce((term: string) => {
|
||||
if (term.trim() === '') {
|
||||
return
|
||||
}
|
||||
void qualimatSetFilters({ search: term })
|
||||
}, 300)
|
||||
|
||||
watch(() => main.name, term => filterQualimatByName(term))
|
||||
|
||||
/** Adresse QUALIMAT condensee pour la colonne « Adresse » (voie · CP · ville). */
|
||||
function formatQualimatAddress(row: QualimatCarrierRow): string {
|
||||
return [row.address, row.postalCode, row.city].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** RG-4.04 : un agrement est perime si sa date de validite est < aujourd'hui. */
|
||||
function isExpired(value: string): boolean {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return false
|
||||
}
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime() < today.getTime()
|
||||
}
|
||||
|
||||
/** Format court francais JJ-MM-AAAA (chaine vide si date absente / invalide). */
|
||||
function formatDateFr(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}-${month}-${date.getFullYear()}`
|
||||
}
|
||||
|
||||
/** Clic sur une ligne du datatable → retrouve la ligne QUALIMAT source + modal. */
|
||||
function onQualimatRowClick(item: Record<string, unknown>): void {
|
||||
const row = qualimatItems.value.find(r => r.id === item.id)
|
||||
if (row) {
|
||||
askIntegrate(row)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ouvre la modal de confirmation d'integration pour une ligne QUALIMAT. */
|
||||
function askIntegrate(row: QualimatCarrierRow): void {
|
||||
pendingRow.value = row
|
||||
confirmOpen.value = true
|
||||
}
|
||||
|
||||
/** Confirme l'integration : copie + PATCH (cf. useCarrierForm.applyQualimatSelection). */
|
||||
async function confirmIntegrate(): Promise<void> {
|
||||
const row = pendingRow.value
|
||||
confirmOpen.value = false
|
||||
if (row === null) {
|
||||
return
|
||||
}
|
||||
const ok = await applyQualimatSelection(row)
|
||||
if (ok) {
|
||||
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
||||
}
|
||||
}
|
||||
|
||||
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/carriers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide le formulaire principal (POST /carriers ; bascule geree par le composable).
|
||||
* RG-4.07 : pour un transporteur QUALIMAT, l'adresse copiee est persistee
|
||||
* automatiquement (pas de bouton Valider dans l'onglet Adresses).
|
||||
*/
|
||||
async function onSubmitMain(): Promise<void> {
|
||||
const ok = await submitMain()
|
||||
if (ok && isQualimat.value) {
|
||||
await submitAddresses(error => toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Types du workflow « Ajouter un transporteur » (M4 Transport, ERP-165 / ERP-166).
|
||||
*
|
||||
* Périmètre :
|
||||
* - ERP-165 : formulaire PRINCIPAL minimal (Nom + Certification + Affréter).
|
||||
* - ERP-166 : champs CONDITIONNELS du formulaire principal (indexation / benne /
|
||||
* volume si affrété — RG-4.03 ; décharge si AUTRE — RG-4.02 ; immatriculations
|
||||
* LIOT — RG-4.01) + saisie assistée QUALIMAT (copie name / certification /
|
||||
* adresse + FK qualimatCarrier — RG-4.01 / § 2.5).
|
||||
*
|
||||
* L'upload réel de la décharge (file → IRI via useUpload) arrive à ERP-171 ; ici
|
||||
* on porte seulement l'IRI résolu (`dischargeDocumentIri`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Brouillon du formulaire principal. Les décimales (indexation / volume) sont
|
||||
* portées en `string` car `MalioInputNumber` émet une chaîne ; le serveur parse.
|
||||
* `certificationType` est un code enum back (GMP_PLUS | OVOCOM | COMPTE_PROPRE |
|
||||
* AUTRE | QUALIMAT — ce dernier posé par la saisie assistée) ou `null`.
|
||||
* `containerType` vaut `BENNE` | `FOND_MOUVANT` (radio) ou `null`.
|
||||
*/
|
||||
export interface CarrierMainDraft {
|
||||
name: string
|
||||
certificationType: string | null
|
||||
isChartered: boolean
|
||||
indexationRate: string
|
||||
containerType: string | null
|
||||
volumeM3: string
|
||||
liotPlates: string
|
||||
/** IRI du document de décharge (résolu par useUpload — ERP-171). */
|
||||
dischargeDocumentIri: string | null
|
||||
/** IRI de la ligne QUALIMAT liée (saisie assistée — null si non QUALIMAT). */
|
||||
qualimatCarrierIri: string | null
|
||||
}
|
||||
|
||||
/** Brouillon principal vide (état initial du formulaire de création). */
|
||||
export function emptyCarrierMain(): CarrierMainDraft {
|
||||
return {
|
||||
name: '',
|
||||
certificationType: null,
|
||||
isChartered: false,
|
||||
indexationRate: '',
|
||||
containerType: null,
|
||||
volumeM3: '',
|
||||
liotPlates: '',
|
||||
dischargeDocumentIri: null,
|
||||
qualimatCarrierIri: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse copiée depuis le référentiel QUALIMAT à la sélection (RG-4.01 / § 2.5).
|
||||
* Stockée dans l'état du formulaire pour alimenter l'onglet Adresses (ticket
|
||||
* ultérieur) ; pré-remplie « France » côté pays par défaut.
|
||||
*/
|
||||
export interface CarrierAddressCopy {
|
||||
country: string
|
||||
postalCode: string
|
||||
city: string
|
||||
street: string
|
||||
}
|
||||
|
||||
/** Adresse copiée vide. */
|
||||
export function emptyCarrierAddressCopy(): CarrierAddressCopy {
|
||||
return { country: 'France', postalCode: '', city: '', street: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Brouillon d'un bloc Adresse (onglet Adresses, ERP-167) — sous-ressource
|
||||
* `CarrierAddress` (groupe `carrier:write:addresses`). Version SIMPLIFIÉE de
|
||||
* l'adresse fournisseur (M2) / prestataire (M3) : pas de sites / catégories /
|
||||
* contacts ni type d'adresse (les sites du M4 vivent dans l'onglet Prix).
|
||||
*/
|
||||
export interface CarrierAddressFormDraft {
|
||||
/** Id serveur une fois l'adresse créée (null tant que non persistée). */
|
||||
id: number | null
|
||||
/** Pays (chaîne libre, défaut « France »). */
|
||||
country: string
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
}
|
||||
|
||||
/** Brouillon d'adresse vide (pays France par défaut, RG-4.05). */
|
||||
export function emptyCarrierAddress(): CarrierAddressFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
country: 'France',
|
||||
postalCode: null,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie
|
||||
* le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel.
|
||||
*/
|
||||
export interface CarrierMainResponse {
|
||||
id: number
|
||||
name: string | null
|
||||
certificationType: string | null
|
||||
'@id'?: string
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Adresse transporteur (M4 Transport, ERP-167) — miroir
|
||||
* SIMPLIFIÉ de `providerAddress` (M3) / `SupplierAddressBlock` (M2), sans sites /
|
||||
* catégories / contacts (les sites du M4 vivent dans l'onglet Prix). Testables
|
||||
* sans Vue.
|
||||
*/
|
||||
|
||||
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
|
||||
/**
|
||||
* RG-4.05 : gate du bouton « + Nouvelle adresse ». Une adresse est « complète »
|
||||
* (donc on autorise l'ajout d'un nouveau bloc) dès qu'elle porte un code postal,
|
||||
* une ville ET une rue. Les RG conditionnelles (obligatoire si affrété) restent
|
||||
* validées par le back (422 inline) — ce gate empêche seulement d'empiler des
|
||||
* blocs vides.
|
||||
*/
|
||||
export function isCarrierAddressValid(address: CarrierAddressFormDraft): boolean {
|
||||
return Boolean(address.postalCode?.trim() && address.city?.trim() && address.street?.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de la sous-ressource addresses (groupe `carrier:write:addresses`). Les
|
||||
* scalaires sont nullable côté entité : on envoie `null` quand le champ est vide
|
||||
* (le `CarrierAddressProcessor` re-valide la présence si affrété — RG-4.05 — et
|
||||
* renvoie une 422 par champ).
|
||||
*/
|
||||
export function buildCarrierAddressPayload(address: CarrierAddressFormDraft): Record<string, unknown> {
|
||||
return {
|
||||
country: address.country,
|
||||
postalCode: address.postalCode || null,
|
||||
city: address.city || null,
|
||||
street: address.street || null,
|
||||
streetComplement: address.streetComplement || null,
|
||||
}
|
||||
}
|
||||
Generated
+10
-10
@@ -7,7 +7,7 @@
|
||||
"name": "starseed-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.12",
|
||||
"@malio/layer-ui": "^1.7.10",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -583,9 +583,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"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": {
|
||||
@@ -594,9 +594,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"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": {
|
||||
@@ -1866,9 +1866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.7.12",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.12/layer-ui-1.7.12.tgz",
|
||||
"integrity": "sha512-ezQLqi19K2ogI3XwSMsUyluU9x5C4W0tu1muxFbL3foKjibRYRg/FdvySivEhEsalAAt1E88V6Sv/06xPqyYTw==",
|
||||
"version": "1.7.10",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
|
||||
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.12",
|
||||
"@malio/layer-ui": "^1.7.10",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -95,11 +95,10 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
// Transport — Repertoire transporteurs (M4, ERP-164). Meme logique :
|
||||
// Transport — Repertoire transporteurs (M4, ERP-153). Meme logique :
|
||||
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
|
||||
// n°7). L'item transporteurs vit desormais dans la section Administration
|
||||
// (1er item, ERP-164) mais sur la route `/carriers` (hors `/admin/<slug>`),
|
||||
// donc il n'entre pas dans ALL_ADMIN_LINKS : expectedAdminLinks reste inchange.
|
||||
// n°7). transport.carriers.view n'ajoute pas de lien dans la section
|
||||
// Administration, donc expectedAdminLinks reste inchange.
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
'transport.carriers.archive',
|
||||
|
||||
Reference in New Issue
Block a user