---
suivi: 1121
date: 2026-07-31
sujet: Diagnostic — catégorie / rôles métier sur fournisseurs (zone Intacct)
chantier: fournisseurs-intacct
type: diagnostic
statut: déployé
hash:
fichiers:
  - tools/diag/diag_categorie_fournisseurs_1121.php
  - docs/suivi/SUIVI_1121_diag_categorie_fournisseurs.md
  - app/Models/Fournisseur.php
  - app/Services/Intacct/SyncFournisseursDepuisIntacct.php
---

## PROMPT ENVOYÉ

SUIVI #1121 — PHASE 1 DIAGNOSTIC champ catégorie sur `fournisseurs` (zone Intacct).
Diagnostic seul. Aucun code métier, aucune migration. Établir colonnes prod, mapping
Intacct R/W, `champs_modifies_sage`, multi-rôles, mécanismes réutilisables, besoin
notaires VEFA. Rapport + proposition chiffrée SANS appliquer. Fiche + script tools/diag/.

## SYNTHÈSE

### Verdict court

1. **Aucune** colonne type / catégorie / famille / rôle sur `fournisseurs` ([CODE] +
   from-scratch migrate_1113 + dumps locaux partiels).
2. Une donnée **locale hors mapping Intacct** est **sans risque synchro** : la sync
   descendante et le push audit utilisent des **listes blanches explicites** de champs.
3. `champs_modifies_sage` **n’absorbe pas** une nouvelle colonne automatiquement.
4. **Multi-rôles** métier (ex. BE VRD + hydraulique) → une **colonne unique ne suffit
   pas** ; table de liaison recommandée.
5. `categories_frais_generaux` / nomenclature bilan / `type_fournisseur` ventilation
   = **non réutilisables** pour ce besoin (autre sémantique, souvent zone Intacct CLASS).
6. Besoin VEFA notaire **confirmé** via FK existantes ; `lot_acte_preparations` /
   `notaire_promoteur_id` / `notaire_acquereur_id` = **ABSENTS** du code. Un seul
   mécanisme de rôles peut servir foncier D68 + filtres notaire/agence VEFA.

---

### 1. Colonnes réelles de `fournisseurs`

#### [CODE] — cumul migrations + `$fillable` `Fournisseur.php`

`id`, `nom`, `nom_legal`, `siret`, `contact_nom`, `contact_email`, `adresse`,
`code_postal`, `ville`, `pays`, `statut` (enum actif/inactif), `sage_encours`,
`sage_encours_synced_at`, `sage_megaentity_id`, `email`, `telephone`,
`representant_legal`, `intacct_id`, `iban`, `bic`, `ancien_id`, `intacct_synced_at`,
`intacct_error`, `rib_path`, `intacct_supdoc_id`, `dirty_sage`,
`champs_modifies_sage` (JSON), `audit_reference_valeurs` (JSON), `created_at`,
`updated_at`.

Champ type / categorie / famille / role / métier : **ABSENT**.

#### [LOCAL] — `erp_immo_migrate_1113` (from-scratch #1113, 0 lignes)

Sortie brute `SHOW COLUMNS` (script `tools/diag/diag_categorie_fournisseurs_1121.php`) :

| Field | Type | Null | Key | Default |
|-------|------|------|-----|---------|
| id | bigint unsigned | NO | PRI | auto_increment |
| nom | varchar(255) | NO | | |
| nom_legal | varchar(255) | YES | | NULL |
| siret | varchar(14) | YES | | NULL |
| contact_nom | varchar(255) | YES | | NULL |
| contact_email | varchar(255) | YES | | NULL |
| adresse | varchar(255) | YES | | NULL |
| code_postal | varchar(10) | YES | | NULL |
| ville | varchar(255) | YES | | NULL |
| pays | varchar(255) | YES | | NULL |
| statut | enum('actif','inactif') | NO | | actif |
| sage_encours | decimal(14,2) | YES | | NULL |
| sage_encours_synced_at | timestamp | YES | | NULL |
| sage_megaentity_id | varchar(32) | YES | | NULL |
| email | varchar(255) | YES | | NULL |
| telephone | varchar(255) | YES | | NULL |
| representant_legal | varchar(255) | YES | | NULL |
| intacct_id | varchar(255) | YES | UNI | NULL |
| iban | varchar(34) | YES | | NULL |
| bic | varchar(11) | YES | | NULL |
| ancien_id | varchar(255) | YES | | NULL |
| intacct_synced_at | timestamp | YES | | NULL |
| intacct_error | text | YES | | NULL |
| rib_path | varchar(255) | YES | | NULL |
| intacct_supdoc_id | varchar(255) | YES | | NULL |
| dirty_sage | tinyint(1) | NO | | 0 |
| champs_modifies_sage | longtext (JSON cast) | YES | | NULL |
| audit_reference_valeurs | longtext (JSON cast) | YES | | NULL |
| created_at / updated_at | timestamp | YES | | NULL |

