---
suivi: 1161
date: 2026-08-03
sujet: Diagnostic — prix vente + multi-lots réservation (#85/#86)
chantier: commercialisation
type: diagnostic
statut: poussé
hash: c0e48937
fichiers:
  - tools/diag/diag_prix_lots_reservation_1161.php
  - docs/suivi/SUIVI_1161_diag_prix_multilots.md
---

## PROMPT ENVOYÉ

SUIVI #1161 — PHASE 1 DIAGNOSTIC — NE PAS CODER métier. Deux demandes
commerciales (#85 multi-lots / #86 prix modifiable) touchent la chaîne
prix → appel de fonds → Intacct. Un seul diagnostic. Amont #1154.
Script `tools/diag/` lecture seule, commit + push. Aucune écriture base /
migration / appel externe.

## SYNTHÈSE

### Fiches lues (consultation-suivi)

- `SUIVI_1154_diag_commercial.md` — cardinalité 1:1 résa↔lot ; AF 0 ligne ;
  `prix_vente_ttc` exclu grille (#856) ; verrou AF partiel ; lecteurs listés.
  **Ne pas refaire** ces constats ; ce lot les approfondit (synchro, figés,
  Intacct, impact N, migration).
- `SUIVI_1062_diag_grille_prix_lotissement.md` — `prix_vente_ttc` figé à la
  réservation côté grille ; `lots` vestigial.
- `SUIVI_1064` / `SUIVI_1066` — affichage / saisie grille ; pas de changement
  d’approche pour #85/#86.
- Dernier numéro consommé avant ce lot sur main : **#1157** (motifs) ; amont
  commercial #1154. Gap 1156/1158–1160 non utilisés ; numéro imposé **#1161**.

---

### Q1 — Synchronisation du prix lot ↔ réservation

**Source de vérité métier : `reservations.prix_vente_ttc`.**
Le lot porte une **copie** pour la grille / KPI / bilan.

| Moment | Sens | Code |
|--------|------|------|
| Création réservation (grille / Commercial) | résa → lot | `ManagesCommercialLotPayload::syncLotFromReservation` ; `CommercialController::syncLotCommercialFromReservation` |
| Update fiche acquéreur | résa → lot (sans écraser statut ACTE) | `ReservationAcquereurUpdateService::syncLotAmountsPreservingStatut` |
| `ReservationController::update` | résa → lot **si** `statut === en_cours` seulement | syncLotFromReservation |
| Création lot / import Pegao | lot (éventuellement avant résa) | `createLotCommercialWithEmplacements` ; `ImportPegaoController` |
| Update grille | **interdit** d’écrire `prix_vente_ttc` | `LOT_GRILLE_WHITELIST` unset (#856) |
| Transition `CommercialController` action=`acte` | **lot seul** si prix posté | **fuite** : résa.prix non mis à jour |
| Annulation | lot.prix → `null` ; résa conserve son prix | `ReservationController::annuler` / Commercial annule |

**Divergence déjà possible** même sans #86 : chemin `acte` legacy + update
résa post-acte sans sync (`ReservationController::update` ne sync pas si
`acte`) + annulations (lot null / résa rempli = attendu).

**Coller sortie script Q1** (nb divergences en_cours|acte + détail).

---

### Q2 — Lecteurs exhaustifs de `prix_vente_ttc` (après changement)

| Lecteur | Source lue | Valeur figée ailleurs ? | Si prix change après lecture |
|---------|------------|-------------------------|------------------------------|
| `GenerationAppelFondService` | résa puis fallback lot | **OUI** → `appels_de_fonds.montant_ht/tva/ttc` + `cumul_anterieur_ttc` | AF déjà créés **ne se recalculent pas**. Intacct déjà syncé = facture AR figée. Verrou actuel = bloquer le prix si AF non annulés. |
| `GenerationAppelFondPDFService` | résa acte du lot / lot | PDF fichier + `pdf_path` | PDF déjà généré **périmé** si on relit un nouveau prix pour « reste » ; montants lignes AF restent ceux de la table. |
| `ProgrammeEtapeObserver` | résa | **OUI** → `appels_fonds.montant_ttc` (legacy) | Même bombe : snapshot à l’étape. Compte dans `hasGeneratedAppelsFonds`. |
| `BilanFinancierService` | **lot** live | Non (recalcul) | Bilan se met à jour au prochain get. Risque affichage faux si lot non resyncé. |
| KPI remises (`CommercialisationController` + Vue) | **lot** live | Non | Idem ; divergence lot/résa → KPI faux. |
| Fiche acquéreur / `UpdateAcquereurFicheRequest` | résa | Historique `reservation_historique` | Audit OK ; sync lot si passe par le service. |
| `ReservationAcquereurUpdateService` | résa → sync lot | — | C’est l’écrivain #86. |
| Grille `LotsGrillePanel` | lot (props) | Non | Affiche copie lot. |
| `ImportPegaoController` | écrit les deux | — | Import initial. |

**Verdict #86** : la bombe n’est **pas** le bilan/KPI (live) — c’est
**`appels_de_fonds` / `appels_fonds` + Intacct ARINVOICE** où le montant est
recopié. Tant qu’il n’y a **0 ligne** AdF Intacct en prod, le risque
comptable réel est **latent** ; le legacy `appels_fonds` (Observer) peut déjà
figer des montants.

**Coller Q2** (counts `appels_de_fonds` / `appels_fonds`).

---

### Q3 — Verrous existants sur le prix

1. **AF (seul hard-lock serveur sur fiche)** —
   `ReservationAcquereurUpdateService::prixVenteChangeBlocked` :
   - déclenché si `prix_vente_ttc` posté **et** `hasGeneratedAppelsFonds`
     **et** |Δ| ≥ 0,005 € ;
   - `hasGeneratedAppelsFonds` = existence `appels_fonds.reservation_id`
     **OU** `appels_de_fonds` sur le lot avec `statut != annule`.
2. **Statut réservation / lot** — **aucun** hard-lock prix. Fiche ouverte
   pour `en_cours` **et** `acte` (`AcquereurController`). Annulée : fiche
   inaccessible (404).
3. **Post-acte** — UX confirmation « champs sensibles » (prix = impact
   CA/bilan) ; champ **toujours éditable** sauf verrou AF.
4. **Grille** — prix vente non éditable (#856).
5. **Trou** — `ReservationController::update` accepte `prix_vente_ttc`
   **sans** `prixVenteChangeBlocked` et **sans** sync si statut `acte`.

**Devrait-il être modifiable après acte ?** Question métier (liste § questions) ;
techniquement aujourd’hui : oui (avec confirmation), sauf AF.

**Coller Q3** (volumes résas / résas touchées AF).

---

### Q4 — Chemin comptable réel

**Entrée** : `POST …/appels-de-fonds/generer/{stade}` —
`AppelsDeFondsController::genererBatch` (contexte **WEB / PHP-FPM**).

**Calcul** (`GenerationAppelFondService`) :
- Résas `statut=acte` + lot `statut=acte` + `date_acte` non null ;
- `prixActeTtc = résa.prix_vente_ttc ?? lot.prix_vente_ttc` ;
- `% à appeler = stade.pourcentage − max(% stades déjà AF)` ;
- `montant_ttc = prix × %/100` ; `montant_ht = ttc/1.20` ; `tva = ttc−ht`
  (**TVA 20 % en dur**, indépendant de `lots_commerciaux.regime_tva`) ;
- Unicité : `(lot_commercial_id, stade_avancement_id)` ;
- Statut initial `brouillon` → job Intacct.

**Intacct** (`AppelDeFondsIntacctSyncService` + `createArInvoiceForAppelDeFonds`) :
- Document : **ARINVOICE** ;
- Compte produit : config `erp.appels_de_fonds.compte_produit` défaut
  **`706000`** ;
- CLASSID = `programme.code_analytique` / `code_analytique_compta` /
  `intacctClassAnalytics` ;
- CUSTOMERID résolu/créé depuis le contact ;
- **Anomalie code** : `montant_tva` est calculé et loggé mais le XML
  ARINVOICE n’envoie que `TRX_AMOUNT` = **HT** (pas de taxentries TVA).

**Queue** : `CreateIntacctClientInvoice` implements `ShouldQueue` ;
prod rappelée `QUEUE_CONNECTION=sync` → exécution **dans la même requête
WEB** (conforme OVH : pas de HTTPS depuis CLI).

**0 ligne `appels_de_fonds`** : le script mesure `count`, `MAX(id)`,
`Auto_increment`, `intacct_record_no`, `failed_jobs` AdF pour trancher
« jamais tourné » vs « tourné puis purgé ».

Table **parallèle** `appels_fonds` (Observer étapes) : autre modèle, sans
Intacct dans ce chemin ; entre déjà dans le verrou prix.

**Coller Q4** (Auto_increment, record_no, sequences, failed_jobs).

---

### Q5 — Périmètre d’impact N lots (#85)

Aujourd’hui « le lot de la réservation » = `reservations.lot_commercial_id`
(BelongsTo singulier). Avec N lots :

| Lecteur | Catégorie | Devenir de « le lot » |
|---------|-----------|------------------------|
| `ReservationController` (store/acter/annuler/sync) | **décision métier** | Annulation / acte / sync prix : tous les lots ? lot principal ? |
| `CommercialController` (transitions reserve/acte/annule) | **décision métier** | Idem ; garde « 1 résa en_cours / lot » |
| `CommercialisationController` (KPI remises) | **à adapter mécaniquement** | Σ par lot reste OK si chaque lot a son `prix_vente_ttc` ; sinon clarifier remise globale |
| `LotCommercialController` | **indifférent** / léger | Opère sur un lot ; résas liées via HasMany |
| `AcquereurController` + `Acquereur/Show.vue` | **décision métier** | Afficher N lots ; quel prix unique vs par lot |
| `ReservationAcquereurUpdateService` | **décision métier** | Sync montants vers **quels** lots |
| `GenerationAppelFondService` | **décision métier** | 1 AF / lot / stade aujourd’hui — N AF ou 1 AF global ? |
| `GenerationAppelFondPDFService` / PDF | **à adapter mécaniquement** | Libellé / référence dossier multi-numéros |
| `AppelsDeFondsController` | **à adapter mécaniquement** | Sérialisation `lot_numero` → liste |
| `ProgrammeEtapeObserver` (`appels_fonds`) | **décision métier** | Montant sur prix global résa déjà — OK si prix global |
| `TMAController` + `ProgrammeTmasPanel.vue` | **à adapter mécaniquement** | Résolution résa par `lot_commercial_id` : OK si pivot inverse ; UI select lot |
| `OffreCommercialeController` | **indifférent** | Offre déjà rattachée au **lot** |
| `AlerteService` | **à adapter mécaniquement** | Libellés `numero_lot` → concat / principal |
| `SharePointService` | **décision métier** | `buildAcquereurFolderName` = **un** `numero_lot` dans le nom de dossier |
| `WidgetDataService` | **à adapter mécaniquement** | Widget retard AdF charge `lotCommercial` (colonne `reference` douteuse vs `numero_lot`) |
| `ImportPegaoController` | **décision métier** | Import 1 lot ↔ 1 résa aujourd’hui |
| `LotsGrillePanel.vue` | **décision métier** | Modale résa / liens / KPI : cœur UI ~3400 L — **série** |
| KPI remises Vue | **à adapter mécaniquement** | Même formule lot |

**Coller Q5** (cardinalité toujours 1:1 en prod).

---

### Q6 — Questions métier soulevées par N lots (sans y répondre)

Voir section **LISTE DES QUESTIONS MÉTIER** ci-dessous (#85 + #86 + compta).

---

### Q7 — Stratégie de migration sans rupture

**Recommandation technique : pivot + conservation de `lot_commercial_id`.**

1. Créer table de liaison `reservation_lot_commercial` (noms FK courts —
   limite MySQL 64 ; **pas** de DDL dans `DB::transaction`).
2. Backfill : 1 ligne pivot par résa existante depuis `lot_commercial_id`.
3. Dual-read / dual-write : code lit pivot si présent, sinon FK ; écrit les
   deux (FK = « lot principal » pendant la transition).
4. UI / services basculent progressivement.
5. Drop FK **plus tard**, lot séparé, quand plus aucun lecteur singulier.

**Contre un big-bang** : pas de `CREATE DATABASE` OVH pour rejouer from
scratch (#1113) ; le #1099 a montré qu’une migration ratée laisse des
chemins cassés. Garder la FK permet un rollback applicatif (ignorer le
pivot) sans restore.

Risque : oublier un lecteur singulier → bug silencieux (mauvais lot /
annulation incomplète). Checklist = tableau Q5.

---

## RECOMMANDATIONS

### #85 — Plusieurs lots sur une réservation (garage flottant)

**Verdict : faisable avec validation métier (pas « maintenant » en l’état),
puis chantier structurant.**

- Faisable techniquement via pivot + dual-write (Q7).
- **Bloqué** tant que les questions prix / AF / annulation / SharePoint
  (liste ci-dessous) ne sont pas tranchées par Robin + commercial.
- Pas de validation **comptable Intacct** bloquante tant que
  `appels_de_fonds` reste vide — mais le modèle AF (1 AF / lot / stade)
  doit être décidé **avant** le code, sinon double écriture plus tard.
- Ne **pas** fusionner avec #86 dans le même commit (zone
  `LotsGrillePanel.vue` + modèles).

### #86 — Prix de vente modifiable (contrat modificatif)

**Verdict : faisable maintenant pour résas sans AF, avec validation
comptable dès qu’un AdF Intacct existe (ou legacy `appels_fonds`).**

- Déjà partiellement livré (fiche acquéreur + verrou AF + sync lot).
- Élargir / fiabiliser **avant** mise en prod AdF réelle :
  1. Fermer le bypass `ReservationController::update` (même verrou).
  2. Garantir sync résa→lot aussi en statut `acte`.
  3. Corriger la fuite `CommercialController` action=`acte` (écrire les
     deux ou interdire).
- **Validation comptable requise** (qui / quoi) dès qu’on veut modifier
  le prix **après** génération AF / facture Intacct :
  - **Qui** : comptabilité (référent Intacct / AF VEFA) + Robin.
  - **Quoi** : procédure d’annulation / régularisation des AF + avoir
    Intacct éventuel ; recalcul des stades suivants ; TVA 20 % figée
    acceptable ? XML AR sans ligne TVA acceptable ?
- Tant que prod = 0 AdF Intacct : risque comptable **latent** ; le
  correctif des fuites de synchro + verrou unique reste **prioritaire**
  et peu coûteux.

---

## LISTE DES QUESTIONS MÉTIER (à poser — sans réponse)

### À Robin / commercial (#85)

1. Le prix de vente est-il **global** à la réservation ou **par lot**
   (appart + garage avec prix séparés) ?
2. Une annulation doit-elle remettre **TOUS** les lots rattachés en stock,
   ou seulement certains (garage libérable indépendamment) ?
3. Le statut lot (`reserve` / `acte`) suit-il la réservation pour **tous**
   les lots liés ?
4. La date d’acte est-elle **commune** aux N lots ?
5. Le dossier SharePoint reste-t-il **unique** (quel numéro de lot dans le
   nom) ou un dossier par lot ?
6. Le garage « flottant » a-t-il sa propre ligne de grille / prix grille /
   surface, ou est-ce un rattachement sans prix ?
7. Les TMA / offres / plans PDF se rattachent-ils au lot secondaire ?
8. En grille, comment visualiser qu’un lot est « secondaire » d’une résa
   déjà affichée sur l’appart ?

### À Robin / commercial (#86)

9. Après signature d’acte, le prix doit-il encore être modifiable (contrat
   modificatif) ou seulement en `en_cours` ?
10. Qui a le droit de modifier le prix (commercial seul / direction /
    back-office) ?
11. Faut-il un motif / pièce jointe obligatoire (avenant) dans
    `reservation_historique` ?
12. La remise KPI doit-elle se recalculer sur le nouveau prix vs grille ?

### À la comptabilité / Intacct

13. L’appel de fonds porte-t-il sur le **prix global** de la réservation
    ou **un AF par lot** ?
14. Si le prix change après un AF déjà émis / sync Intacct : avoir ?
    annulation + régénération ? interdit tant que non soldé ?
15. La TVA AdF forcée à **20 %** (`TTC/1.20`) est-elle correcte pour tous
    les régimes (`lots_commerciaux.regime_tva`) ?
16. L’ARINVOICE n’envoyant que le **HT** (TVA calculée ERP non poussée
    dans le XML) est-elle conforme au paramétrage Sage ?
17. Compte produit **706000** + CLASSID programme : confirmés pour VEFA ?
18. Le module AdF Intacct a-t-il déjà été **testé puis purgé** en prod
    (Auto_increment / souvenirs métier) ou jamais activé métier ?

---

## DÉPLOIEMENT / TEST

```text
git pull origin main
# Aucune migrate. Aucun npm.
php tools/diag/diag_prix_lots_reservation_1161.php
```

Coller la sortie complète (Q0–Q6 script) dans cette fiche. Vérifier surtout
Q1 (divergences actives), Q4 (Auto_increment / intacct_record_no).

## LEÇON

Le prix a **deux colonnes** et une synchro **unidirectionnelle résa→lot**
avec au moins **deux trous** (acte CommercialController ; update résa
post-acte). #86 n’est pas « ouvrir un champ » : c’est garantir une seule
source de vérité + un seul verrou avant que `appels_de_fonds` (montant
figé × Intacct) ne soit alimenté. #85 n’est pas un pivot SQL : c’est
trancher prix global vs par lot et AF global vs par lot **avant** tout
DDL — sinon chaque lecteur du tableau Q5 sera réécrit deux fois.
