Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97d7cacd2c | |||
| d9313dbec8 | |||
| e607cccf08 |
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.129'
|
||||
app.version: '0.1.126'
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# 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/
|
||||
@@ -1,80 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,201 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,496 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,306 +0,0 @@
|
||||
# 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.*
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/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"
|
||||
@@ -586,7 +586,7 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
> 2. Sérialisation booléen `isArchived` (bug #3 M1) : clé présente dans le JSON réel.
|
||||
> 3. `qualimatCarrier` embarqué (statut + validité) pour RG-4.04.
|
||||
|
||||
> ✅ **CAPTURÉ (ERP-163)** — JSON **RÉEL** produit par `CarrierSerializationContractTest::testDodReferenceJsonShape` (transporteur complet seedé : lien QUALIMAT, 1 adresse, 1 contact, 2 prix CLIENT + FOURNISSEUR), dumpé via la variable d'env `CARRIER_DOD_DUMP=1`. Les 3 pièges sont vérifiés verts. **Le front peut démarrer sur ce contrat.** Les valeurs cosmétiques (noms, SIRET) sont nettoyées du bruit de seed ; **toutes les clés ci-dessous sont présentes telles quelles dans la réponse réelle**.
|
||||
> ✅ **CAPTURÉ (WT3, ERP-155/157)** — JSON réel produit par `CarrierSerializationContractTest` (transporteur complet seedé : lien QUALIMAT, 1 adresse, 1 contact, 2 prix CLIENT + FOURNISSEUR). Les 3 pièges sont vérifiés verts. **Le front peut démarrer sur ce contrat.**
|
||||
>
|
||||
> Contraintes d'architecture validées au passage :
|
||||
> - Relations cross-module des prix (`client`/`supplier`/adresses) câblées **sans import inter-module** (règle n°1) via des contrats `Shared/Domain/Contract/*Interface` + `resolve_target_entities`. L'embed JSON passe par les read-groups des entités concrètes (`client:read`, `client_address:read`, `supplier:read`, `supplier_address:read`, `site:read`). Un groupe `supplier_address:read` a été **ajouté aux champs scalaires de `SupplierAddress`** (M2) pour que `supplierSupplyAddress` s'embarque comme `clientDeliveryAddress` (M1 avait déjà `client_address:read`).
|
||||
@@ -596,31 +596,19 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/Carrier",
|
||||
"@id": "/api/carriers",
|
||||
"@type": "Collection",
|
||||
"@context": "/api/contexts/Carrier", "@id": "/api/carriers", "@type": "Collection",
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/carriers/26",
|
||||
"@type": "Carrier",
|
||||
"id": 26,
|
||||
"@id": "/api/carriers/12", "@type": "Carrier", "id": 12,
|
||||
"name": "TRANSPORTS GRELILLIER",
|
||||
"qualimatCarrier": { // embarqué (objet), pas IRI — RG-4.04
|
||||
"@id": "/api/qualimat_carriers/22",
|
||||
"@type": "QualimatCarrier",
|
||||
"id": "22",
|
||||
"siret": "80012345600017",
|
||||
"name": "TRANSPORTS GRELILLIER",
|
||||
"address": "12 rue des Acacias",
|
||||
"postalCode": "86000",
|
||||
"city": "Poitiers",
|
||||
"status": "Valide",
|
||||
"validityDate": "2027-12-31T00:00:00+01:00"
|
||||
"@id": "/api/qualimat_carriers/8", "@type": "QualimatCarrier", "id": "8",
|
||||
"siret": "…", "name": "…", "address": "…", "postalCode": "86000", "city": "Poitiers",
|
||||
"status": "Valide", "validityDate": "2027-12-31T00:00:00+01:00"
|
||||
},
|
||||
"certificationType": "QUALIMAT",
|
||||
"createdAt": "2026-06-15T19:12:39+02:00",
|
||||
"updatedAt": "2026-06-15T19:12:39+02:00",
|
||||
"createdAt": "…", "updatedAt": "…",
|
||||
"isChartered": false, // bool présent (getter + SerializedName)
|
||||
"isArchived": false // bool présent (piège #3)
|
||||
}
|
||||
@@ -629,169 +617,44 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/carriers/{id}` (DÉTAIL)** — `qualimatCarrier` + `addresses[]` + `contacts[]` + `prices[]` avec relations cross-module embarquées en objet (les `@id` des sous-collections sortent en `/.well-known/genid/…` : ce sont des IRI anonymes API Platform, normal pour des entités non exposées en ressource racine) :
|
||||
**`GET /api/carriers/{id}` (DÉTAIL)** — `qualimatCarrier` + `addresses[]` + `contacts[]` + `prices[]` avec relations cross-module embarquées en objet :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/Carrier",
|
||||
"@id": "/api/carriers/26",
|
||||
"@type": "Carrier",
|
||||
"id": 26,
|
||||
"@id": "/api/carriers/12", "@type": "Carrier", "id": 12,
|
||||
"name": "TRANSPORTS GRELILLIER",
|
||||
"qualimatCarrier": { // embarqué (statut + validité) — RG-4.04
|
||||
"@id": "/api/qualimat_carriers/22",
|
||||
"@type": "QualimatCarrier",
|
||||
"id": "22",
|
||||
"siret": "80012345600017",
|
||||
"name": "TRANSPORTS GRELILLIER",
|
||||
"address": "12 rue des Acacias",
|
||||
"postalCode": "86000",
|
||||
"city": "Poitiers",
|
||||
"status": "Valide",
|
||||
"validityDate": "2027-12-31T00:00:00+01:00"
|
||||
},
|
||||
"qualimatCarrier": { "@type": "QualimatCarrier", "status": "Valide", "validityDate": "…", "...": "…" },
|
||||
"certificationType": "QUALIMAT",
|
||||
"addresses": [
|
||||
{
|
||||
"@type": "CarrierAddress",
|
||||
"@id": "/api/.well-known/genid/9f597da33f73776f1c25",
|
||||
"id": 12,
|
||||
"country": "France",
|
||||
"postalCode": "86000",
|
||||
"city": "Poitiers",
|
||||
"street": "12 rue des Acacias",
|
||||
"createdAt": "2026-06-15T19:12:39+02:00",
|
||||
"updatedAt": "2026-06-15T19:12:39+02:00"
|
||||
}
|
||||
{ "@type": "CarrierAddress", "id": 4, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "…", "createdAt": "…", "updatedAt": "…" }
|
||||
],
|
||||
"contacts": [
|
||||
{
|
||||
"@type": "CarrierContact",
|
||||
"@id": "/api/.well-known/genid/6c6335ead4557062774f",
|
||||
"id": 13,
|
||||
"firstName": "Marie",
|
||||
"lastName": "Martin",
|
||||
"phonePrimary": "0612345678",
|
||||
"email": "marie.martin@grelillier.fr",
|
||||
"createdAt": "2026-06-15T19:12:39+02:00",
|
||||
"updatedAt": "2026-06-15T19:12:39+02:00"
|
||||
}
|
||||
{ "@type": "CarrierContact", "id": 5, "firstName": "Marie", "lastName": "Martin", "phonePrimary": "0612345678", "email": "…", "createdAt": "…", "updatedAt": "…" }
|
||||
],
|
||||
"prices": [
|
||||
{
|
||||
"@type": "CarrierPrice",
|
||||
"@id": "/api/.well-known/genid/ac0305352bb3751a5b76",
|
||||
"id": 23,
|
||||
"direction": "CLIENT",
|
||||
"client": { // OBJET embarqué (client:read), pas IRI nu — piège #1
|
||||
"@type": "Client",
|
||||
"@id": "/api/clients/117",
|
||||
"id": 117,
|
||||
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
|
||||
"triageService": false,
|
||||
"categories": [],
|
||||
"createdAt": "2026-06-15T19:12:39+02:00",
|
||||
"updatedAt": "2026-06-15T19:12:39+02:00",
|
||||
"sites": [],
|
||||
"isArchived": false
|
||||
},
|
||||
"clientDeliveryAddress": { // OBJET embarqué (client_address:read)
|
||||
"@type": "ClientAddress",
|
||||
"@id": "/api/client_addresses/32",
|
||||
"id": 32,
|
||||
"country": "France",
|
||||
"postalCode": "86000",
|
||||
"city": "Poitiers",
|
||||
"street": "1 rue de la Livraison",
|
||||
"position": 0,
|
||||
"sites": [],
|
||||
"contacts": [],
|
||||
"categories": [],
|
||||
"createdAt": "2026-06-15T19:12:39+02:00",
|
||||
"updatedAt": "2026-06-15T19:12:39+02:00",
|
||||
"isProspect": false,
|
||||
"isDelivery": true,
|
||||
"isBilling": false,
|
||||
"isBroker": false,
|
||||
"isDistributor": false
|
||||
},
|
||||
"departureSite": { // OBJET embarqué (site:read)
|
||||
"@type": "Site",
|
||||
"@id": "/api/sites/1",
|
||||
"id": 1,
|
||||
"name": "Chatellerault",
|
||||
"street": "14 All. d'Argenson",
|
||||
"postalCode": "86100",
|
||||
"city": "Châtellerault",
|
||||
"color": "#056CF2",
|
||||
"createdAt": "2026-06-15T18:57:56+02:00",
|
||||
"updatedAt": "2026-06-15T18:57:56+02:00",
|
||||
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||
},
|
||||
"containerType": "BENNE",
|
||||
"pricingUnit": "TONNE",
|
||||
"price": "42.50",
|
||||
"priceState": "VALIDE",
|
||||
"createdAt": "2026-06-15T19:12:39+02:00",
|
||||
"updatedAt": "2026-06-15T19:12:39+02:00"
|
||||
"@type": "CarrierPrice", "id": 7, "direction": "CLIENT",
|
||||
"client": { "@type": "Client", "@id": "/api/clients/4", "id": 4, "companyName": "…", "isArchived": false, "...": "…" },
|
||||
"clientDeliveryAddress": { "@type": "ClientAddress", "@id": "/api/client_addresses/4", "postalCode": "86000", "city": "Poitiers", "street": "…", "...": "…" },
|
||||
"departureSite": { "@type": "Site", "@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "...": "…" },
|
||||
"containerType": "BENNE", "pricingUnit": "TONNE", "price": "42.50", "priceState": "VALIDE",
|
||||
"createdAt": "…", "updatedAt": "…"
|
||||
},
|
||||
{
|
||||
"@type": "CarrierPrice",
|
||||
"@id": "/api/.well-known/genid/cfee3c4dda8fb899ff3e",
|
||||
"id": 24,
|
||||
"direction": "FOURNISSEUR",
|
||||
"supplier": { // OBJET embarqué (supplier:read), pas IRI nu — piège #1
|
||||
"@type": "Supplier",
|
||||
"@id": "/api/suppliers/102",
|
||||
"id": 102,
|
||||
"companyName": "FERRAILLEUR GRAND OUEST",
|
||||
"categories": [],
|
||||
"createdAt": "2026-06-15T19:12:39+02:00",
|
||||
"updatedAt": "2026-06-15T19:12:39+02:00",
|
||||
"sites": [],
|
||||
"isArchived": false
|
||||
},
|
||||
"supplierSupplyAddress": { // OBJET embarqué (supplier_address:read)
|
||||
"@type": "SupplierAddress",
|
||||
"@id": "/api/supplier_addresses/38",
|
||||
"id": 38,
|
||||
"addressType": "DEPART",
|
||||
"country": "France",
|
||||
"postalCode": "17000",
|
||||
"city": "La Rochelle",
|
||||
"street": "2 quai de l Appro",
|
||||
"createdAt": "2026-06-15T19:12:39+02:00",
|
||||
"updatedAt": "2026-06-15T19:12:39+02:00"
|
||||
},
|
||||
"deliverySite": { // OBJET embarqué (site:read)
|
||||
"@type": "Site",
|
||||
"@id": "/api/sites/1",
|
||||
"id": 1,
|
||||
"name": "Chatellerault",
|
||||
"street": "14 All. d'Argenson",
|
||||
"postalCode": "86100",
|
||||
"city": "Châtellerault",
|
||||
"color": "#056CF2",
|
||||
"createdAt": "2026-06-15T18:57:56+02:00",
|
||||
"updatedAt": "2026-06-15T18:57:56+02:00",
|
||||
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||
},
|
||||
"containerType": "FOND_MOUVANT",
|
||||
"pricingUnit": "FORFAIT",
|
||||
"price": "320.00",
|
||||
"priceState": "EN_COURS",
|
||||
"createdAt": "2026-06-15T19:12:39+02:00",
|
||||
"updatedAt": "2026-06-15T19:12:39+02:00"
|
||||
"@type": "CarrierPrice", "id": 8, "direction": "FOURNISSEUR",
|
||||
"supplier": { "@type": "Supplier", "@id": "/api/suppliers/4", "id": 4, "companyName": "…", "isArchived": false, "...": "…" },
|
||||
"supplierSupplyAddress": { "@type": "SupplierAddress", "@id": "/api/supplier_addresses/38", "id": 38, "addressType": "DEPART", "country": "France", "postalCode": "17000", "city": "La Rochelle", "street": "…" },
|
||||
"deliverySite": { "@type": "Site", "@id": "/api/sites/1", "name": "Chatellerault", "...": "…" },
|
||||
"containerType": "FOND_MOUVANT", "pricingUnit": "FORFAIT", "price": "320.00", "priceState": "EN_COURS",
|
||||
"createdAt": "…", "updatedAt": "…"
|
||||
}
|
||||
],
|
||||
"createdAt": "2026-06-15T19:12:39+02:00",
|
||||
"updatedAt": "2026-06-15T19:12:39+02:00",
|
||||
"isChartered": false, // bool présent (getter + SerializedName)
|
||||
"isArchived": false // bool présent (piège #3)
|
||||
"createdAt": "…", "updatedAt": "…",
|
||||
"isChartered": false, "isArchived": false
|
||||
}
|
||||
```
|
||||
|
||||
> Note (ERP-163) : opérations exposées = `GetCollection` + `Get` (lecture) **et** `POST`/`PATCH` (`CarrierProcessor` : normalisation RG-4.13, RG-4.01→4.14, 409 doublon, gating archive mode strict) **et** les sous-ressources d'écriture adresses/contacts/prix (`Carrier*Processor`). La couverture RG-4.01→4.14 + RBAC + audit + anti-N+1 est portée par la matrice de tests `tests/Module/Transport/Api/` (ERP-163).
|
||||
> Note WT3 : opérations exposées = `GetCollection` + `Get` (lecture). `POST`/`PATCH` (+ `CarrierProcessor`, normalisation, RG-4.01→4.14, 409 doublon, gating archive) et les sous-ressources d'écriture (adresses/contacts/prix) arrivent aux worktrees suivants (WT4+).
|
||||
|
||||
### 4.1 `GET /api/carriers` — Liste
|
||||
|
||||
@@ -1046,7 +909,7 @@ Synchronisation : `php bin/console app:sync-permissions`.
|
||||
|
||||
- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
|
||||
- [x] Décision embed vs GetCollection explicite (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5)
|
||||
- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — produites par `CarrierSerializationContractTest` (ERP-163, dump `CARRIER_DOD_DUMP=1`)
|
||||
- [ ] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — à produire au ticket tests (`CarrierSerializationContractTest`)
|
||||
- [x] Matrice RBAC rôle × permission + mode strict archive (§ 5.2 / RG-4.14)
|
||||
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
|
||||
- [x] Réutilisations identifiées (référentiel QUALIMAT, Client/Supplier/Site partagés, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"common": {
|
||||
"loading": "Chargement...",
|
||||
"save": "Enregistrer",
|
||||
"validate": "Valider",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
@@ -75,7 +74,7 @@
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"archivedOnly": "Voir les archivés",
|
||||
"includeArchived": "Inclure les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
@@ -124,7 +123,7 @@
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du fournisseur…",
|
||||
"notFound": "Fournisseur introuvable.",
|
||||
"save": "Enregistrer"
|
||||
"save": "Valider"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un fournisseur",
|
||||
@@ -267,7 +266,7 @@
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"save": "Enregistrer"
|
||||
"save": "Valider"
|
||||
},
|
||||
"validation": {
|
||||
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
||||
@@ -389,7 +388,7 @@
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"archivedOnly": "Voir les archivés",
|
||||
"includeArchived": "Inclure les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canShowSave"
|
||||
:label="isCreateMode ? t('common.validate') : t('common.save')"
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="form.submitting.value || loadingTypes"
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('useSuppliersRepository', () => {
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||
'siteId[]': ['86', '17'],
|
||||
archivedOnly: true,
|
||||
includeArchived: true,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
@@ -63,7 +63,7 @@ describe('useSuppliersRepository', () => {
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||
'siteId[]': ['86', '17'],
|
||||
archivedOnly: true,
|
||||
includeArchived: true,
|
||||
page: 1,
|
||||
itemsPerPage: 10,
|
||||
},
|
||||
@@ -73,7 +73,7 @@ describe('useSuppliersRepository', () => {
|
||||
|
||||
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||
const repo = useSuppliersRepository()
|
||||
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
|
||||
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true })
|
||||
await repo.setFilters({}, { replace: true })
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
|
||||
@@ -172,16 +172,16 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
|
||||
it('repercute le filtre « Inclure 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)
|
||||
// Coche « Inclure les archivés » puis applique les filtres.
|
||||
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
|
||||
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ archivedOnly: true },
|
||||
{ includeArchived: true },
|
||||
{ replace: true },
|
||||
)
|
||||
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||
@@ -192,7 +192,7 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
||||
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
|
||||
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
||||
|
||||
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||
|
||||
@@ -128,13 +128,13 @@
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
|
||||
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
|
||||
<MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status">
|
||||
<MalioCheckbox
|
||||
id="filter-archived-only"
|
||||
:label="t('commercial.suppliers.filters.archivedOnly')"
|
||||
:model-value="draftArchivedOnly"
|
||||
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||
id="filter-include-archived"
|
||||
:label="t('commercial.suppliers.filters.includeArchived')"
|
||||
:model-value="draftIncludeArchived"
|
||||
@update:model-value="(val: boolean) => draftIncludeArchived = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
@@ -254,12 +254,12 @@ const filterDrawerOpen = ref(false)
|
||||
const draftSearch = ref('')
|
||||
const draftCategoryCodes = ref<string[]>([])
|
||||
const draftSiteIds = ref<string[]>([])
|
||||
const draftArchivedOnly = ref(false)
|
||||
const draftIncludeArchived = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryCodes = ref<string[]>([])
|
||||
const appliedSiteIds = ref<string[]>([])
|
||||
const appliedArchivedOnly = ref(false)
|
||||
const appliedIncludeArchived = ref(false)
|
||||
|
||||
// Options des selects multi, chargees une fois (referentiels courts).
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
@@ -270,7 +270,7 @@ const activeFilterCount = computed(() => {
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryCodes.value.length > 0) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
if (appliedArchivedOnly.value) count++
|
||||
if (appliedIncludeArchived.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
@@ -285,7 +285,7 @@ function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||
draftIncludeArchived.value = appliedIncludeArchived.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||
if (appliedIncludeArchived.value) payload.includeArchived = true
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||
appliedIncludeArchived.value = draftIncludeArchived.value
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
@@ -333,12 +333,12 @@ function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryCodes.value = []
|
||||
draftSiteIds.value = []
|
||||
draftArchivedOnly.value = false
|
||||
draftIncludeArchived.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryCodes.value = []
|
||||
appliedSiteIds.value = []
|
||||
appliedArchivedOnly.value = false
|
||||
appliedIncludeArchived.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="isEditMode ? t('common.save') : t('common.validate')"
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="saving || permissionsLoadFailed"
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="isEditMode ? t('common.save') : t('common.validate')"
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="saving || !isValidHex"
|
||||
|
||||
@@ -14,9 +14,9 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
* - 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
|
||||
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
|
||||
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
|
||||
* archives) ; le filtre `archivedOnly` est bien transmis une fois applique.
|
||||
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
|
||||
*/
|
||||
describe('useProvidersRepository', () => {
|
||||
beforeEach(() => {
|
||||
@@ -53,26 +53,26 @@ describe('useProvidersRepository', () => {
|
||||
expect(repo.totalItems.value).toBe(1)
|
||||
})
|
||||
|
||||
it('exclut les archives par defaut : aucun archivedOnly au premier fetch', async () => {
|
||||
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(query.archivedOnly).toBeUndefined()
|
||||
expect(query.includeArchived).toBeUndefined()
|
||||
})
|
||||
|
||||
it('transmet archivedOnly une fois le filtre applique (retour page 1)', async () => {
|
||||
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
await repo.setFilters({ archivedOnly: true })
|
||||
await repo.setFilters({ includeArchived: true })
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(query.archivedOnly).toBe(true)
|
||||
expect(query.includeArchived).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,11 +45,10 @@ export interface Provider {
|
||||
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
|
||||
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
|
||||
*
|
||||
* Les filtres (recherche, categories, sites, 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 donc les prestataires
|
||||
* archives (exclusion par defaut, spec-back § 2.11). 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. Par defaut, aucun `includeArchived` n'est envoye : le back masque
|
||||
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
|
||||
*
|
||||
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
|
||||
* fonction de l'utilisateur — rien a filtrer cote front.
|
||||
|
||||
@@ -129,13 +129,13 @@
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
|
||||
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
|
||||
<MalioCheckbox
|
||||
id="filter-archived-only"
|
||||
:label="t('technique.providers.filters.archivedOnly')"
|
||||
:model-value="draftArchivedOnly"
|
||||
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||
id="filter-include-archived"
|
||||
:label="t('technique.providers.filters.includeArchived')"
|
||||
:model-value="draftIncludeArchived"
|
||||
@update:model-value="(val: boolean) => draftIncludeArchived = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
@@ -258,12 +258,12 @@ const filterDrawerOpen = ref(false)
|
||||
const draftSearch = ref('')
|
||||
const draftCategoryCodes = ref<string[]>([])
|
||||
const draftSiteIds = ref<string[]>([])
|
||||
const draftArchivedOnly = ref(false)
|
||||
const draftIncludeArchived = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryCodes = ref<string[]>([])
|
||||
const appliedSiteIds = ref<string[]>([])
|
||||
const appliedArchivedOnly = ref(false)
|
||||
const appliedIncludeArchived = ref(false)
|
||||
|
||||
// Options des selects multi, chargees une fois (referentiels courts).
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
@@ -274,7 +274,7 @@ const activeFilterCount = computed(() => {
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryCodes.value.length > 0) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
if (appliedArchivedOnly.value) count++
|
||||
if (appliedIncludeArchived.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
@@ -289,7 +289,7 @@ function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||
draftIncludeArchived.value = appliedIncludeArchived.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||
if (appliedIncludeArchived.value) payload.includeArchived = true
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||
appliedIncludeArchived.value = draftIncludeArchived.value
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
@@ -337,12 +337,12 @@ function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryCodes.value = []
|
||||
draftSiteIds.value = []
|
||||
draftArchivedOnly.value = false
|
||||
draftIncludeArchived.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryCodes.value = []
|
||||
appliedSiteIds.value = []
|
||||
appliedArchivedOnly.value = false
|
||||
appliedIncludeArchived.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
@@ -4,86 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierAddressProcessor;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Adresse d'un transporteur (1:n) — onglet Adresse (M4). Jumelle de
|
||||
* SupplierAddress (M2), version simplifiee (pas de type d'adresse, pas de M2M
|
||||
* sites/categories sur l'adresse : les sites du M4 vivent dans l'onglet Prix).
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:addresses`.
|
||||
*
|
||||
* Sous-ressource API (ERP-159, spec § 4.5) — jumelle de SupplierAddress (M2) /
|
||||
* ProviderAddress (M3), sans address_type ni M2M (les sites du M4 vivent dans
|
||||
* l'onglet Prix) :
|
||||
* - POST /api/carriers/{carrierId}/addresses : creation rattachee au
|
||||
* transporteur parent (Link toProperty 'carrier'), security
|
||||
* transport.carriers.manage.
|
||||
* - PATCH / DELETE /api/carrier_addresses/{id} : security
|
||||
* transport.carriers.manage.
|
||||
* - GET /api/carrier_addresses/{id} : lecture unitaire (security view) — la
|
||||
* lecture courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le CarrierAddressProcessor (rattachement parent + RG-4.05).
|
||||
*
|
||||
* Regles de l'onglet Adresse :
|
||||
* - RG-4.06 : code postal a 4 ou 5 chiffres (Assert\Regex ; pas de controle
|
||||
* CP/ville serveur, l'autocomplete BAN est front).
|
||||
* - RG-4.05 : si le transporteur est affrete (isChartered), l'adresse devient
|
||||
* obligatoire (Pays / CP / Ville / Adresse) — validation conditionnelle portee
|
||||
* par le CarrierAddressProcessor (le parent n'est pas disponible a la
|
||||
* validation Symfony sur un POST sous-ressource en read:false).
|
||||
* - RG-4.07 : masquage du bouton « Valider » si QUALIMAT = front ; le back
|
||||
* accepte le PATCH normalement (aucune garde back specifique).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`
|
||||
* (embarquees au detail du transporteur). Les sous-ressources d'ecriture
|
||||
* (POST/PATCH/DELETE) + RG-4.05→4.07 arrivent au worktree dedie (WT6).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/carriers/{carrierId}/addresses',
|
||||
uriVariables: [
|
||||
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT CarrierAddress ... WHERE carrier = :id)
|
||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par CarrierAddressProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:addresses']],
|
||||
processor: CarrierAddressProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:addresses']],
|
||||
processor: CarrierAddressProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
processor: CarrierAddressProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_address')]
|
||||
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
|
||||
@@ -105,32 +41,23 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private string $country = 'France';
|
||||
|
||||
// RG-4.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur,
|
||||
// l'autocomplete BAN est front). Le Regex borne deja la longueur (<= 5) : pas
|
||||
// de Length redondant (whitelist EXCLUDED_LENGTH_MIRROR). Nullable : obligatoire
|
||||
// seulement si affrete (RG-4.05, garde CarrierAddressProcessor).
|
||||
#[ORM\Column(name: 'postal_code', length: 20, nullable: true)]
|
||||
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -4,80 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierContactProcessor;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
|
||||
* SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le
|
||||
* CHECK chk_carrier_contact_filled + le Processor), max 2 telephones.
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:contacts`.
|
||||
*
|
||||
* Sous-ressource API (ERP-160, spec § 4.5) — jumelle de SupplierContact (M2) :
|
||||
* - POST /api/carriers/{carrierId}/contacts : creation rattachee au
|
||||
* transporteur parent (Link toProperty 'carrier'), security
|
||||
* transport.carriers.manage.
|
||||
* - PATCH / DELETE /api/carrier_contacts/{id} : security
|
||||
* transport.carriers.manage.
|
||||
* - GET /api/carrier_contacts/{id} : lecture unitaire (security view) — la
|
||||
* lecture courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le CarrierContactProcessor (rattachement parent + RG-4.08 +
|
||||
* RG-4.13).
|
||||
*
|
||||
* Telephones (RG-4.08, max 2) : le contrat d'ecriture expose un tableau virtuel
|
||||
* `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») mappe par le
|
||||
* Processor vers `phonePrimary` / `phoneSecondary` (un 3e numero -> 422). Les
|
||||
* deux colonnes scalaires restent en lecture seule (embarquees au detail).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`
|
||||
* (embarquees au detail). Les sous-ressources d'ecriture arrivent au WT7.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/carriers/{carrierId}/contacts',
|
||||
uriVariables: [
|
||||
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT CarrierContact ... WHERE carrier = :id)
|
||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par CarrierContactProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:contacts']],
|
||||
processor: CarrierContactProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:contacts']],
|
||||
processor: CarrierContactProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
processor: CarrierContactProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_contact')]
|
||||
#[ORM\Index(name: 'idx_carrier_contact_carrier', columns: ['carrier_id'])]
|
||||
@@ -98,27 +39,18 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
// RG-4.08 : aucun champ obligatoire isolement (≥ 1 champ rempli, garde
|
||||
// Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM.
|
||||
#[ORM\Column(name: 'first_name', length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(name: 'last_name', length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(name: 'job_title', length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
// Telephones en LECTURE seule : alimentes en ecriture via le tableau virtuel
|
||||
// `phones` (mappe par le CarrierContactProcessor). Pas de groupe write -> pas
|
||||
// de saisie directe (et donc exemptes du miroir Assert\Length, le Processor
|
||||
// borne deja la longueur).
|
||||
#[ORM\Column(name: 'phone_primary', length: 20, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $phonePrimary = null;
|
||||
@@ -128,22 +60,9 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
|
||||
private ?string $phoneSecondary = null;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
|
||||
#[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $email = null;
|
||||
|
||||
/**
|
||||
* Telephones en ecriture (RG-4.08, max 2), NON persiste : le
|
||||
* CarrierContactProcessor normalise chaque numero (RG-4.13) puis le mappe vers
|
||||
* phonePrimary / phoneSecondary. null = non fourni (PATCH partiel : on ne
|
||||
* touche pas aux telephones existants). Un 3e numero -> 422 sur `phones`.
|
||||
*
|
||||
* @var null|list<string>
|
||||
*/
|
||||
#[Groups(['carrier:write:contacts'])]
|
||||
private ?array $phones = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
@@ -236,24 +155,6 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|list<string>
|
||||
*/
|
||||
public function getPhones(): ?array
|
||||
{
|
||||
return $this->phones;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|list<string> $phones
|
||||
*/
|
||||
public function setPhones(?array $phones): static
|
||||
{
|
||||
$this->phones = $phones;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierPriceProcessor;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\ClientAddressInterface;
|
||||
@@ -22,7 +15,6 @@ use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Prix d'un transporteur (1:n) — onglet Prix (M4, RG-4.09→4.11). Une ligne porte
|
||||
@@ -38,73 +30,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* (client:read / client_address:read / supplier:read / supplier_address:read /
|
||||
* site:read), inclus dans le contexte du Get racine de Carrier (§ 4.0).
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:prices`.
|
||||
*
|
||||
* Sous-ressource API (ERP-161, spec § 4.5) — jumelle de CarrierAddress /
|
||||
* CarrierContact :
|
||||
* - POST /api/carriers/{carrierId}/prices : creation rattachee au transporteur
|
||||
* parent (Link toProperty 'carrier'), security transport.carriers.manage.
|
||||
* - PATCH / DELETE /api/carrier_prices/{id} : security transport.carriers.manage.
|
||||
* - GET /api/carrier_prices/{id} : lecture unitaire (security view).
|
||||
* Tout passe par le CarrierPriceProcessor (rattachement parent + RG-4.09→4.11 :
|
||||
* coherence de branche CLIENT/FOURNISSEUR + appartenance de l'adresse).
|
||||
*
|
||||
* Les champs communs (direction, containerType, pricingUnit, price, priceState)
|
||||
* sont obligatoires (Assert\NotBlank + Assert\Choice). L'obligation conditionnelle
|
||||
* des champs de branche (client/supplier + adresses + sites) et l'appartenance de
|
||||
* l'adresse au client/fournisseur sont portees par le Processor (violations Hydra
|
||||
* a la main) : ces RG dependent de relations resolues a la denormalisation et non
|
||||
* exprimables par une simple contrainte d'attribut.
|
||||
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`. Les
|
||||
* sous-ressources d'ecriture + validation des branches (Processor) : WT8.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => [
|
||||
'carrier:item:read',
|
||||
'client:read', 'client_address:read',
|
||||
'supplier:read', 'supplier_address:read',
|
||||
'site:read', 'default:read',
|
||||
]],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/carriers/{carrierId}/prices',
|
||||
uriVariables: [
|
||||
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT CarrierPrice ... WHERE carrier = :id) et
|
||||
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par CarrierPriceProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => [
|
||||
'carrier:item:read',
|
||||
'client:read', 'client_address:read',
|
||||
'supplier:read', 'supplier_address:read',
|
||||
'site:read', 'default:read',
|
||||
]],
|
||||
denormalizationContext: ['groups' => ['carrier:write:prices']],
|
||||
processor: CarrierPriceProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => [
|
||||
'carrier:item:read',
|
||||
'client:read', 'client_address:read',
|
||||
'supplier:read', 'supplier_address:read',
|
||||
'site:read', 'default:read',
|
||||
]],
|
||||
denormalizationContext: ['groups' => ['carrier:write:prices']],
|
||||
processor: CarrierPriceProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
processor: CarrierPriceProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_price')]
|
||||
#[ORM\Index(name: 'idx_carrier_price_carrier', columns: ['carrier_id'])]
|
||||
@@ -133,74 +61,61 @@ class CarrierPrice implements TimestampableInterface, BlamableInterface
|
||||
|
||||
/** CLIENT|FOURNISSEUR (RG-4.09) — pilote la branche active. */
|
||||
#[ORM\Column(length: 12)]
|
||||
#[Assert\NotBlank(message: 'Le sens du prix est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR'], message: 'Le sens du prix est invalide.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $direction = null;
|
||||
|
||||
// === Branche CLIENT (RG-4.10) ===
|
||||
// Obligation conditionnelle (direction=CLIENT) + appartenance de l'adresse au
|
||||
// client : portees par le CarrierPriceProcessor (relations resolues a la
|
||||
// denormalisation, hors portee d'une contrainte d'attribut).
|
||||
#[ORM\ManyToOne(targetEntity: ClientInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?ClientInterface $client = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClientAddressInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'client_delivery_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?ClientAddressInterface $clientDeliveryAddress = null;
|
||||
|
||||
/** Adresse de depart = un des 3 sites (86/17/82). */
|
||||
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'departure_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?SiteInterface $departureSite = null;
|
||||
|
||||
// === Branche FOURNISSEUR (RG-4.11) ===
|
||||
#[ORM\ManyToOne(targetEntity: SupplierInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?SupplierInterface $supplier = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: SupplierAddressInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'supplier_supply_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?SupplierAddressInterface $supplierSupplyAddress = null;
|
||||
|
||||
/** Adresse de livraison = un des 3 sites (86/17/82). */
|
||||
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'delivery_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?SiteInterface $deliverySite = null;
|
||||
|
||||
// === Commun (toujours obligatoires, RG-4.10/4.11) ===
|
||||
// === Commun ===
|
||||
/** BENNE|FOND_MOUVANT. */
|
||||
#[ORM\Column(name: 'container_type', length: 12)]
|
||||
#[Assert\NotBlank(message: 'Le type de contenant est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Le type de contenant est invalide.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $containerType = null;
|
||||
|
||||
/** FORFAIT|TONNE. */
|
||||
#[ORM\Column(name: 'pricing_unit', length: 8)]
|
||||
#[Assert\NotBlank(message: 'L\'unite de tarification est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['FORFAIT', 'TONNE'], message: 'L\'unite de tarification est invalide.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $pricingUnit = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
|
||||
#[Assert\NotBlank(message: 'Le prix est obligatoire.')]
|
||||
#[Assert\PositiveOrZero(message: 'Le prix ne peut pas etre negatif.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $price = null;
|
||||
|
||||
/** EN_COURS|VALIDE|NON_VALIDE. */
|
||||
#[ORM\Column(name: 'price_state', length: 12)]
|
||||
#[Assert\NotBlank(message: 'L\'etat du prix est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['EN_COURS', 'VALIDE', 'NON_VALIDE'], message: 'L\'etat du prix est invalide.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $priceState = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\QualimatCarrierSearchProvider;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -23,9 +26,8 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
* - cible de la FK editable `carrier.qualimat_carrier_id` (§ 2.5) ;
|
||||
* - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour
|
||||
* afficher statut + date de validite QUALIMAT (RG-4.04) ;
|
||||
* - endpoint de recherche `GET /api/qualimat_carriers?search=` pour la saisie
|
||||
* assistee du nom (§ 4.7) — fuzzy name (+ siret), SEULEMENT les lignes actives,
|
||||
* tri name ASC, paginee ; logique portee par QualimatCarrierSearchProvider.
|
||||
* - endpoint de recherche `GET /api/qualimat_carriers?...` pour la saisie
|
||||
* assistee du nom (§ 4.7) — filtres built-in name/siret (partiel), isActive.
|
||||
*
|
||||
* La table reste hors `schema_filter` Doctrine (doctrine.yaml) : c'est la
|
||||
* migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT
|
||||
@@ -34,14 +36,8 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
// Saisie assistee (§ 4.7 / RG-4.01) : ?search= fuzzy name (+ siret),
|
||||
// SEULEMENT les lignes actives, tri name ASC, paginee. La logique vit
|
||||
// dans le provider (forcage is_active + recherche multi-champs) car un
|
||||
// SearchFilter natif ne sait ni unifier name/siret sous un seul ?search=,
|
||||
// ni imposer cote serveur le filtre actif.
|
||||
new GetCollection(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
provider: QualimatCarrierSearchProvider::class,
|
||||
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
|
||||
),
|
||||
new Get(
|
||||
@@ -50,6 +46,9 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'siret' => 'partial'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isActive'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name'], arguments: ['orderParameterName' => 'order'])]
|
||||
#[ORM\Entity]
|
||||
// Mapping reproduisant a l'identique le DDL de la migration ERP-39
|
||||
// (Version20260612150000) pour que `schema:update --force` reste un no-op :
|
||||
|
||||
@@ -23,19 +23,11 @@ interface CarrierRepositoryInterface
|
||||
* Fetch-join uniquement qualimatCarrier (ManyToOne, sur — § 2.11) : la liste
|
||||
* n'embarque aucune sous-collection. Tri par defaut name ASC.
|
||||
*
|
||||
* Perimetre d'archivage (aligne sur ClientProvider/SupplierProvider/
|
||||
* ProviderProvider — toggle « Voir les archives » d'ERP-173) :
|
||||
* - $archivedOnly = true -> uniquement les archives (is_archived = true) ;
|
||||
* - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
|
||||
* - par defaut -> actifs seuls (is_archived = false).
|
||||
* $archivedOnly a la priorite sur $includeArchived.
|
||||
*
|
||||
* @param list<string> $certificationTypes filtre repetable (OR) sur certificationType
|
||||
*/
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
array $certificationTypes = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder;
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Repository;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Contrat du repository du referentiel QUALIMAT (M4, lecture seule). Implementation
|
||||
* Doctrine : App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository.
|
||||
*
|
||||
* La table `qualimat_carrier` est alimentee/soft-deletee EXCLUSIVEMENT par la
|
||||
* commande console `app:qualimat:sync` : ce contrat n'expose que de la lecture.
|
||||
*/
|
||||
interface QualimatCarrierRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* QueryBuilder de la saisie assistee (§ 4.7 / RG-4.01). Restreint aux lignes
|
||||
* actives (is_active = true), recherche fuzzy sur name (+ siret), tri name ASC.
|
||||
*
|
||||
* @param null|string $search texte de recherche libre (fuzzy name + siret)
|
||||
*/
|
||||
public function createSearchQueryBuilder(?string $search = null): QueryBuilder;
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Adresse d'un transporteur (M4,
|
||||
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2) / ProviderAddressProcessor
|
||||
* (M3), recentre sur le perimetre ERP-159, AVEC une garde propre au M4 : RG-4.05
|
||||
* (adresse obligatoire si le transporteur est affrete).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis garde
|
||||
* RG-4.05 (guardCharteredAddress). RG-4.06 (code postal, Assert\Regex) est portee
|
||||
* par l'entite et jouee par API Platform AVANT ce processor.
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* RG-4.05 vit ICI (et non en Assert\Callback sur l'entite) car elle depend du
|
||||
* transporteur PARENT, indisponible a la validation Symfony sur un POST
|
||||
* sous-ressource en read:false (le parent n'est rattache qu'au stade processor).
|
||||
* La violation est construite a la main avec le meme rendu Hydra que les
|
||||
* contraintes Symfony, donc consommable inline par champ (convention ERP-101).
|
||||
*
|
||||
* RG-4.07 (masquage du bouton « Valider » si QUALIMAT) est purement front : le
|
||||
* back accepte le PATCH normalement, aucune garde ici.
|
||||
*
|
||||
* La security d'operation (transport.carriers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
|
||||
*
|
||||
* @implements ProcessorInterface<CarrierAddress, null|CarrierAddress>
|
||||
*/
|
||||
final class CarrierAddressProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof CarrierAddress) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->guardCharteredAddress($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache l'adresse au transporteur parent de la sous-ressource POST
|
||||
* (/carriers/{carrierId}/addresses) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(CarrierAddress $address, array $uriVariables): void
|
||||
{
|
||||
if (null !== $address->getCarrier()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrierId = $uriVariables['carrierId'] ?? null;
|
||||
if (null === $carrierId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrier = $carrierId instanceof Carrier
|
||||
? $carrierId
|
||||
: $this->em->getRepository(Carrier::class)->find($carrierId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte carrier_id NOT NULL).
|
||||
if (!$carrier instanceof Carrier) {
|
||||
throw new NotFoundHttpException('Transporteur introuvable.');
|
||||
}
|
||||
|
||||
$address->setCarrier($carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit
|
||||
* porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une
|
||||
* violation 422 sur son propre propertyPath (mapping inline ERP-101). La
|
||||
* validation porte sur l'ETAT RESULTANT de l'adresse (apres application du
|
||||
* payload), donc identique sur POST et sur PATCH partiel. Sans affretement,
|
||||
* l'adresse reste partielle (champs nullable, RG-4.06 inchangee).
|
||||
*/
|
||||
private function guardCharteredAddress(CarrierAddress $address): void
|
||||
{
|
||||
$carrier = $address->getCarrier();
|
||||
if (!$carrier instanceof Carrier || !$carrier->isChartered()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$required = [
|
||||
'country' => [$address->getCountry(), 'Le pays est obligatoire pour un transporteur affrété.'],
|
||||
'postalCode' => [$address->getPostalCode(), 'Le code postal est obligatoire pour un transporteur affrété.'],
|
||||
'city' => [$address->getCity(), 'La ville est obligatoire pour un transporteur affrété.'],
|
||||
'street' => [$address->getStreet(), 'L\'adresse est obligatoire pour un transporteur affrété.'],
|
||||
];
|
||||
|
||||
$violations = new ConstraintViolationList();
|
||||
foreach ($required as $path => [$value, $message]) {
|
||||
if (null === $value || '' === trim($value)) {
|
||||
$violations->add(new ConstraintViolation($message, null, [], $address, $path, $value));
|
||||
}
|
||||
}
|
||||
|
||||
if (0 < $violations->count()) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
}
|
||||
-235
@@ -1,235 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierContact;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
use function count;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
|
||||
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
|
||||
* perimetre ERP-160, AVEC deux specificites M4 : RG-4.08 (≥ 1 champ rempli, max
|
||||
* 2 telephones) portee a la fois par le CHECK BDD chk_carrier_contact_filled et
|
||||
* par ce Processor.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au transporteur parent (linkParent),
|
||||
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
|
||||
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
|
||||
* (max 2, chiffres uniquement), puis garde RG-4.08 (≥ 1 champ) avant
|
||||
* persistance.
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* RG-4.08 vit ICI (double du CHECK BDD) pour transformer une violation SQL (500
|
||||
* generique) en 422 propre rattachee au champ `firstName` (mapping inline
|
||||
* ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
|
||||
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
|
||||
* lecture seule).
|
||||
*
|
||||
* La security d'operation (transport.carriers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
|
||||
* (Assert\Email, Assert\Length...).
|
||||
*
|
||||
* @implements ProcessorInterface<CarrierContact, null|CarrierContact>
|
||||
*/
|
||||
final class CarrierContactProcessor implements ProcessorInterface
|
||||
{
|
||||
/** RG-4.08 : nombre maximal de telephones par contact. */
|
||||
private const int MAX_PHONES = 2;
|
||||
|
||||
/** Longueur max d'un telephone normalise (colonne VARCHAR(20)). */
|
||||
private const int PHONE_MAX_LENGTH = 20;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly CarrierFieldNormalizer $normalizer,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof CarrierContact) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->normalize($data);
|
||||
$this->applyPhones($data);
|
||||
$this->validateAtLeastOneField($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le contact au transporteur parent de la sous-ressource POST
|
||||
* (/carriers/{carrierId}/contacts) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH (entite existante),
|
||||
* le transporteur est deja present -> no-op.
|
||||
*/
|
||||
private function linkParent(CarrierContact $contact, array $uriVariables): void
|
||||
{
|
||||
if (null !== $contact->getCarrier()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrierId = $uriVariables['carrierId'] ?? null;
|
||||
if (null === $carrierId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrier = $carrierId instanceof Carrier
|
||||
? $carrierId
|
||||
: $this->em->getRepository(Carrier::class)->find($carrierId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte carrier_id NOT NULL).
|
||||
if (!$carrier instanceof Carrier) {
|
||||
throw new NotFoundHttpException('Transporteur introuvable.');
|
||||
}
|
||||
|
||||
$contact->setCarrier($carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du
|
||||
* normalizer sont null-safe : une chaine vide apres trim devient null (donc la
|
||||
* garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont
|
||||
* traites a part (applyPhones).
|
||||
*/
|
||||
private function normalize(CarrierContact $contact): void
|
||||
{
|
||||
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
|
||||
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
|
||||
$contact->setJobTitle($this->blankToNull($contact->getJobTitle()));
|
||||
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe le tableau d'ecriture `phones` (max 2, RG-4.08) vers phonePrimary /
|
||||
* phoneSecondary apres normalisation RG-4.13 (chiffres uniquement). Les
|
||||
* numeros vides (sans chiffre) sont ecartes. null = champ non fourni (PATCH
|
||||
* partiel) -> on ne touche pas aux telephones existants. Un 3e numero
|
||||
* exploitable, ou un numero trop long (> colonne VARCHAR(20)), -> 422 sur
|
||||
* `phones`.
|
||||
*/
|
||||
private function applyPhones(CarrierContact $contact): void
|
||||
{
|
||||
$phones = $contact->getPhones();
|
||||
if (null === $phones) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($phones as $phone) {
|
||||
$digits = $this->normalizer->normalizePhone(is_string($phone) ? $phone : null);
|
||||
if (null !== $digits) {
|
||||
$normalized[] = $digits;
|
||||
}
|
||||
}
|
||||
|
||||
$violations = new ConstraintViolationList();
|
||||
if (self::MAX_PHONES < count($normalized)) {
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Un contact ne peut comporter plus de deux téléphones.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'phones',
|
||||
$phones,
|
||||
));
|
||||
}
|
||||
foreach ($normalized as $digits) {
|
||||
if (self::PHONE_MAX_LENGTH < mb_strlen($digits)) {
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Un numéro de téléphone ne peut dépasser '.self::PHONE_MAX_LENGTH.' caractères.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'phones',
|
||||
$phones,
|
||||
));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (0 < $violations->count()) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
|
||||
$contact->setPhonePrimary($normalized[0] ?? null);
|
||||
$contact->setPhoneSecondary($normalized[1] ?? null);
|
||||
// Nettoie le champ virtuel (non persiste, mais evite toute fuite ulterieure).
|
||||
$contact->setPhones(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli
|
||||
* (firstName, lastName, jobTitle, phonePrimary ou email — meme perimetre que
|
||||
* le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double
|
||||
* garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL.
|
||||
* Joue apres normalisation + mapping telephones, donc les chaines vides sont
|
||||
* deja ramenees a null.
|
||||
*/
|
||||
private function validateAtLeastOneField(CarrierContact $contact): void
|
||||
{
|
||||
if (
|
||||
null === $contact->getFirstName()
|
||||
&& null === $contact->getLastName()
|
||||
&& null === $contact->getJobTitle()
|
||||
&& null === $contact->getPhonePrimary()
|
||||
&& null === $contact->getEmail()
|
||||
) {
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Renseignez au moins un champ pour le contact.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'firstName',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
|
||||
* contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ
|
||||
* « non rempli » meme si le client envoie une chaine vide.
|
||||
*/
|
||||
private function blankToNull(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
}
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Prix d'un transporteur (M4,
|
||||
* spec-back § 4.5, ERP-161). Jumeau des CarrierAddressProcessor / CarrierContactProcessor.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis
|
||||
* validation de la coherence de branche CLIENT/FOURNISSEUR (RG-4.09→4.11).
|
||||
* - DELETE : suppression physique directe (aucune regle metier specifique).
|
||||
*
|
||||
* RG-4.10 (branche CLIENT) : `client`, `clientDeliveryAddress`, `departureSite`
|
||||
* obligatoires ; l'adresse de livraison doit appartenir au client choisi.
|
||||
* RG-4.11 (branche FOURNISSEUR) : `supplier`, `supplierSupplyAddress`,
|
||||
* `deliverySite` obligatoires ; l'adresse d'appro doit appartenir au fournisseur.
|
||||
* Ces RG vivent ICI (et non en contrainte d'attribut) car elles dependent de
|
||||
* relations resolues a la denormalisation (et le parent carrier est indisponible
|
||||
* en validation Symfony sur un POST sous-ressource read:false). On nettoie aussi
|
||||
* la branche opposee (les CHECK BDD imposent ses colonnes nulles) — transforme une
|
||||
* violation SQL (500) en 422 propre rattachee au champ (mapping inline ERP-101).
|
||||
*
|
||||
* Les champs communs obligatoires (direction, containerType, pricingUnit, price,
|
||||
* priceState) sont valides en amont par les contraintes d'attribut (Assert\NotBlank
|
||||
* + Assert\Choice), de meme que la security d'operation (transport.carriers.manage).
|
||||
*
|
||||
* @implements ProcessorInterface<CarrierPrice, null|CarrierPrice>
|
||||
*/
|
||||
final class CarrierPriceProcessor implements ProcessorInterface
|
||||
{
|
||||
private const string DIRECTION_CLIENT = 'CLIENT';
|
||||
|
||||
private const string DIRECTION_SUPPLIER = 'FOURNISSEUR';
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof CarrierPrice) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->validateBranch($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le prix au transporteur parent de la sous-ressource POST
|
||||
* (/carriers/{carrierId}/prices) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH (entite existante),
|
||||
* le transporteur est deja present -> no-op.
|
||||
*/
|
||||
private function linkParent(CarrierPrice $price, array $uriVariables): void
|
||||
{
|
||||
if (null !== $price->getCarrier()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrierId = $uriVariables['carrierId'] ?? null;
|
||||
if (null === $carrierId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrier = $carrierId instanceof Carrier
|
||||
? $carrierId
|
||||
: $this->em->getRepository(Carrier::class)->find($carrierId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte carrier_id NOT NULL).
|
||||
if (!$carrier instanceof Carrier) {
|
||||
throw new NotFoundHttpException('Transporteur introuvable.');
|
||||
}
|
||||
|
||||
$price->setCarrier($carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.09→4.11 : valide la coherence de la branche active (CLIENT vs
|
||||
* FOURNISSEUR) et nettoie la branche opposee (les CHECK BDD imposent ses
|
||||
* colonnes nulles). Toutes les violations sont collectees puis renvoyees d'un
|
||||
* coup (un seul aller-retour, mapping inline par champ — ERP-101). La direction
|
||||
* elle-meme est deja garantie CLIENT|FOURNISSEUR par Assert\NotBlank + Choice.
|
||||
*/
|
||||
private function validateBranch(CarrierPrice $price): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
|
||||
if (self::DIRECTION_CLIENT === $price->getDirection()) {
|
||||
$this->requireField($violations, $price, 'client', $price->getClient(), 'Le client est obligatoire pour un prix client.');
|
||||
$this->requireField($violations, $price, 'clientDeliveryAddress', $price->getClientDeliveryAddress(), 'L\'adresse de livraison du client est obligatoire pour un prix client.');
|
||||
$this->requireField($violations, $price, 'departureSite', $price->getDepartureSite(), 'Le site de depart est obligatoire pour un prix client.');
|
||||
|
||||
// RG-4.10 : l'adresse de livraison doit appartenir au client choisi.
|
||||
$client = $price->getClient();
|
||||
$address = $price->getClientDeliveryAddress();
|
||||
if (null !== $client && null !== $address && $address->getClient()?->getId() !== $client->getId()) {
|
||||
$violations->add($this->violation($price, 'clientDeliveryAddress', 'L\'adresse de livraison doit appartenir au client selectionne.'));
|
||||
}
|
||||
|
||||
// Coherence CHECK chk_carrier_price_client_branch : branche fournisseur nulle.
|
||||
$price->setSupplier(null);
|
||||
$price->setSupplierSupplyAddress(null);
|
||||
$price->setDeliverySite(null);
|
||||
} elseif (self::DIRECTION_SUPPLIER === $price->getDirection()) {
|
||||
$this->requireField($violations, $price, 'supplier', $price->getSupplier(), 'Le fournisseur est obligatoire pour un prix fournisseur.');
|
||||
$this->requireField($violations, $price, 'supplierSupplyAddress', $price->getSupplierSupplyAddress(), 'L\'adresse d\'approvisionnement est obligatoire pour un prix fournisseur.');
|
||||
$this->requireField($violations, $price, 'deliverySite', $price->getDeliverySite(), 'Le site de livraison est obligatoire pour un prix fournisseur.');
|
||||
|
||||
// RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi.
|
||||
$supplier = $price->getSupplier();
|
||||
$address = $price->getSupplierSupplyAddress();
|
||||
if (null !== $supplier && null !== $address && $address->getSupplier()?->getId() !== $supplier->getId()) {
|
||||
$violations->add($this->violation($price, 'supplierSupplyAddress', 'L\'adresse d\'approvisionnement doit appartenir au fournisseur selectionne.'));
|
||||
}
|
||||
|
||||
// Coherence CHECK chk_carrier_price_supplier_branch : branche client nulle.
|
||||
$price->setClient(null);
|
||||
$price->setClientDeliveryAddress(null);
|
||||
$price->setDepartureSite(null);
|
||||
}
|
||||
|
||||
if (0 < $violations->count()) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une violation « champ obligatoire » sur `$path` si la relation est
|
||||
* absente (branche active, RG-4.10/4.11).
|
||||
*/
|
||||
private function requireField(ConstraintViolationList $violations, CarrierPrice $price, string $path, ?object $value, string $message): void
|
||||
{
|
||||
if (null === $value) {
|
||||
$violations->add($this->violation($price, $path, $message));
|
||||
}
|
||||
}
|
||||
|
||||
private function violation(CarrierPrice $price, string $path, string $message): ConstraintViolation
|
||||
{
|
||||
return new ConstraintViolation($message, null, [], $price, $path, null);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
* Collection (GET /api/carriers) :
|
||||
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes ;
|
||||
* - ?includeArchived=true reintegre les archives (soft-deletes toujours exclus) ;
|
||||
* - ?archivedOnly=true n'expose QUE les archives (prioritaire sur includeArchived,
|
||||
* aligne sur Client/Supplier/Provider — toggle « Voir les archives » ERP-173) ;
|
||||
* - filtres ?search= (fuzzy name) et ?certificationType= (repetable) ;
|
||||
* - tri par defaut name ASC ; pagination Hydra (regle n°13) + echappatoire
|
||||
* ?pagination=false.
|
||||
@@ -60,7 +58,6 @@ final class CarrierProvider implements ProviderInterface
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
|
||||
$search = $filters['search'] ?? null;
|
||||
$certificationTypes = $this->readStringList($filters['certificationType'] ?? []);
|
||||
|
||||
@@ -68,12 +65,11 @@ final class CarrierProvider implements ProviderInterface
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
$certificationTypes,
|
||||
$archivedOnly,
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete (selects front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
// @var list<Carrier> $carriers
|
||||
/** @var list<Carrier> $carriers */
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider de la saisie assistee QUALIMAT (spec-back § 4.7 / RG-4.01).
|
||||
*
|
||||
* GET /api/qualimat_carriers?search=<texte> :
|
||||
* - restreint aux lignes actives (is_active = true) — regle serveur, pas un
|
||||
* filtre client desactivable ;
|
||||
* - recherche fuzzy insensible a la casse sur name (+ siret) ;
|
||||
* - tri par name ASC ;
|
||||
* - pagination Hydra (regle n°13) + echappatoire ?pagination=false (selects).
|
||||
*
|
||||
* Branche uniquement sur la GetCollection ; le Get unitaire reste servi par le
|
||||
* provider ORM par defaut (lecture seule, aucune ecriture exposee).
|
||||
*
|
||||
* @implements ProviderInterface<QualimatCarrier>
|
||||
*/
|
||||
final class QualimatCarrierSearchProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository')]
|
||||
private readonly QualimatCarrierRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<QualimatCarrier>|Paginator<QualimatCarrier>
|
||||
*/
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$search = $filters['search'] ?? null;
|
||||
|
||||
$qb = $this->repository->createSearchQueryBuilder(is_string($search) ? $search : null);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete (selects front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
// @var list<QualimatCarrier> $carriers
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// fetchJoinCollection: false — aucune jointure to-many (referentiel plat).
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX du repertoire transporteurs (M4, spec-back § 4.6). Jumeau des
|
||||
* controllers d'export SupplierExportController (M2) / ProviderExportController
|
||||
* (M3) — references en prose volontairement (pas de {@see} : un import
|
||||
* inter-module violerait la regle ABSOLUE n°1). Simplifie : pas de cloisonnement
|
||||
* par site (§ 2.3) ni de colonne gatee par une permission comptable.
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
|
||||
* sur la route : sans cela API Platform capterait `/api/carriers/export.xlsx`
|
||||
* comme l'item `GET /api/carriers/{id}.{_format}` (id="export", _format="xlsx")
|
||||
* — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des transporteurs (MEMES filtres que
|
||||
* `GET /api/carriers`, via {@see CarrierRepositoryInterface::createListQueryBuilder()}
|
||||
* — l'export reflete exactement ce que l'utilisateur voit a l'ecran) et mapping
|
||||
* metier des colonnes.
|
||||
*/
|
||||
#[AsController]
|
||||
final class CarrierExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
|
||||
private readonly CarrierRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
) {}
|
||||
|
||||
#[Route('/api/carriers/export.xlsx', name: 'transport_carriers_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('transport.carriers.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
// Memes filtres que la vue liste (CarrierProvider) pour que l'export
|
||||
// reflete exactement ce que l'utilisateur voit a l'ecran :
|
||||
// - includeArchived : reintegre les archives en plus des actifs ;
|
||||
// - archivedOnly : n'exporte QUE les archives (prioritaire sur
|
||||
// includeArchived, aligne sur le provider — toggle « Voir les archives ») ;
|
||||
// - search : recherche fuzzy sur le nom ;
|
||||
// - certificationType : filtre repetable (?certificationType[]=A&...).
|
||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
$certificationTypes = $this->readStringList($request->query->all()['certificationType'] ?? []);
|
||||
|
||||
/** @var list<Carrier> $carriers */
|
||||
$carriers = $this->repository
|
||||
->createListQueryBuilder($includeArchived, $search, $certificationTypes, $archivedOnly)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Répertoire transporteurs',
|
||||
$this->buildHeaders(),
|
||||
$this->buildRows($carriers),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.6).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Nom',
|
||||
'Certification',
|
||||
'Statut QUALIMAT',
|
||||
'Date de validité',
|
||||
'Affrété',
|
||||
'Volume m³',
|
||||
'Date de création',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Carrier> $carriers
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $carriers): iterable
|
||||
{
|
||||
foreach ($carriers as $carrier) {
|
||||
// Statut / date de validite proviennent du referentiel QUALIMAT lie
|
||||
// (RG-4.04), deja fetch-joine par le repository (anti N+1, § 2.11).
|
||||
$qualimat = $carrier->getQualimatCarrier();
|
||||
|
||||
yield [
|
||||
$carrier->getName(),
|
||||
$carrier->getCertificationType() ?? '',
|
||||
$qualimat?->getStatus() ?? '',
|
||||
$qualimat?->getValidityDate()?->format('d/m/Y') ?? '',
|
||||
$carrier->isChartered() ? 'Oui' : 'Non',
|
||||
$carrier->getVolumeM3() ?? '',
|
||||
$carrier->getCreatedAt()?->format('d/m/Y'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('repertoire-transporteurs-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
* Aligne sur CarrierProvider pour un comportement identique a la liste.
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines (valeur unique ou liste).
|
||||
* Aligne sur CarrierProvider pour un comportement identique a la liste.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX du tableau Prix d'un transporteur (M4, spec-back § 4.6 / spec-front
|
||||
* § « Onglet Prix »). Reproduit le tableau de consultation regroupe par type de
|
||||
* contenant (Fond Mouvant / Benne — colonnes du docx p.10).
|
||||
*
|
||||
* Controller Symfony custom (binaire de fichier, pas une representation Hydra).
|
||||
* `priority: 1` est OBLIGATOIRE : sans cela API Platform capterait
|
||||
* `/api/carriers/{id}/prices/export.xlsx` via ses routes generiques.
|
||||
*
|
||||
* Separation des responsabilites : le COMMENT (generation) est delegue au service
|
||||
* Shared {@see SpreadsheetExporterInterface} ; le QUOI (chargement du transporteur,
|
||||
* regroupement par contenant, mapping metier des colonnes) vit ICI.
|
||||
*
|
||||
* Adresses cross-module : les contrats Shared (ClientInterface / SupplierInterface
|
||||
* / SiteInterface) exposent volontairement le minimum (regle ABSOLUE n°1). Faute
|
||||
* d'acceder au detail postal d'une adresse Client/Fournisseur sans coupler au
|
||||
* module Commercial, les colonnes d'adresse identifient le point par le libelle
|
||||
* disponible : nom du site pour un Site, raison sociale du client/fournisseur pour
|
||||
* une adresse de livraison/approvisionnement.
|
||||
*/
|
||||
#[AsController]
|
||||
final class CarrierPriceExportController
|
||||
{
|
||||
/** Libelles d'affichage des enums (spec-front « Onglet Prix »). */
|
||||
private const array CONTAINER_LABELS = ['BENNE' => 'Benne', 'FOND_MOUVANT' => 'Fond Mouvant'];
|
||||
|
||||
private const array PRICE_STATE_LABELS = [
|
||||
'EN_COURS' => 'En cours',
|
||||
'VALIDE' => 'Validé',
|
||||
'NON_VALIDE' => 'Non validé',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
|
||||
private readonly CarrierRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
) {}
|
||||
|
||||
#[Route('/api/carriers/{id}/prices/export.xlsx', name: 'transport_carrier_prices_export_xlsx', requirements: ['id' => '\d+'], methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('transport.carriers.view')]
|
||||
public function __invoke(int $id): Response
|
||||
{
|
||||
$carrier = $this->repository->findById($id);
|
||||
// Soft-delete jamais expose (comme CarrierProvider::provideItem) : 404.
|
||||
if (null === $carrier || null !== $carrier->getDeletedAt()) {
|
||||
throw new NotFoundHttpException('Transporteur introuvable.');
|
||||
}
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Prix transporteur',
|
||||
$this->buildHeaders(),
|
||||
$this->buildRows($carrier),
|
||||
);
|
||||
|
||||
return $this->buildResponse($carrier, $binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes du tableau Prix regroupe (spec-front « Onglet Prix » / docx p.10).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Type de contenant',
|
||||
'Transporteurs',
|
||||
'Adresse APRO ou Adresse Sites',
|
||||
'Adresse livraisons',
|
||||
'Forfait €',
|
||||
'Tonne €',
|
||||
'Indexation',
|
||||
'État du prix',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lignes regroupees par type de contenant (Fond Mouvant / Benne). On trie les
|
||||
* prix par contenant puis position pour materialiser le regroupement.
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(Carrier $carrier): iterable
|
||||
{
|
||||
$prices = $carrier->getPrices()->toArray();
|
||||
usort(
|
||||
$prices,
|
||||
static fn (CarrierPrice $a, CarrierPrice $b): int => [$a->getContainerType(), $a->getPosition()]
|
||||
<=> [$b->getContainerType(), $b->getPosition()],
|
||||
);
|
||||
|
||||
// Indexation : portee par le transporteur (RG-4.03), identique pour toutes
|
||||
// ses lignes de prix. Vide si non renseigne (spec-front).
|
||||
$indexation = $carrier->getIndexationRate() ?? '';
|
||||
|
||||
foreach ($prices as $price) {
|
||||
$isForfait = 'FORFAIT' === $price->getPricingUnit();
|
||||
|
||||
yield [
|
||||
self::CONTAINER_LABELS[$price->getContainerType()] ?? $price->getContainerType(),
|
||||
$carrier->getName(),
|
||||
$this->formatDeparture($price),
|
||||
$this->formatDelivery($price),
|
||||
$isForfait ? $price->getPrice() : '',
|
||||
$isForfait ? '' : $price->getPrice(),
|
||||
$indexation,
|
||||
self::PRICE_STATE_LABELS[$price->getPriceState()] ?? $price->getPriceState(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point de depart du prix (colonne « Adresse APRO ou Adresse Sites ») :
|
||||
* - branche CLIENT : le site de depart (un des 3 sites 86/17/82) ;
|
||||
* - branche FOURNISSEUR : l'adresse d'approvisionnement, identifiee par la
|
||||
* raison sociale du fournisseur (cf. note de classe sur les contrats Shared).
|
||||
*/
|
||||
private function formatDeparture(CarrierPrice $price): string
|
||||
{
|
||||
if ('CLIENT' === $price->getDirection()) {
|
||||
return $price->getDepartureSite()?->getName() ?? '';
|
||||
}
|
||||
|
||||
return $price->getSupplierSupplyAddress()?->getSupplier()?->getCompanyName() ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Point de livraison du prix (colonne « Adresse livraisons ») :
|
||||
* - branche CLIENT : l'adresse de livraison, identifiee par la raison sociale
|
||||
* du client ;
|
||||
* - branche FOURNISSEUR : le site de livraison (un des 3 sites 86/17/82).
|
||||
*/
|
||||
private function formatDelivery(CarrierPrice $price): string
|
||||
{
|
||||
if ('CLIENT' === $price->getDirection()) {
|
||||
return $price->getClientDeliveryAddress()?->getClient()?->getCompanyName() ?? '';
|
||||
}
|
||||
|
||||
return $price->getDeliverySite()?->getName() ?? '';
|
||||
}
|
||||
|
||||
private function buildResponse(Carrier $carrier, string $binary): Response
|
||||
{
|
||||
$filename = sprintf('prix-transporteur-%d-%s.xlsx', (int) $carrier->getId(), new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -4,311 +4,48 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\DataFixtures\ClientFixtures;
|
||||
use App\Module\Commercial\Infrastructure\DataFixtures\SupplierFixtures;
|
||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||
use App\Module\Transport\Domain\Entity\CarrierContact;
|
||||
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Shared\Domain\Contract\ClientAddressInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||
use App\Shared\Domain\Contract\SupplierAddressInterface;
|
||||
use App\Shared\Domain\Entity\UploadedDocument;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Fixtures dev/demo du repertoire transporteurs (M4) couvrant l'ensemble des cas
|
||||
* metier RG-4.xx, jumelles des fixtures fournisseurs (M2). C'est ICI que vivent
|
||||
* les fixtures COMPLETES (les maillons WT precedents s'etaient limites a un stub
|
||||
* de lecture). Cas pivots seedes (§ 8.4) :
|
||||
* - 1 transporteur QUALIMAT (lien `qualimat_carrier` + adresse copiee +
|
||||
* validityDate PASSEE pour exercer le fond rouge RG-4.04) ;
|
||||
* - 1 transporteur AUTRE + Decharge (UploadedDocument, RG-4.02) ;
|
||||
* - 1 transporteur affrete (indexation + benne + volume obligatoires, RG-4.03) ;
|
||||
* - 1 transporteur LIOT (immatriculations, certification non requise, RG-4.01) ;
|
||||
* - 1 transporteur COMPLET : contacts + adresses + prix CLIENT et FOURNISSEUR ;
|
||||
* - 1 transporteur archive (exclusion liste + restauration, RG-4.14).
|
||||
* Fixtures dev/test MINIMALES du repertoire transporteurs (M4, ERP-155/157) :
|
||||
* 2 transporteurs de demonstration suffisant a faire tourner les ecrans de
|
||||
* lecture (liste + detail). Les fixtures completes (cas QUALIMAT, affrete,
|
||||
* LIOT, prix CLIENT/FOURNISSEUR...) sont livrees par le worktree dedie (WT10) —
|
||||
* ne pas les developper ici (scope WT3 : contrat de lecture).
|
||||
*
|
||||
* Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) :
|
||||
* - sites resolus via le contrat Shared SiteProviderInterface ;
|
||||
* - client/adresse et fournisseur/adresse des prix resolus via les contrats
|
||||
* Shared ClientAddressInterface / SupplierAddressInterface (relations ORM
|
||||
* partagees, RG-4.10/4.11). Si la demo Commercial/Sites n'est pas chargee, les
|
||||
* prix sont simplement omis (le reste de la fiche reste seede).
|
||||
*
|
||||
* Normalisation : valeurs fournies BRUTES puis normalisees par
|
||||
* CarrierFieldNormalizer avant persist, comme le ferait le CarrierProcessor via
|
||||
* l'API (name UPPERCASE, first/last Capitalize, telephones chiffres seuls, email
|
||||
* lowercase, liotPlates « ; »-normalise).
|
||||
*
|
||||
* Idempotence : lookup par `name` normalise (coherent avec l'index unique partiel
|
||||
* uq_carrier_name_active). Un transporteur deja present n'est pas reconstruit (ses
|
||||
* sous-collections ne sont pas redupliquees). Rejouable sans doublon.
|
||||
*
|
||||
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
|
||||
* restent null (« Systeme » cote front), c'est attendu.
|
||||
*
|
||||
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la
|
||||
* fixture ne charge rien : les tests seedent et nettoient leurs propres
|
||||
* transporteurs et comptent sur une table `carrier` vierge — y injecter des
|
||||
* transporteurs de demo casserait les comptages de liste et les cleanups. Meme
|
||||
* garde-fou que ClientFixtures / SupplierFixtures.
|
||||
* Aucune dependance cross-module (pas de prix, pas de lien QUALIMAT) : la
|
||||
* fixture reste autonome et joue en fin de chaine sans contrainte d'ordre.
|
||||
*/
|
||||
class CarrierFixtures extends Fixture implements DependentFixtureInterface
|
||||
final class CarrierFixtures extends Fixture
|
||||
{
|
||||
/** SIRET de la ligne qualimat_carrier de demo (cle naturelle, insert idempotent). */
|
||||
private const string QUALIMAT_DEMO_SIRET = '90000000000017';
|
||||
|
||||
public function __construct(
|
||||
private readonly CarrierFieldNormalizer $normalizer,
|
||||
private readonly SiteProviderInterface $siteProvider,
|
||||
#[Autowire('%kernel.environment%')]
|
||||
private readonly string $environment,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, class-string>
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
// Les prix referencent des Client/Supplier/Site de demo (relations ORM
|
||||
// partagees) : ces fixtures doivent tourner avant.
|
||||
return [
|
||||
SitesFixtures::class,
|
||||
ClientFixtures::class,
|
||||
SupplierFixtures::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
|
||||
if ('test' === $this->environment) {
|
||||
return;
|
||||
}
|
||||
// Transporteur certifie « classique ».
|
||||
$alpha = new Carrier();
|
||||
$alpha->setName('TRANSPORTS ALPHA');
|
||||
$alpha->setCertificationType('GMP_PLUS');
|
||||
$manager->persist($alpha);
|
||||
|
||||
// === Transporteur QUALIMAT (RG-4.01) — adresse copiee + validite PASSEE (RG-4.04) ===
|
||||
[$grelillier, $isNew] = $this->ensureCarrier($manager, 'Transports Grelillier');
|
||||
if ($isNew) {
|
||||
$grelillier->setQualimatCarrier($this->ensureQualimatDemoLine($manager));
|
||||
$grelillier->setCertificationType('QUALIMAT');
|
||||
// Adresse pre-remplie depuis la copie QUALIMAT (RG-4.05).
|
||||
$this->addAddress($grelillier, '86000', 'Poitiers', '12 rue des Acacias');
|
||||
$this->addContact($grelillier, 'Marie', 'Martin', 'Exploitation', '06 12 34 56 78', null, 'marie.martin@grelillier.fr');
|
||||
}
|
||||
$contact = new CarrierContact();
|
||||
$contact->setCarrier($alpha);
|
||||
$contact->setLastName('Durand');
|
||||
$contact->setPhonePrimary('0612345678');
|
||||
$alpha->addContact($contact);
|
||||
$manager->persist($contact);
|
||||
|
||||
// === Transporteur AUTRE + Decharge (RG-4.02) ===
|
||||
[$pandele, $isNew] = $this->ensureCarrier($manager, 'Transports Pandele');
|
||||
if ($isNew) {
|
||||
$pandele->setCertificationType('AUTRE');
|
||||
$pandele->setDischargeDocument($this->buildDischargeDocument($manager));
|
||||
$this->addContact($pandele, 'Luc', 'Pandele', 'Gerant', '05 49 11 22 33', null, 'luc.pandele@pandele.fr');
|
||||
}
|
||||
|
||||
// === Transporteur affrete (RG-4.03) — indexation + benne + volume ===
|
||||
[$affrete, $isNew] = $this->ensureCarrier($manager, 'Affreteurs Reunis');
|
||||
if ($isNew) {
|
||||
$affrete->setCertificationType('GMP_PLUS');
|
||||
$affrete->setIsChartered(true);
|
||||
$affrete->setIndexationRate('5.00');
|
||||
$affrete->setContainerType('BENNE');
|
||||
$affrete->setVolumeM3('90.00');
|
||||
$this->addAddress($affrete, '17000', 'La Rochelle', '4 quai des Affreteurs');
|
||||
}
|
||||
|
||||
// === Cas LIOT (RG-4.01) — immatriculations, certification non requise ===
|
||||
[$liot, $isNew] = $this->ensureCarrier($manager, 'LIOT');
|
||||
if ($isNew) {
|
||||
$liot->setLiotPlates($this->normalizer->normalizeLiotPlates('ab-123-cd ; ef-456-gh ; gh-789-ij'));
|
||||
}
|
||||
|
||||
// === Transporteur COMPLET — contacts + adresses + prix CLIENT et FOURNISSEUR ===
|
||||
[$complet, $isNew] = $this->ensureCarrier($manager, 'Transports Logistique Globale');
|
||||
if ($isNew) {
|
||||
$complet->setCertificationType('OVOCOM');
|
||||
$this->addAddress($complet, '86100', 'Châtellerault', '20 zone des Transporteurs');
|
||||
$this->addContact($complet, 'Sophie', 'Bernard', 'Directrice', '05 49 44 55 66', '06 99 88 77 66', 'sophie.bernard@logistique-globale.fr', 0);
|
||||
$this->addContact($complet, 'Marc', 'Lopez', 'Affretement', '05 49 44 55 67', null, 'marc.lopez@logistique-globale.fr', 1);
|
||||
$this->addPrices($manager, $complet);
|
||||
}
|
||||
|
||||
// === Transporteur archive (RG-4.14) ===
|
||||
[$archive, $isNew] = $this->ensureCarrier($manager, 'Transports Anciens', isArchived: true);
|
||||
if ($isNew) {
|
||||
$archive->setCertificationType('COMPTE_PROPRE');
|
||||
$this->addContact($archive, 'Paul', 'Ancien', 'Ex-gerant', '05 49 00 00 00', null, 'paul.ancien@anciens.fr');
|
||||
}
|
||||
// Transporteur affrete (RG-4.03).
|
||||
$beta = new Carrier();
|
||||
$beta->setName('TRANSPORTS BETA');
|
||||
$beta->setCertificationType('AUTRE');
|
||||
$beta->setIsChartered(true);
|
||||
$beta->setIndexationRate('5.00');
|
||||
$beta->setContainerType('BENNE');
|
||||
$beta->setVolumeM3('90.00');
|
||||
$manager->persist($beta);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un transporteur (nom normalise UPPERCASE) s'il n'existe pas encore,
|
||||
* sinon retourne l'existant. Retourne [Carrier, isNew] : isNew=false bloque la
|
||||
* reconstruction des sous-collections (idempotence sans doublon).
|
||||
*
|
||||
* @return array{0: Carrier, 1: bool}
|
||||
*/
|
||||
private function ensureCarrier(ObjectManager $manager, string $name, bool $isArchived = false): array
|
||||
{
|
||||
$normalizedName = (string) $this->normalizer->normalizeName($name);
|
||||
|
||||
$existing = $manager->getRepository(Carrier::class)->findOneBy(['name' => $normalizedName]);
|
||||
if ($existing instanceof Carrier) {
|
||||
return [$existing, false];
|
||||
}
|
||||
|
||||
$carrier = new Carrier();
|
||||
$carrier->setName($normalizedName);
|
||||
|
||||
if ($isArchived) {
|
||||
$carrier->setIsArchived(true);
|
||||
$carrier->setArchivedAt(new DateTimeImmutable());
|
||||
}
|
||||
|
||||
$manager->persist($carrier);
|
||||
|
||||
return [$carrier, true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une adresse au transporteur (cascade persist via Carrier.addresses).
|
||||
*/
|
||||
private function addAddress(Carrier $carrier, string $postalCode, string $city, string $street): void
|
||||
{
|
||||
$address = new CarrierAddress();
|
||||
$address->setPostalCode($postalCode);
|
||||
$address->setCity($city);
|
||||
$address->setStreet($street);
|
||||
$carrier->addAddress($address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un contact normalise au transporteur (cascade persist via
|
||||
* Carrier.contacts). Au moins un champ est toujours fourni (RG-4.08).
|
||||
*/
|
||||
private function addContact(
|
||||
Carrier $carrier,
|
||||
?string $firstName,
|
||||
?string $lastName,
|
||||
?string $jobTitle,
|
||||
?string $phonePrimary,
|
||||
?string $phoneSecondary,
|
||||
?string $email,
|
||||
int $position = 0,
|
||||
): void {
|
||||
$contact = new CarrierContact();
|
||||
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
|
||||
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
|
||||
$contact->setJobTitle($jobTitle);
|
||||
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
|
||||
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
|
||||
$contact->setEmail($this->normalizer->normalizeEmail($email));
|
||||
$contact->setPosition($position);
|
||||
|
||||
$carrier->addContact($contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un prix CLIENT et un prix FOURNISSEUR au transporteur (RG-4.10/4.11),
|
||||
* en resolvant les relations cross-module (client/adresse de livraison + site
|
||||
* de depart ; fournisseur/adresse d'appro + site de livraison) via les contrats
|
||||
* Shared. Si la demo Commercial/Sites n'est pas disponible, les prix sont omis.
|
||||
*/
|
||||
private function addPrices(ObjectManager $manager, Carrier $carrier): void
|
||||
{
|
||||
$site = $this->siteProvider->findByName('Chatellerault');
|
||||
|
||||
// Branche CLIENT (RG-4.10) : 1ere adresse de livraison de la demo M1.
|
||||
$clientAddress = $manager->getRepository(ClientAddressInterface::class)->findOneBy(['isDelivery' => true]);
|
||||
if ($site instanceof SiteInterface && $clientAddress instanceof ClientAddressInterface && null !== $clientAddress->getClient()) {
|
||||
$clientPrice = new CarrierPrice();
|
||||
$clientPrice->setDirection('CLIENT');
|
||||
$clientPrice->setClient($clientAddress->getClient());
|
||||
$clientPrice->setClientDeliveryAddress($clientAddress);
|
||||
$clientPrice->setDepartureSite($site);
|
||||
$clientPrice->setContainerType('BENNE');
|
||||
$clientPrice->setPricingUnit('TONNE');
|
||||
$clientPrice->setPrice('42.50');
|
||||
$clientPrice->setPriceState('VALIDE');
|
||||
$carrier->addPrice($clientPrice);
|
||||
}
|
||||
|
||||
// Branche FOURNISSEUR (RG-4.11) : 1ere adresse de DEPART de la demo M2.
|
||||
$supplierAddress = $manager->getRepository(SupplierAddressInterface::class)->findOneBy(['addressType' => 'DEPART']);
|
||||
if ($site instanceof SiteInterface && $supplierAddress instanceof SupplierAddressInterface && null !== $supplierAddress->getSupplier()) {
|
||||
$supplierPrice = new CarrierPrice();
|
||||
$supplierPrice->setDirection('FOURNISSEUR');
|
||||
$supplierPrice->setSupplier($supplierAddress->getSupplier());
|
||||
$supplierPrice->setSupplierSupplyAddress($supplierAddress);
|
||||
$supplierPrice->setDeliverySite($site);
|
||||
$supplierPrice->setContainerType('FOND_MOUVANT');
|
||||
$supplierPrice->setPricingUnit('FORFAIT');
|
||||
$supplierPrice->setPrice('320.00');
|
||||
$supplierPrice->setPriceState('EN_COURS');
|
||||
$carrier->addPrice($supplierPrice);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit (non persiste explicitement — cascade via la FK Carrier) un
|
||||
* UploadedDocument de demo pour la Decharge (RG-4.02). Pas de fichier reel sur
|
||||
* disque : metadonnees factices suffisantes pour la demo.
|
||||
*/
|
||||
private function buildDischargeDocument(ObjectManager $manager): UploadedDocument
|
||||
{
|
||||
$document = new UploadedDocument(
|
||||
'decharge-demo.pdf',
|
||||
'demo/decharge-demo.pdf',
|
||||
'application/pdf',
|
||||
12_345,
|
||||
str_repeat('0', 64),
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$manager->persist($document);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insere (idempotent, par SIRET) une ligne `qualimat_carrier` de demo a
|
||||
* validite PASSEE (RG-4.04) puis retourne l'entite (lecture seule) rechargee.
|
||||
* La table est normalement alimentee par `app:qualimat:sync` ; en demo on pose
|
||||
* une ligne directe en DBAL (l'entite mappee n'expose aucune ecriture API).
|
||||
*/
|
||||
private function ensureQualimatDemoLine(ObjectManager $manager): QualimatCarrier
|
||||
{
|
||||
$repository = $manager->getRepository(QualimatCarrier::class);
|
||||
$existing = $repository->findOneBy(['siret' => self::QUALIMAT_DEMO_SIRET]);
|
||||
if ($existing instanceof QualimatCarrier) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
if ($manager instanceof EntityManagerInterface) {
|
||||
$manager->getConnection()->insert('qualimat_carrier', [
|
||||
'siret' => self::QUALIMAT_DEMO_SIRET,
|
||||
'name' => 'TRANSPORTS GRELILLIER',
|
||||
'address' => '12 rue des Acacias',
|
||||
'postal_code' => '86000',
|
||||
'city' => 'Poitiers',
|
||||
'status' => 'Valide',
|
||||
// Validite PASSEE : exerce le fond rouge RG-4.04 cote front.
|
||||
'validity_date' => '2024-12-31',
|
||||
'is_active' => 'true',
|
||||
'last_synced_at' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
// @var QualimatCarrier $line
|
||||
return $repository->findOneBy(['siret' => self::QUALIMAT_DEMO_SIRET]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ class DoctrineCarrierRepository extends ServiceEntityRepository implements Carri
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
array $certificationTypes = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder {
|
||||
// Fetch-join de la SEULE relation ManyToOne qualimatCarrier (sur, pas de
|
||||
// cartesien) pour exposer statut/date de validite QUALIMAT en liste sans
|
||||
@@ -48,11 +47,7 @@ class DoctrineCarrierRepository extends ServiceEntityRepository implements Carri
|
||||
;
|
||||
|
||||
// Pas de cloisonnement par site (§ 2.3) : referentiel global.
|
||||
// Perimetre d'archivage : archivedOnly prioritaire sur includeArchived
|
||||
// (jumeau de DoctrineProviderRepository — toggle « Voir les archives »).
|
||||
if ($archivedOnly) {
|
||||
$qb->andWhere('c.isArchived = true');
|
||||
} elseif (!$includeArchived) {
|
||||
if (!$includeArchived) {
|
||||
$qb->andWhere('c.isArchived = false');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<QualimatCarrier>
|
||||
*/
|
||||
class DoctrineQualimatCarrierRepository extends ServiceEntityRepository implements QualimatCarrierRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, QualimatCarrier::class);
|
||||
}
|
||||
|
||||
public function createSearchQueryBuilder(?string $search = null): QueryBuilder
|
||||
{
|
||||
// Saisie assistee (§ 4.7) : on ne propose QUE des transporteurs QUALIMAT
|
||||
// actifs (is_active = true), tries par nom. Le forcage de l'actif est une
|
||||
// regle serveur (pas un filtre client) — les lignes soft-deletees par la
|
||||
// synchro restent invisibles.
|
||||
$qb = $this->createQueryBuilder('q')
|
||||
->andWhere('q.isActive = true')
|
||||
->orderBy('q.name', 'ASC')
|
||||
;
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur le nom (+ siret) du transporteur
|
||||
* QUALIMAT (§ 4.7 / RG-4.01). Metacaracteres LIKE (%, _, \) echappes pour
|
||||
* rester litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||
{
|
||||
if (null === $search || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere('LOWER(q.name) LIKE :search OR LOWER(q.siret) LIKE :search')
|
||||
->setParameter('search', $pattern)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,4 @@ namespace App\Shared\Domain\Contract;
|
||||
interface ClientAddressInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
/**
|
||||
* Client parent de l'adresse. Expose le lien inverse sans coupler au module
|
||||
* Commercial : permet a un autre module de verifier l'appartenance d'une
|
||||
* adresse a un client (ex: CarrierPrice, RG-4.10 — l'adresse de livraison
|
||||
* doit appartenir au client choisi). Retour covariant ?Client cote entite.
|
||||
*/
|
||||
public function getClient(): ?ClientInterface;
|
||||
}
|
||||
|
||||
@@ -17,12 +17,4 @@ namespace App\Shared\Domain\Contract;
|
||||
interface SupplierAddressInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
/**
|
||||
* Fournisseur parent de l'adresse. Expose le lien inverse sans coupler au
|
||||
* module Commercial : permet a un autre module de verifier l'appartenance
|
||||
* d'une adresse a un fournisseur (ex: CarrierPrice, RG-4.11 — l'adresse
|
||||
* d'appro doit appartenir au fournisseur choisi). Retour covariant ?Supplier.
|
||||
*/
|
||||
public function getSupplier(): ?SupplierInterface;
|
||||
}
|
||||
|
||||
@@ -56,19 +56,12 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Idem cote prestataire (meme Regex CP — M3 Technique).
|
||||
'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Idem cote transporteur (meme Regex CP — M4 Transport).
|
||||
'CarrierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20).
|
||||
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
|
||||
// Le Choice {QUALIMAT,GMP_PLUS,OVOCOM,COMPTE_PROPRE,AUTRE} borne les valeurs (<= 13 < 20).
|
||||
'Carrier::certificationType' => 'Choice des 5 certifications borne deja les valeurs.',
|
||||
// Le Choice {BENNE,FOND_MOUVANT} borne les valeurs (<= 12).
|
||||
'Carrier::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.',
|
||||
// Colonnes enum du prix transporteur (M4) : le Choice borne deja les valeurs.
|
||||
'CarrierPrice::direction' => 'Choice {CLIENT,FOURNISSEUR} borne deja les valeurs.',
|
||||
'CarrierPrice::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.',
|
||||
'CarrierPrice::pricingUnit' => 'Choice {FORFAIT,TONNE} borne deja les valeurs.',
|
||||
'CarrierPrice::priceState' => 'Choice {EN_COURS,VALIDE,NON_VALIDE} borne deja les valeurs.',
|
||||
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
|
||||
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
|
||||
];
|
||||
@@ -114,7 +107,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
}
|
||||
|
||||
/** @var Constraint $constraint */
|
||||
$constraint = $attribute->newInstance();
|
||||
$constraint = $attribute->newInstance();
|
||||
$messageProps = $this->messagePropertiesFor($constraint);
|
||||
|
||||
self::assertNotNull(
|
||||
@@ -185,7 +178,6 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
foreach ($constraints as $c) {
|
||||
if ($c instanceof Assert\Length) {
|
||||
$length = $c->max;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -257,7 +249,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
* Liste des proprietes de message a verifier pour une contrainte donnee, ou
|
||||
* null si la contrainte n'est pas geree (le test echoue alors explicitement).
|
||||
*
|
||||
* @return null|list<string>
|
||||
* @return list<string>|null
|
||||
*/
|
||||
private function messagePropertiesFor(Constraint $constraint): ?array
|
||||
{
|
||||
@@ -331,7 +323,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Constraint> $constraints
|
||||
* @param list<Constraint> $constraints
|
||||
* @param list<class-string<Constraint>> $classes
|
||||
*/
|
||||
private function hasAnyConstraint(array $constraints, array $classes): bool
|
||||
|
||||
@@ -17,7 +17,6 @@ use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Base des tests fonctionnels du repertoire transporteurs (M4). Apporte les
|
||||
@@ -48,11 +47,9 @@ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
|
||||
// client/supplier), liberant les Client/Supplier de test pour leur purge.
|
||||
$em->createQuery('DELETE FROM '.Carrier::class)->execute();
|
||||
$em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p')
|
||||
->setParameter('p', self::TEST_REF_PREFIX.'%')->execute()
|
||||
;
|
||||
->setParameter('p', self::TEST_REF_PREFIX.'%')->execute();
|
||||
$em->createQuery('DELETE FROM '.Supplier::class.' s WHERE s.companyName LIKE :p')
|
||||
->setParameter('p', self::TEST_REF_PREFIX.'%')->execute()
|
||||
;
|
||||
->setParameter('p', self::TEST_REF_PREFIX.'%')->execute();
|
||||
// qualimat_carrier : insere en DBAL brut (entite lecture seule) -> purge DBAL.
|
||||
$em->getConnection()->executeStatement(
|
||||
'DELETE FROM qualimat_carrier WHERE siret LIKE :p',
|
||||
@@ -67,27 +64,6 @@ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
|
||||
return $this->authenticatedClient('admin', 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde-fou ERP-101 : verifie qu'une reponse 422 porte une violation sur le
|
||||
* `propertyPath` attendu (et pas seulement le bon code HTTP). Sans cette
|
||||
* assertion, une 422 venue d'une AUTRE cause (autre champ manquant, IRI 404)
|
||||
* ferait passer le test au vert sans prouver le mapping inline par champ.
|
||||
*
|
||||
* Mutualise dans la base (au lieu d'un duplicata par fichier) pour que toute
|
||||
* la stack d'ecriture (formulaire principal + sous-ressources) l'utilise.
|
||||
*/
|
||||
protected static function assertViolationOnPath(object $response, string $path): void
|
||||
{
|
||||
/** @var ResponseInterface $response */
|
||||
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
|
||||
|
||||
self::assertContains(
|
||||
$path,
|
||||
$paths,
|
||||
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload minimal valide du formulaire principal (transporteur non-QUALIMAT,
|
||||
* non affrete) : nom + certification GMP_PLUS. Sert de base aux tests
|
||||
@@ -271,7 +247,7 @@ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
|
||||
'status' => 'Valide',
|
||||
'validity_date' => '2027-12-31',
|
||||
'is_active' => 'true',
|
||||
'last_synced_at' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
'last_synced_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$qualimat = $em->getRepository(QualimatCarrier::class)->findOneBy(['siret' => $siret]);
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Sous-ressource Adresse d'un transporteur (spec-back M4 § 4.5, ERP-159).
|
||||
* POST /api/carriers/{id}/addresses, PATCH/DELETE /api/carrier_addresses/{id}.
|
||||
*
|
||||
* Contrat verifie :
|
||||
* - RG-4.06 : code postal hors ^[0-9]{4,5}$ -> 422 ;
|
||||
* - RG-4.05 : transporteur affrete + adresse incomplete -> 422 (par champ) ;
|
||||
* - RG-4.05 : transporteur affrete + adresse complete -> 201 ;
|
||||
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
|
||||
// qu'en recette), requis pour les tests de permission (bureau/commerciale).
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testInvalidPostalCodeReturns422(): void
|
||||
{
|
||||
// Transporteur NON affrete : RG-4.05 ne s'applique pas, seule RG-4.06 joue.
|
||||
$carrier = $this->seedCarrierWithChartered('Cp Invalide', false);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// La 422 doit cibler le champ fautif (mapping inline ERP-101), pas juste le code HTTP.
|
||||
self::assertViolationOnPath($response, 'postalCode');
|
||||
}
|
||||
|
||||
public function testInconsistentPostalCodeAndCityIsAccepted(): void
|
||||
{
|
||||
// RG-4.06 : la validation serveur borne le FORMAT du code postal
|
||||
// (^[0-9]{4,5}$) mais ne controle PAS la coherence CP <-> ville (deleguee
|
||||
// a l'autocomplete BAN cote front). Un CP valide avec une ville qui ne lui
|
||||
// correspond pas est donc accepte (201).
|
||||
$carrier = $this->seedCarrierWithChartered('Cp Ville Incoherents', false);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86000', // Poitiers
|
||||
'city' => 'Marseille', // incoherent, mais non controle
|
||||
'street' => '1 rue de la Coherence',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testCharteredCarrierIncompleteAddressReturns422(): void
|
||||
{
|
||||
// Transporteur affrete : RG-4.05 exige Pays/CP/Ville/Adresse. CP valide mais
|
||||
// ville + rue manquantes -> 422 conditionnelle (CarrierAddressProcessor).
|
||||
$carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// RG-4.05 mappe une violation PAR champ manquant (ville + rue ici) -> chaque
|
||||
// erreur s'affiche inline sous son champ (ERP-101).
|
||||
self::assertViolationOnPath($response, 'city');
|
||||
self::assertViolationOnPath($response, 'street');
|
||||
}
|
||||
|
||||
public function testCharteredCarrierCompleteAddressIsCreated(): void
|
||||
{
|
||||
$carrier = $this->seedCarrierWithChartered('Affrete Complet', true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'country' => 'France',
|
||||
'postalCode' => '86000',
|
||||
'city' => 'Poitiers',
|
||||
'street' => '12 rue des Acacias',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testPostAddressOnUnknownCarrierReturns404(): void
|
||||
{
|
||||
// Sous-ressource en read:false : le parent introuvable n'est plus intercepte
|
||||
// en amont -> le processor doit lever un 404 explicite (sinon 500 au persist).
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/999999/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testPatchAndDeleteSucceedWithManage(): void
|
||||
{
|
||||
$address = $this->seedAddress('Patch Delete', false);
|
||||
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
|
||||
|
||||
// PATCH (manage) -> 200
|
||||
$client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['city' => 'Lyon'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// DELETE (manage) -> 204
|
||||
$client->request('DELETE', '/api/carrier_addresses/'.$address->getId());
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testWriteForbiddenWithoutManage(): void
|
||||
{
|
||||
$address = $this->seedAddress('Forbidden', false);
|
||||
$carrier = $address->getCarrier();
|
||||
self::assertNotNull($carrier);
|
||||
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['city' => 'Lyon'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('DELETE', '/api/carrier_addresses/'.$address->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur minimal en controlant le flag affrete (RG-4.05).
|
||||
*/
|
||||
private function seedCarrierWithChartered(string $name, bool $isChartered): Carrier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = new Carrier();
|
||||
$carrier->setName(mb_strtoupper($name, 'UTF-8'));
|
||||
$carrier->setCertificationType('GMP_PLUS');
|
||||
$carrier->setIsChartered($isChartered);
|
||||
$em->persist($carrier);
|
||||
$em->flush();
|
||||
|
||||
return $carrier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur + une adresse rattachee (pour les tests PATCH/DELETE).
|
||||
*/
|
||||
private function seedAddress(string $name, bool $isChartered): CarrierAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = $this->seedCarrierWithChartered($name, $isChartered);
|
||||
|
||||
$address = new CarrierAddress();
|
||||
$address->setCarrier($carrier);
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
$carrier->addAddress($address);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Tests Audit du repertoire transporteurs (M4, spec § 6). Couvre :
|
||||
* - POST / PATCH / archivage -> ligne audit_log entity_type='transport.Carrier'
|
||||
* avec l'action et le diff attendus ;
|
||||
* - le diff d'archivage trace bien le champ `isArchived` (RG-4.14).
|
||||
*
|
||||
* Jumeau de {@see \App\Tests\Module\Commercial\Api\SupplierAuditTest}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierAuditTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string CARRIER_TYPE = 'transport.Carrier';
|
||||
|
||||
private ?Connection $auditConnection = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
self::bootKernel();
|
||||
|
||||
/** @var Connection $conn */
|
||||
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||
$this->auditConnection = $conn;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (null !== $this->auditConnection) {
|
||||
$this->auditConnection->close();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testPostCarrierIsAudited(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
|
||||
$created = $admin->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Audit Created Co'),
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
self::assertGreaterThanOrEqual(
|
||||
1,
|
||||
$this->countAudit(self::CARRIER_TYPE, (string) $created['id'], 'create'),
|
||||
'Un audit_log "create" doit etre genere pour le transporteur.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchCarrierIsAudited(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedCarrier('Audit Patch Co');
|
||||
|
||||
$admin->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['name' => 'Audit Patch Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
self::assertGreaterThanOrEqual(
|
||||
1,
|
||||
$this->countAudit(self::CARRIER_TYPE, (string) $seed->getId(), 'update'),
|
||||
'Un audit_log "update" doit etre genere pour le PATCH.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testArchiveCarrierIsAudited(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedCarrier('Audit Archive Co');
|
||||
|
||||
$admin->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
$rows = $this->auditConnection->fetchAllAssociative(
|
||||
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
|
||||
['type' => self::CARRIER_TYPE, 'id' => (string) $seed->getId(), 'action' => 'update'],
|
||||
);
|
||||
self::assertGreaterThanOrEqual(1, count($rows));
|
||||
|
||||
/** @var array<string, mixed> $changes */
|
||||
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
|
||||
self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived (RG-4.14).');
|
||||
}
|
||||
|
||||
private function countAudit(string $type, string $id, string $action): int
|
||||
{
|
||||
return (int) $this->auditConnection->fetchOne(
|
||||
'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action',
|
||||
['type' => $type, 'id' => $id, 'action' => $action],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use App\Module\Transport\Domain\Entity\CarrierContact;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Sous-ressource Contact d'un transporteur (spec-back M4 § 4.5, ERP-160).
|
||||
* POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}.
|
||||
*
|
||||
* Contrat verifie :
|
||||
* - RG-4.08 : contact totalement vide -> 422 (au moins 1 champ requis) ;
|
||||
* - RG-4.08 : 1 seul champ rempli -> 201 ;
|
||||
* - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ;
|
||||
* - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ;
|
||||
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierContactApiTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
|
||||
// qu'en recette), requis pour les tests de permission (bureau/commerciale).
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testEmptyContactReturns422(): void
|
||||
{
|
||||
// RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD).
|
||||
$carrier = $this->seedCarrier('Contact Vide');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// RG-4.08 : la violation est rattachee a `firstName` (mapping inline ERP-101).
|
||||
self::assertViolationOnPath($response, 'firstName');
|
||||
}
|
||||
|
||||
public function testSingleFieldContactIsCreated(): void
|
||||
{
|
||||
// RG-4.08 : un seul champ suffit a valider le bloc.
|
||||
$carrier = $this->seedCarrier('Contact Mono');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['lastName' => 'martin'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// RG-4.13 : nom capitalise serveur.
|
||||
self::assertJsonContains(['lastName' => 'Martin']);
|
||||
}
|
||||
|
||||
public function testThirdPhoneReturns422(): void
|
||||
{
|
||||
// RG-4.08 : max 2 telephones. Le contrat d'ecriture accepte un tableau
|
||||
// `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») ; un 3e
|
||||
// numero -> 422 rattachee au champ `phones`.
|
||||
$carrier = $this->seedCarrier('Contact Trois Tel');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'firstName' => 'Jean',
|
||||
'phones' => ['0611111111', '0622222222', '0633333333'],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// Le max-2 cible le champ virtuel `phones` (mapping inline ERP-101).
|
||||
self::assertViolationOnPath($response, 'phones');
|
||||
}
|
||||
|
||||
public function testInvalidEmailReturns422(): void
|
||||
{
|
||||
// L'email du contact porte un Assert\Email (nouvelle contrainte M4) : une
|
||||
// adresse mal formee -> 422 ciblee sur `email`.
|
||||
$carrier = $this->seedCarrier('Contact Email Invalide');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['lastName' => 'Durand', 'email' => 'pas-un-email'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'email');
|
||||
}
|
||||
|
||||
public function testPostContactOnUnknownCarrierReturns404(): void
|
||||
{
|
||||
// Parent introuvable (read:false) -> 404 explicite du processor.
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/999999/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['lastName' => 'Martin'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testPhonesAreMappedAndNormalized(): void
|
||||
{
|
||||
// Mapping `phones[0]` -> phonePrimary, `phones[1]` -> phoneSecondary +
|
||||
// normalisation RG-4.13 (chiffres uniquement).
|
||||
$carrier = $this->seedCarrier('Contact Deux Tel');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'lastName' => 'Dupont',
|
||||
'phones' => ['06.11.11.11.11', '06 22 22 22 22'],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertJsonContains([
|
||||
'phonePrimary' => '0611111111',
|
||||
'phoneSecondary' => '0622222222',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPatchAndDeleteSucceedWithManage(): void
|
||||
{
|
||||
$contact = $this->seedContact('Patch Delete');
|
||||
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
|
||||
|
||||
// PATCH (manage) -> 200
|
||||
$client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['jobTitle' => 'Directeur'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// DELETE (manage) -> 204
|
||||
$client->request('DELETE', '/api/carrier_contacts/'.$contact->getId());
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testWriteForbiddenWithoutManage(): void
|
||||
{
|
||||
$contact = $this->seedContact('Forbidden');
|
||||
$carrier = $contact->getCarrier();
|
||||
self::assertNotNull($carrier);
|
||||
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['lastName' => 'Bernard'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['jobTitle' => 'Chef'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('DELETE', '/api/carrier_contacts/'.$contact->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur + un contact rattache (pour les tests PATCH/DELETE).
|
||||
*/
|
||||
private function seedContact(string $name): CarrierContact
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = $this->seedCarrier($name);
|
||||
|
||||
$contact = new CarrierContact();
|
||||
$contact->setCarrier($carrier);
|
||||
$contact->setLastName('Martin');
|
||||
$contact->setPhonePrimary('0612345678');
|
||||
$carrier->addContact($contact);
|
||||
$em->persist($contact);
|
||||
$em->flush();
|
||||
|
||||
return $contact;
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du repertoire transporteurs (M4, § 4.6).
|
||||
* Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}.
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), exclusion
|
||||
* des archives par defaut, respect du filtre ?search, peuplement des colonnes
|
||||
* QUALIMAT (statut + date de validite, RG-4.04), 403 sans transport.carriers.view,
|
||||
* 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierExportControllerTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/carriers/export.xlsx';
|
||||
|
||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedCarrier('Export Alpha');
|
||||
|
||||
$response = $client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="repertoire-transporteurs-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="repertoire-transporteurs-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
||||
$headers = $this->gridFromResponse($response->getContent())[0];
|
||||
self::assertSame('Nom', $headers[0]);
|
||||
self::assertContains('Certification', $headers);
|
||||
self::assertContains('Statut QUALIMAT', $headers);
|
||||
self::assertContains('Date de validité', $headers);
|
||||
self::assertContains('Affrété', $headers);
|
||||
self::assertContains('Volume m³', $headers);
|
||||
self::assertContains('Date de création', $headers);
|
||||
}
|
||||
|
||||
public function testExportExcludesArchivedByDefault(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedCarrier('Active One');
|
||||
$this->seedCarrier('Archived One', true);
|
||||
|
||||
$names = $this->carrierNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('ACTIVE ONE', $names);
|
||||
self::assertNotContains('ARCHIVED ONE', $names);
|
||||
}
|
||||
|
||||
public function testExportRespectsSearchFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedCarrier('Searchable Alpha');
|
||||
$this->seedCarrier('Other Beta');
|
||||
|
||||
$names = $this->carrierNames(
|
||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('SEARCHABLE ALPHA', $names);
|
||||
self::assertNotContains('OTHER BETA', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes « Statut QUALIMAT » et « Date de validite » : alimentees par le
|
||||
* referentiel QUALIMAT lie (RG-4.04). Un transporteur complet seede un lien
|
||||
* QUALIMAT (statut « Valide », validite 31/12/2027).
|
||||
*/
|
||||
public function testExportPopulatesQualimatColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedCompleteCarrier('Grelillier');
|
||||
|
||||
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
||||
|
||||
self::assertStringContainsString('QUALIMAT', $flat);
|
||||
self::assertStringContainsString('Valide', $flat);
|
||||
self::assertStringContainsString('31/12/2027', $flat);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutCarriersViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_carrier_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la colonne « Nom » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function carrierNames(string $binary): array
|
||||
{
|
||||
$rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete
|
||||
|
||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplatit toute la grille en une chaine, pour les assertions de presence.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*/
|
||||
private function flatten(array $grid): string
|
||||
{
|
||||
return implode('|', array_map(
|
||||
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
|
||||
$grid,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de la liste transporteurs (M4, spec § 4.1 + RG-4.14 + regle
|
||||
* ABSOLUE n°13) : tri name ASC, echappatoire ?pagination=false (selects), et
|
||||
* ANTI N+1 (le nombre de requetes SQL de la liste ne croit pas avec le nombre de
|
||||
* lignes — fetch-join qualimatCarrier batche, § 2.11). L'exclusion des archives
|
||||
* et la forme de l'enveloppe Hydra sont couvertes par
|
||||
* {@see CarrierSerializationContractTest::testCollectionEnvelopeShapeAndArchivedExcluded}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierListTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
public function testListIsSortedByNameAsc(): void
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$token = $this->token();
|
||||
|
||||
// Inseres dans le desordre ; le tri par defaut doit remonter ALPHA avant ZETA.
|
||||
$this->seedCarrier($token.' Zeta');
|
||||
$this->seedCarrier($token.' Alpha');
|
||||
|
||||
$names = array_map(
|
||||
static fn (array $m): string => (string) $m['name'],
|
||||
$http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray()['member'],
|
||||
);
|
||||
|
||||
self::assertCount(2, $names);
|
||||
self::assertStringContainsString('ALPHA', $names[0], 'Tri name ASC (spec § 4.1).');
|
||||
self::assertStringContainsString('ZETA', $names[1]);
|
||||
}
|
||||
|
||||
public function testPaginationDisabledReturnsFullCollection(): void
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$token = $this->token();
|
||||
|
||||
for ($i = 0; $i < 3; ++$i) {
|
||||
$this->seedCarrier($token.' Item'.$i);
|
||||
}
|
||||
|
||||
// ?pagination=false : echappatoire pour alimenter un <select> (regle n°13).
|
||||
$data = $http->request('GET', '/api/carriers?search='.$token.'&pagination=false', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $data);
|
||||
self::assertCount(3, $data['member']);
|
||||
}
|
||||
|
||||
public function testAnonymousRequestReturns401(): void
|
||||
{
|
||||
// La collection est gatee par is_granted('transport.carriers.view') : un appel
|
||||
// NON authentifie doit recevoir 401 (spec § 4.1 liste 401 ET 403 ; jusqu'ici
|
||||
// seuls les exports couvraient le 401).
|
||||
$http = self::createClient();
|
||||
|
||||
$http->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testCertificationTypeFilterRestrictsResults(): void
|
||||
{
|
||||
// Filtre ?certificationType= (repetable, livre cote repo/provider mais
|
||||
// jusqu'ici non exerce en collection) : seul le transporteur OVOCOM remonte.
|
||||
$http = $this->createAdminClient();
|
||||
$token = $this->token();
|
||||
|
||||
$this->seedCarrier($token.' Gmp'); // GMP_PLUS (defaut seedCarrier)
|
||||
$ovocom = $this->seedCarrier($token.' Ovo');
|
||||
$ovocom->setCertificationType('OVOCOM');
|
||||
$this->getEm()->flush();
|
||||
|
||||
$data = $http->request(
|
||||
'GET',
|
||||
'/api/carriers?search='.$token.'&certificationType=OVOCOM',
|
||||
['headers' => ['Accept' => self::LD]],
|
||||
)->toArray();
|
||||
|
||||
self::assertCount(1, $data['member'], 'Seul le transporteur OVOCOM doit remonter.');
|
||||
self::assertStringContainsString('OVO', (string) $data['member'][0]['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anti N+1 (§ 2.11) : le nombre de requetes SQL de la liste ne doit PAS croitre
|
||||
* avec le nombre de transporteurs. On mesure pour N=2 puis N=4 (chacun avec son
|
||||
* lien QUALIMAT embarque) et on exige un compte IDENTIQUE — preuve que le
|
||||
* fetch-join `qualimatCarrier` est batche et non par ligne.
|
||||
*/
|
||||
public function testListQueryCountDoesNotGrowWithRowCount(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$token = $this->token();
|
||||
|
||||
// Premiere mesure : 2 transporteurs complets (lien QUALIMAT embarque en liste).
|
||||
$this->seedCompleteCarrier($token.' A');
|
||||
$this->seedCompleteCarrier($token.' B');
|
||||
$countFor2 = $this->countListQueries($token);
|
||||
|
||||
// Seconde mesure : 2 de plus (4 au total, tous sur la meme page).
|
||||
$this->seedCompleteCarrier($token.' C');
|
||||
$this->seedCompleteCarrier($token.' D');
|
||||
$countFor4 = $this->countListQueries($token);
|
||||
|
||||
self::assertSame(
|
||||
$countFor2,
|
||||
$countFor4,
|
||||
sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
|
||||
* debug Doctrine (actif en test grace a `profiling: true` dans la config test,
|
||||
* independamment d'APP_DEBUG — sinon le compte casse en CI). Le holder est remis
|
||||
* a zero juste avant la requete pour isoler ses requetes (hors login).
|
||||
*/
|
||||
private function countListQueries(string $token): int
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$holder = self::getContainer()->get('doctrine.debug_data_holder');
|
||||
$holder->reset();
|
||||
|
||||
$http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
$data = $holder->getData();
|
||||
|
||||
return count($data['default'] ?? []);
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return 'List'.substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
}
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Sous-ressource Prix d'un transporteur (spec-back M4 § 4.5, ERP-161).
|
||||
* POST /api/carriers/{id}/prices, PATCH/DELETE /api/carrier_prices/{id}.
|
||||
*
|
||||
* Contrat verifie (RG-4.09→4.11) :
|
||||
* - branche CLIENT incomplete -> 422 ;
|
||||
* - branche FOURNISSEUR incomplete -> 422 ;
|
||||
* - adresse de livraison etrangere au client -> 422 ;
|
||||
* - adresse d'appro etrangere au fournisseur -> 422 ;
|
||||
* - prix CLIENT / FOURNISSEUR complets -> 201 ;
|
||||
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierPriceApiTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
|
||||
// qu'en recette), requis pour les tests de permission (bureau/commerciale).
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testIncompleteClientBranchReturns422(): void
|
||||
{
|
||||
// RG-4.10 : direction CLIENT sans client / adresse / site de depart -> 422.
|
||||
$carrier = $this->seedCarrier('Prix Client Incomplet');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'CLIENT',
|
||||
'containerType' => 'BENNE',
|
||||
'pricingUnit' => 'TONNE',
|
||||
'price' => '42.50',
|
||||
'priceState' => 'VALIDE',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testIncompleteSupplierBranchReturns422(): void
|
||||
{
|
||||
// RG-4.11 : direction FOURNISSEUR sans fournisseur / adresse / site -> 422.
|
||||
$carrier = $this->seedCarrier('Prix Fournisseur Incomplet');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'FOURNISSEUR',
|
||||
'containerType' => 'FOND_MOUVANT',
|
||||
'pricingUnit' => 'FORFAIT',
|
||||
'price' => '320.00',
|
||||
'priceState' => 'EN_COURS',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testForeignClientAddressReturns422(): void
|
||||
{
|
||||
// RG-4.10 : l'adresse de livraison doit appartenir au client choisi.
|
||||
$carrier = $this->seedCarrier('Prix Adresse Etrangere Client');
|
||||
$addrA = $this->seedClientWithAddress('Client A');
|
||||
$addrB = $this->seedClientWithAddress('Client B');
|
||||
$this->getEm()->flush();
|
||||
$siteId = $this->aSiteId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'CLIENT',
|
||||
'client' => '/api/clients/'.$addrA->getClient()?->getId(),
|
||||
'clientDeliveryAddress' => '/api/client_addresses/'.$addrB->getId(), // adresse du client B
|
||||
'departureSite' => '/api/sites/'.$siteId,
|
||||
'containerType' => 'BENNE',
|
||||
'pricingUnit' => 'TONNE',
|
||||
'price' => '42.50',
|
||||
'priceState' => 'VALIDE',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// Faux-vert evite : la 422 doit prouver l'integrite referentielle adresse<->tiers
|
||||
// (violation sur clientDeliveryAddress), pas une autre cause (RG-4.10, ERP-101).
|
||||
self::assertViolationOnPath($response, 'clientDeliveryAddress');
|
||||
}
|
||||
|
||||
public function testForeignSupplierAddressReturns422(): void
|
||||
{
|
||||
// RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi.
|
||||
$carrier = $this->seedCarrier('Prix Adresse Etrangere Fournisseur');
|
||||
$addrA = $this->seedSupplierWithAddress('Fournisseur A');
|
||||
$addrB = $this->seedSupplierWithAddress('Fournisseur B');
|
||||
$this->getEm()->flush();
|
||||
$siteId = $this->aSiteId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'FOURNISSEUR',
|
||||
'supplier' => '/api/suppliers/'.$addrA->getSupplier()?->getId(),
|
||||
'supplierSupplyAddress' => '/api/supplier_addresses/'.$addrB->getId(), // adresse du fournisseur B
|
||||
'deliverySite' => '/api/sites/'.$siteId,
|
||||
'containerType' => 'FOND_MOUVANT',
|
||||
'pricingUnit' => 'FORFAIT',
|
||||
'price' => '320.00',
|
||||
'priceState' => 'EN_COURS',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'supplierSupplyAddress');
|
||||
}
|
||||
|
||||
public function testValidClientPriceIsCreated(): void
|
||||
{
|
||||
$carrier = $this->seedCarrier('Prix Client Valide');
|
||||
$addr = $this->seedClientWithAddress('Client OK');
|
||||
$this->getEm()->flush();
|
||||
$siteId = $this->aSiteId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'CLIENT',
|
||||
'client' => '/api/clients/'.$addr->getClient()?->getId(),
|
||||
'clientDeliveryAddress' => '/api/client_addresses/'.$addr->getId(),
|
||||
'departureSite' => '/api/sites/'.$siteId,
|
||||
'containerType' => 'BENNE',
|
||||
'pricingUnit' => 'TONNE',
|
||||
'price' => '42.50',
|
||||
'priceState' => 'VALIDE',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertJsonContains(['direction' => 'CLIENT', 'priceState' => 'VALIDE']);
|
||||
}
|
||||
|
||||
public function testValidSupplierPriceIsCreated(): void
|
||||
{
|
||||
$carrier = $this->seedCarrier('Prix Fournisseur Valide');
|
||||
$addr = $this->seedSupplierWithAddress('Fournisseur OK');
|
||||
$this->getEm()->flush();
|
||||
$siteId = $this->aSiteId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'FOURNISSEUR',
|
||||
'supplier' => '/api/suppliers/'.$addr->getSupplier()?->getId(),
|
||||
'supplierSupplyAddress' => '/api/supplier_addresses/'.$addr->getId(),
|
||||
'deliverySite' => '/api/sites/'.$siteId,
|
||||
'containerType' => 'FOND_MOUVANT',
|
||||
'pricingUnit' => 'FORFAIT',
|
||||
'price' => '320.00',
|
||||
'priceState' => 'EN_COURS',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertJsonContains(['direction' => 'FOURNISSEUR', 'priceState' => 'EN_COURS']);
|
||||
}
|
||||
|
||||
public function testNegativePriceReturns422(): void
|
||||
{
|
||||
// Le prix porte un Assert\PositiveOrZero : une valeur negative -> 422 sur `price`
|
||||
// (la branche CLIENT est par ailleurs complete pour isoler la cause).
|
||||
$carrier = $this->seedCarrier('Prix Negatif');
|
||||
$addr = $this->seedClientWithAddress('Client Prix Negatif');
|
||||
$this->getEm()->flush();
|
||||
$siteId = $this->aSiteId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'CLIENT',
|
||||
'client' => '/api/clients/'.$addr->getClient()?->getId(),
|
||||
'clientDeliveryAddress' => '/api/client_addresses/'.$addr->getId(),
|
||||
'departureSite' => '/api/sites/'.$siteId,
|
||||
'containerType' => 'BENNE',
|
||||
'pricingUnit' => 'TONNE',
|
||||
'price' => '-5.00',
|
||||
'priceState' => 'VALIDE',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'price');
|
||||
}
|
||||
|
||||
public function testPostPriceOnUnknownCarrierReturns404(): void
|
||||
{
|
||||
// Parent introuvable (read:false) -> 404 explicite du processor (linkParent
|
||||
// s'execute avant validateBranch). Le payload porte les scalaires NotBlank
|
||||
// (containerType/pricingUnit/price/priceState) pour passer la validation
|
||||
// d'entite et atteindre le processor, ou le 404 prime.
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/999999/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'CLIENT',
|
||||
'containerType' => 'BENNE',
|
||||
'pricingUnit' => 'TONNE',
|
||||
'price' => '42.50',
|
||||
'priceState' => 'VALIDE',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testPatchAndDeleteSucceedWithManage(): void
|
||||
{
|
||||
$price = $this->seedClientPrice('Patch Delete');
|
||||
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
|
||||
|
||||
// PATCH (manage) -> 200
|
||||
$client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['priceState' => 'NON_VALIDE'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertJsonContains(['priceState' => 'NON_VALIDE']);
|
||||
|
||||
// DELETE (manage) -> 204
|
||||
$client->request('DELETE', '/api/carrier_prices/'.$price->getId());
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testWriteForbiddenWithoutManage(): void
|
||||
{
|
||||
$price = $this->seedClientPrice('Forbidden');
|
||||
$carrier = $price->getCarrier();
|
||||
self::assertNotNull($carrier);
|
||||
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['direction' => 'CLIENT'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['priceState' => 'VALIDE'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('DELETE', '/api/carrier_prices/'.$price->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/** Id d'un site fixture (adresse de depart / livraison des prix). */
|
||||
private function aSiteId(): int
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Un site fixture est requis (SitesFixtures).');
|
||||
$id = $site->getId();
|
||||
self::assertNotNull($id);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur + un prix CLIENT complet rattache (pour les tests
|
||||
* PATCH / DELETE). Passe par l'EM directement (le flux d'ecriture est teste
|
||||
* via l'API ailleurs).
|
||||
*/
|
||||
private function seedClientPrice(string $name): CarrierPrice
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = $this->seedCarrier($name);
|
||||
|
||||
/** @var ClientAddress $addr */
|
||||
$addr = $this->seedClientWithAddress($name);
|
||||
|
||||
$price = new CarrierPrice();
|
||||
$price->setCarrier($carrier);
|
||||
$price->setDirection('CLIENT');
|
||||
$price->setClient($addr->getClient());
|
||||
$price->setClientDeliveryAddress($addr);
|
||||
$price->setDepartureSite($em->getRepository(Site::class)->findOneBy([]));
|
||||
$price->setContainerType('BENNE');
|
||||
$price->setPricingUnit('TONNE');
|
||||
$price->setPrice('42.50');
|
||||
$price->setPriceState('VALIDE');
|
||||
$carrier->addPrice($price);
|
||||
$em->persist($price);
|
||||
$em->flush();
|
||||
|
||||
return $price;
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du tableau Prix d'un transporteur (M4,
|
||||
* § 4.6 / spec-front « Onglet Prix »).
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), rendu des
|
||||
* lignes regroupees par type de contenant (Benne / Fond Mouvant) avec ventilation
|
||||
* Forfait/Tonne, libelles d'etat FR, points de depart/livraison cross-module,
|
||||
* 404 sur transporteur inconnu, 403 sans transport.carriers.view, 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierPriceExportControllerTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
|
||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$carrier = $this->seedCompleteCarrier('Price Alpha');
|
||||
|
||||
$response = $client->request('GET', $this->exportUrl($carrier));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="prix-transporteur-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="prix-transporteur-\d+-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
$headerRow = $this->gridFromResponse($response->getContent())[0];
|
||||
self::assertSame('Type de contenant', $headerRow[0]);
|
||||
self::assertContains('Transporteurs', $headerRow);
|
||||
self::assertContains('Adresse APRO ou Adresse Sites', $headerRow);
|
||||
self::assertContains('Adresse livraisons', $headerRow);
|
||||
self::assertContains('Forfait €', $headerRow);
|
||||
self::assertContains('Tonne €', $headerRow);
|
||||
self::assertContains('Indexation', $headerRow);
|
||||
self::assertContains('État du prix', $headerRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le transporteur complet seede 2 prix : une branche CLIENT (Benne / Tonne /
|
||||
* 42.50 / Valide) et une branche FOURNISSEUR (Fond Mouvant / Forfait / 320.00 /
|
||||
* En cours). On verifie le regroupement par contenant, la ventilation
|
||||
* Forfait/Tonne, les libelles d'etat FR et les points de depart/livraison
|
||||
* cross-module (le prix CLIENT livre chez le client, le prix FOURNISSEUR part
|
||||
* de l'adresse du fournisseur).
|
||||
*/
|
||||
public function testExportRendersGroupedPriceRows(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$carrier = $this->seedCompleteCarrier('Price Grouping');
|
||||
|
||||
$grid = $this->gridFromResponse($client->request('GET', $this->exportUrl($carrier))->getContent());
|
||||
|
||||
$benne = $this->rowForContainer($grid, 'Benne');
|
||||
self::assertNotNull($benne, 'Ligne « Benne » introuvable dans l\'export prix.');
|
||||
self::assertSame($carrier->getName(), $benne[1]);
|
||||
// Branche CLIENT : prix en Tonne (42.50 -> 42.5 apres typage numerique du
|
||||
// classeur), colonne Forfait vide, etat « Valide », livraison chez le client.
|
||||
self::assertEmpty($benne[4]);
|
||||
self::assertEqualsWithDelta(42.5, (float) $benne[5], 0.001);
|
||||
self::assertSame('Validé', $benne[7]);
|
||||
self::assertStringContainsString('TESTCARRIERREF CLI', (string) $benne[3]);
|
||||
|
||||
$fondMouvant = $this->rowForContainer($grid, 'Fond Mouvant');
|
||||
self::assertNotNull($fondMouvant, 'Ligne « Fond Mouvant » introuvable dans l\'export prix.');
|
||||
// Branche FOURNISSEUR : prix au Forfait (320.00 -> 320), colonne Tonne vide,
|
||||
// etat « En cours », depart depuis l'adresse du fournisseur (APRO).
|
||||
self::assertEqualsWithDelta(320.0, (float) $fondMouvant[4], 0.001);
|
||||
self::assertEmpty($fondMouvant[5]);
|
||||
self::assertSame('En cours', $fondMouvant[7]);
|
||||
self::assertStringContainsString('TESTCARRIERREF FRN', (string) $fondMouvant[2]);
|
||||
}
|
||||
|
||||
public function testNotFoundForUnknownCarrier(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('GET', '/api/carriers/99999999/prices/export.xlsx');
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutCarriersViewPermission(): void
|
||||
{
|
||||
$carrier = $this->seedCompleteCarrier('Price Forbidden');
|
||||
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', $this->exportUrl($carrier));
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$carrier = $this->seedCompleteCarrier('Price Anonymous');
|
||||
|
||||
$client = self::createClient();
|
||||
$client->request('GET', $this->exportUrl($carrier));
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
private function exportUrl(Carrier $carrier): string
|
||||
{
|
||||
return sprintf('/api/carriers/%d/prices/export.xlsx', (int) $carrier->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_carrier_price_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renvoie la 1re ligne de donnees dont la colonne « Type de contenant »
|
||||
* (1re colonne) vaut $container, ou null.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*
|
||||
* @return null|array<int, mixed>
|
||||
*/
|
||||
private function rowForContainer(array $grid, string $container): ?array
|
||||
{
|
||||
foreach (array_slice($grid, 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $container) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -221,4 +221,20 @@ final class CarrierWriteApiTest extends AbstractCarrierApiTestCase
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie qu'une violation 422 cible bien la propriete attendue (propertyPath),
|
||||
* gage du mapping inline front (useFormErrors, ERP-101).
|
||||
*/
|
||||
private function assertViolationOnPath(object $response, string $path): void
|
||||
{
|
||||
/** @var \Symfony\Contracts\HttpClient\ResponseInterface $response */
|
||||
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
|
||||
|
||||
self::assertContains(
|
||||
$path,
|
||||
$paths,
|
||||
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Endpoint de recherche du referentiel QUALIMAT (spec-back M4 § 4.7 / RG-4.01,
|
||||
* ERP-156). Saisie assistee du nom : GET /api/qualimat_carriers?search= .
|
||||
*
|
||||
* Contrat verifie :
|
||||
* - recherche fuzzy sur name (+ siret), SEULEMENT les lignes actives ;
|
||||
* - tri name ASC ;
|
||||
* - enveloppe Hydra paginee (member / totalItems / view — regle n°13) ;
|
||||
* - 403 sans la permission transport.carriers.view (compta/usine, matrice § 5.2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class QualimatCarrierSearchTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
/** Prefixe SIRET dedie (purge par AbstractCarrierApiTestCase::tearDown). */
|
||||
private const string SIRET_PREFIX = 'TESTQ';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
|
||||
// qu'en recette), requis pour le test de permission (usine sans acces).
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testSearchReturnsOnlyActiveOrderedByName(): void
|
||||
{
|
||||
// Marqueur unique partage par les 3 lignes : isole la recherche d'eventuelles
|
||||
// autres lignes du referentiel.
|
||||
$this->insertQualimat('QSEARCH GAMMA', true, 'A1');
|
||||
$this->insertQualimat('QSEARCH ALPHA', true, 'A2');
|
||||
$this->insertQualimat('QSEARCH BETA', false, 'A3'); // inactive -> exclue
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/qualimat_carriers?search=qsearch', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $client->getResponse()->toArray();
|
||||
$names = array_column($data['member'], 'name');
|
||||
|
||||
self::assertSame(2, $data['totalItems'], 'Seules les 2 lignes actives doivent remonter (BETA inactive exclue).');
|
||||
self::assertSame(['QSEARCH ALPHA', 'QSEARCH GAMMA'], $names, 'Tri name ASC, sans la ligne inactive.');
|
||||
}
|
||||
|
||||
public function testSearchMatchesSiret(): void
|
||||
{
|
||||
// Le nom ne porte pas le marqueur : la correspondance se fait via le siret.
|
||||
$this->insertQualimat('TRANSPORTEUR SANS MARQUEUR', true, 'SIRETHIT1');
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/qualimat_carriers?search=testqsirethit1', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $client->getResponse()->toArray();
|
||||
self::assertSame(1, $data['totalItems'], 'La recherche fuzzy doit aussi cibler le siret.');
|
||||
self::assertSame('TRANSPORTEUR SANS MARQUEUR', $data['member'][0]['name']);
|
||||
}
|
||||
|
||||
public function testCollectionExposesHydraPagination(): void
|
||||
{
|
||||
$this->insertQualimat('QPAGE UN', true, 'P1');
|
||||
$this->insertQualimat('QPAGE DEUX', true, 'P2');
|
||||
$this->insertQualimat('QPAGE TROIS', true, 'P3');
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/qualimat_carriers?search=qpage&itemsPerPage=2', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $client->getResponse()->toArray();
|
||||
self::assertArrayHasKey('totalItems', $data, 'La collection doit exposer totalItems.');
|
||||
self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
|
||||
self::assertIsArray($data['member']);
|
||||
self::assertSame(3, $data['totalItems']);
|
||||
self::assertCount(2, $data['member'], 'La page doit etre bornee a itemsPerPage=2.');
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutPermission(): void
|
||||
{
|
||||
// Usine : aucun acces transporteurs (matrice § 5.2) -> 403 sur la recherche.
|
||||
$client = $this->authenticatedClient('usine', self::PWD);
|
||||
$client->request('GET', '/api/qualimat_carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insere une ligne qualimat_carrier de test en DBAL brut (l'entite mappee est
|
||||
* en lecture seule). SIRET prefixe TESTQ pour la purge ciblee du tearDown.
|
||||
*/
|
||||
private function insertQualimat(string $name, bool $isActive, string $siretSuffix): void
|
||||
{
|
||||
$this->getEm()->getConnection()->insert('qualimat_carrier', [
|
||||
'siret' => self::SIRET_PREFIX.$siretSuffix,
|
||||
'name' => $name,
|
||||
'status' => 'Valide',
|
||||
'validity_date' => '2027-12-31',
|
||||
'is_active' => $isActive ? 'true' : 'false',
|
||||
'last_synced_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user