A2b : **AUCUNE** colonne dont le nom évoque type/categorie/famille/role/métier.
A3 volumes locaux = 0 (DB vide) — **chiffre prod à coller** via le même script sur OVH.
Volume Intacct historique logs locaux (mai–juin 2026) : `totalcount` VENDOR ≈ 3884 → 3921 ;
le « ~3978 » du prompt est cohérent avec une prod à jour — **à confirmer A3 prod**.

#### [PROD] — non interrogée dans cette session

```
php tools/diag/diag_categorie_fournisseurs_1121.php
```

Coller A0–A4 dans le ticket. Sans cette sortie, ne pas affirmer un count prod.

---

### 2. Code Intacct qui LIT / ÉCRIT `fournisseurs` — mapping champs

#### Lecture Sage → ERP (sync descendante)

| Fichier | Méthode | Rôle |
|---------|---------|------|
| `app/Jobs/SyncFournisseursDepuisIntacct.php` | `handle` | `updateOrCreate` sur `intacct_id` |
| `app/Services/IntacctService.php` | `getFournisseurs` | `readByQuery` VENDOR paginé |
| idem | `readVendorPayloadByVendorId` | lecture unitaire |
| idem | `getVendorBankDetailsIndexed` | VENDORBANKFILEDETAIL |
| `app/Support/IntacctVendorReadByQueryFlatResolver.php` | `contactPatchFromPayload`, `siretFromPayload` | clés plates |
| `app/Support/FournisseurSyncWriteGuard.php` | `guard` | troncature longueurs |
| `app/Support/FournisseurSageEncours.php` | `fromVendorPayload` | TOTALDUE → sage_encours |
| `app/Support/VendorBankDetailRowResolver.php` | bank payload | IBAN/BIC |
| `app/Support/FournisseurAuditResetFromSageService.php` | `reset` | recharge fiche audit |
| `routes/console.php` | Schedule 02:00 | job sync (HTTPS CLI OVH = dette connue) |
| `Admin/IntacctController.php` | déclenchement web | sync manuelle PHP-FPM |

**Fields Intacct lus** (`getFournisseurs` / `readVendorPayloadByVendorId`) :

`VENDORID`, `NAME`, `STATUS`, `TOTALDUE`, `MEGAENTITYID`,
`DISPLAYCONTACT.COMPANYNAME`, `DISPLAYCONTACT.SIRET`, `DISPLAYCONTACT.EMAIL1`,
`DISPLAYCONTACT.PHONE1`, `DISPLAYCONTACT.MAILADDRESS.ADDRESS1|CITY|ZIP|COUNTRY`,
`SIRET_`, `ANCIEN_ID`

+ bancaire : `VENDORBANKFILEDETAIL` → `BANKACCOUNTNUMBER`, `BUSINESSIDCODE`, …

**Mapping Sage → colonnes ERP (liste EXPLICITE dans le job)** :

| Colonne ERP | Source Sage |
|-------------|-------------|
| intacct_id (clé) | VENDORID |
| nom | NAME |
| siret | SIRET_ / DISPLAYCONTACT.SIRET |
| ancien_id | ANCIEN_ID |
| statut | STATUS active→actif sinon inactif |
| sage_encours | TOTALDUE |
| sage_encours_synced_at | now() |
| sage_megaentity_id | MEGAENTITYID |
| nom_legal | DISPLAYCONTACT.COMPANYNAME |
| contact_email | DISPLAYCONTACT.EMAIL1 |
| telephone | DISPLAYCONTACT.PHONE1 |
| adresse | …ADDRESS1 |
| ville | …CITY |
| code_postal | …ZIP |
| pays | …COUNTRY |
| iban / bic | bank index (seulement si non vide — non destructif) |
| intacct_synced_at | now() |
| intacct_error | null / message |

`updateOrCreate` ne touche **que** ces clés → toute autre colonne locale est **préservée**.

⚠️ La sync **ne saute pas** les lignes `dirty_sage=true` : elle peut écraser les champs
mappés en attente de push. Sans effet sur une colonne hors liste.

#### Écriture ERP → Sage (push audit + création demande)

| Fichier | Méthode | Rôle |
|---------|---------|------|
| `FournisseurAuditSagePushService` | `pushOne` / batch | si `dirty_sage` |
| `IntacctService::updateFournisseur` | XML update VENDOR | |
| `IntacctVendorUpdateXmlBuilder` | build XML | NAME, DISPLAYCONTACT (email+adresse), SIRET_, STATUS, banking |
| `IntacctService::creerFournisseur` | create VENDOR | depuis `DemandeFournisseur` |
| `FournisseurRibSyncService` | SUPDOC RIB | rib_path / intacct_supdoc_id locaux + Sage |

**EDITABLE_FIELDS** (`FournisseurAuditFieldValidator`) — seuls champs pouvant entrer
dans dirty / push métier :

`nom`, `siret`, `contact_email`, `adresse`, `ville`, `code_postal`, `iban`, `bic`, `statut`

XML push : NAME, EMAIL1, ADDRESS1/CITY/ZIP/COUNTRY, SIRET_, STATUS (si dans
`champs_modifies_sage`), VENDORBANKFILEDETAILS si iban/bic modifiés.

**Create** (`creerFournisseur`) : NAME, DISPLAYCONTACT, SIRET_, STATUS, banking, SUPDOCID.
Aucun champ « catégorie ».

#### Réponse à « colonne locale hors mapping = sans risque ? »

**OUI, sous conditions strictes (sans risque Intacct)** :

- Ne PAS l’ajouter au mapping sync / reset Sage / `EDITABLE_FIELDS` / XML builder /
  `creerFournisseur` / `COLUMN_MAX_LENGTHS` (sauf besoin troncature locale).
- Ne PAS la pousser vers un custom field Sage sans validation comptable.
- UI / fillable / filtres sélecteurs : OK.

**Exige validation comptable** :

- Tout mapping vers un champ Sage (VENDORTYPE, custom field, dimension…).
- Réutiliser `categories_frais_generaux.intacct_class_id` ou une CLASS Intacct.
- Modifier le comportement de sync dirty / push / create vendor.

Aligné #1071 BLOC 8.

---

### 3. Que fait `champs_modifies_sage` ?

JSON listant les **noms de colonnes ERP** modifiées localement via l’audit
(`FournisseurAuditDirtyTracker::applyFieldPatch`), parmi **EDITABLE_FIELDS uniquement**.

- `dirty_sage=true` + snapshot `audit_reference_valeurs` tant qu’il reste des écarts.
- Push (`FournisseurAuditSagePushService`) ne traite que `dirty_sage=true`.
- Banking XML seulement si `iban` ou `bic` ∈ `champs_modifies_sage`.
- Clear après push OK / reset Sage.

**Une nouvelle colonne n’y entrerait PAS automatiquement.** Il faudrait
l’ajouter explicitement à `EDITABLE_FIELDS` + au XML builder → synchro parasite
vers Sage. Donc hors whitelist = **pas de sync parasite**.

---

### 4. Multi-rôles ?

Règle D68 (intervenants dossier foncier) : géomètre, avocat, notaire, BE VRD,
BE hydraulique, géotechnicien, urbaniste, architecte.

Un même tiers peut cumuler (ex. BE VRD + hydraulique). Un notaire aussi géomètre :
peu probable mais le modèle ne doit pas l’interdire.

| Option | Suffit multi-rôles ? | Intacct |
|--------|----------------------|---------|
| A — colonne unique `categorie` / `role_principal` | **NON** | OK si hors mapping |
| B — table liaison `fournisseur_role` (N–N) | **OUI** | OK (tables ERP pures) |
| C — JSON array sur `fournisseurs` | OUI techniquement | OK hors mapping ; moins propre (index, admin) |

**Recommandation : B.**

---

### 5. Mécanismes de catégorisation existants — réutilisables ?

| Mécanisme | Usage réel | Réutilisable D68 / notaire ? |
|-----------|------------|------------------------------|
| `categories_frais_generaux` | Catégories factures FRAIS_GENERAUX + `intacct_class_id` | **NON** — sémantique compta/CLASS ; réutiliser = **validation comptable** |
| Nomenclature bilan / `postes_budgetaires_types` | Postes budget programme (#920) | **NON** |
| `type_fournisseur` ventilation | `principal` / `sous_traitant` sur marché | **NON** — rôle marché, pas métier |
| `etiquettes` | Tâches suivi | **NON** — autre domaine |
| Filtre statut `actifs()` | Sélection générale | Complément, pas catégorie |

→ **Créer un référentiel dédié** (codes métier ERP), pas greffer sur frais généraux.

---

### 6. Besoin VEFA notaires — confirmation

| Élément | État |
|---------|------|
| `reservations.notaire_fournisseur_id` | **OUI** (+ `notaire_double_minute_fournisseur_id`, `agence_fournisseur_id`) |
| `promesses.notaire_fournisseur_id` | **OUI** (foncier #1089 R8) — commentaire code : « pas de filtre catégorie » |
| `lot_acte_preparations` | **ABSENT** |
| `notaire_promoteur_id` / `notaire_acquereur_id` | **ABSENTS** du repo |

Les sélecteurs notaire / agence parcourent aujourd’hui **tous** les fournisseurs actifs
(~4k) — même douleur que le géomètre D68.

**Un seul mécanisme de rôles** (`role=notaire`, `role=agence`, `role=geometre`, …)
peut filtrer VEFA **et** intervenants foncier, sans second référentiel tiers (CDC).

---

## PROPOSITION CHIFFRÉE (NON APPLIQUÉE)

### Option recommandée — B (table de liaison)

| Lot | Contenu | Charge | Risque Intacct | Validation compta |
|-----|---------|--------|----------------|-------------------|
| B1 | Tables `fournisseur_roles` (réf. codes D68 + notaire + agence…) + `fournisseur_fournisseur_role` ; seed codes ; pas de touch mapping Sage | 0,5–1 j | **Sans risque** | Non |
| B2 | Admin : affecter rôles à un fournisseur (hors page audit dirty) | 0,5–1 j | **Sans risque** | Non |
| B3 | Filtre sélecteurs (`FournisseurSelectionOptions` + promesse / réservation / futurs intervenants) | 0,5–1 j | **Sans risque** | Non |
| B4 | Campagne de tagging métier (~4k fiches) — hors dev | 2–10 j métier | N/A | Non |

**Total technique B1–B3 : ≈ 1,5–3 j.** Tagging = coût métier dominant.

### Option A — colonne unique (déconseillée)

Migration `categorie` string/enum + fillable + UI + filtre : **≈ 0,5–1 j** technique,
**sans risque Intacct** si hors mapping. **Insuffisante** dès qu’un BE a 2 métiers ;
VEFA + D68 forceraient soit des valeurs composites, soit une 2ᵉ évolution vers B.

### Option C — pousser un type vers Sage

Custom field / VENDORTYPE Intacct : **interdit sans validation comptable** + audit
mapping + tests push/pull. Charge **≥ 3–5 j** + risque sync parasite. **Hors scope**
recommandé.

### Garde-fous à graver en PHASE 2

Ne jamais ajouter le(s) champ(s) rôle dans :
`SyncFournisseursDepuisIntacct` attributes, `FournisseurAuditResetFromSageService`,
`EDITABLE_FIELDS`, `IntacctVendorUpdateXmlBuilder`, `creerFournisseur`.

---

## DÉPLOIEMENT / TEST

Diagnostic uniquement — pas de migrate / build / journal.

1. `git pull` (quand la fiche/script seront poussés)
2. Sur OVH : `php tools/diag/diag_categorie_fournisseurs_1121.php`
3. Coller A0–A4 (surtout A2 colonnes + A3 total) dans le ticket #1121
4. Décision produit : Option B vs A avant tout lot d’implémentation

## LEÇON

Sur `fournisseurs`, le risque Intacct n’est pas « toute colonne nouvelle » : c’est
**l’inscription dans une whitelist** (sync, dirty, XML, create). Un rôle métier ERP
en table de liaison hors ces listes est le chemin sûr ; greffer sur
`categories_frais_generaux` mélangerait CLASS compta et métier — à éviter.
