Source de vérité opérationnelle de l'app. Lire AVANT toute intervention, mettre à jour APRÈS.
/root/workspace/apps/moa-ops"type":"module"), Express uniquement (aucune autre dépendance).8338 sur 127.0.0.1 (8336 et 8337 étaient déjà pris au moment du build). Surchargéable via env PORT.127.0.0.1:8338. Service systemd moa-ops.service (redémarrer après un changement backend : sudo systemctl restart moa-ops.service). Depuis le 2026-08-24, PLUS d'accès public : l'UI a été rapatriée dans le CRM MOA (onglets Ventes MOA + Commissions, accès admin + Gary). moa-ops est désormais un backend interne appelé par le CRM via proxy server-to-server (X-Internal-Key, en-tête X-Internal-User pour l'attribution). L'ancienne URL ventes-moa.panelbay.com redirige (301) vers crm-moa.panelbay.com. Ne PAS rouvrir au public.```bash
cd /root/workspace/apps/moa-ops
npm install # première fois seulement (installe express)
npm start # ou: node server.js
```
/root/aios/.env (lecture directe du fichier).```
moa-ops/
server.js # serveur Express : login (équipe + comptes perso), routage, /health
ventes.js # routeur API : lecture/écriture Airtable "Unités vendues" (cache 60 s, PATCH validé, journal)
commissions.js # routeur API + moteur commissions : taux 33/40/45, lignes agent + affilié, registre paiements LOCAL SQLite
dossiers.js # routeur API : suivi de l'avancement (timeline d'événements datés + notes) LOCAL SQLite, aucune écriture Airtable
avis.js # routeur API : suivi qualitatif des demandes d'avis client (e-réputation) LOCAL SQLite, aucune écriture Airtable
public/
index.html # frontend ventes : tableau + modal (SUIVI DU DOSSIER : stepper + timeline, Dossier client, Cycle de vie)
commissions.html # frontend commissions : onglets à payer / payées / à compléter, paiements agent+affilié, panneau top seller
data/
journal.jsonl # journal append-only des écritures Airtable + actions avis (créé au premier PATCH/action réussi)
commissions.db # registre LOCAL des paiements de commissions (SQLite, seedé depuis Airtable)
dossiers.db # timeline LOCALE des événements de suivi de dossier (SQLite)
avis.db # registre LOCAL des demandes d'avis (SQLite, table avis_requests)
accounts.json # comptes perso (email -> hash scrypt) pour le login
package.json # start script + dépendances express, better-sqlite3
MANUAL.md # ce fichier
```
appSVJQULMJNXy9HltblfIJdn0MB5dHDJ4https://api.airtable.com/v0?fresh=1 force une relecture.typecast:false.| Clé API interne | Champ Airtable | Type | Options / règle |
|---|---|---|---|
| statut | Statut de l'achat | select | Contrat en Attente, Entrega en Attente, Commission en attente, Commission recue, Avis demandé, Perdu |
| date_suivi | Date Suivi | date | AAAA-MM-JJ |
| entrega | Entrega payé | select | SI, NO, PERDU |
| commission | Commission | currency | nombre >= 0 |
| date_commission | Date reception commission | date | AAAA-MM-JJ (Airtable l'affiche en J/M/AAAA mais l'API attend AAAA-MM-JJ) |
| affilie | Affilié | select | Ladislas, Resident Paraguay, MOA |
| devise | Devise | select | USD, PYG |
| commentaires | Commentaires | text | libre |
| typologie | Typologie | select | Mono, 1 Hab, 2 Hab, 2 Hab Lockoff, 3 Hab, Baulera, Cochera, Lock-Off, Otro, Bonificacion |
| prix_paye | Prix payé | currency | nombre >= 0 (USD) |
| Whatsapp du client | phone | texte libre (<= 40 car.) | |
| Email du client | format email validé avant PATCH | ||
| acheteur2_prenom | Acheteur 2 - Prenom | line | texte 1 ligne (<= 200 car.) |
| acheteur2_nom | Acheteur 2 - Nom | line | texte 1 ligne (<= 200 car.) |
| langues | Langues parlées | multiselect | Español, Aleman, Ingles, Frances, Portugues, Italiano, Neerlandes, Otro |
Un champ envoyé vide efface la valeur Airtable (PATCH avec null ; un multiselect vidé = tableau vide -> null). Toute autre clé de champ est refusée (400 "Champ non modifiable"). Toute valeur select/multiselect hors options, date mal formée, email invalide ou montant négatif est refusée avant l'appel Airtable (400).
Les 8 premiers champs = cycle de vie de la vente. Les 7 suivants (typologie -> langues) = dossier client : infos directement portées par la fiche de vente, éditables depuis la section « Dossier client » du modal (ajout 2026-08-24). Dans l'UI, langues est un groupe de chips togglables ; typologie et prix_paye sont aussi affichés dans le tableau (sous-ligne + colonne) et synchronisés à l'édition.
Nom de l'achat (formule) et les lookups des liens : Name (from Projets immobiliers), Nom (from Nom client), Nom de l'Agent (from Agent). Ces lookups sont déjà présents sur chaque enregistrement, donc l'app n'a PAS besoin de lire les tables liées séparément : elle affiche directement le nom résolu. Le Client et l'Agent (liens Airtable) restent en lecture seule dans le modal ; les réassigner demanderait des menus de sélection (non implémenté).
Une cochera ou une baulera n'est pas une vente à part : c'est une annexe
rattachée à un appartement du même dossier. groupAnnexes() (dans ventes.js)
regroupe, côté board, toute annexe (Typologie ∈ {Cochera, Baulera}) sous
l'appartement du même dossier, où le dossier = **ensemble COMPLET des clients
liés (trié) + projet** (clé = clientKey|projet, clientKey = tous les ids du
lien Nom client triés et joints). L'annexe rattachée n'apparaît plus comme une
ligne de vente : elle devient une puce + Cochera <n> sur la ligne de l'appart
parent (le compte de ventes reflète les vraies affaires). Détails :
2026-08-24). Deux achats du même immeuble qui partagent une personne mais pas
la même société (ex. Akirov, FGA vs Akirov, SIAPE) sont ainsi des dossiers
DISTINCTS : les annexes de l'un ne remontent plus sur l'appart de l'autre. Avant,
la clé ne prenait que le 1er client (Akirov), fusionnait les deux achats et
collait toutes leurs annexes sur un seul appart (bug signalé par Matt).
chaque annexe est rattachée à l'appart le plus proche en date (Creation :
les annexes d'un achat sont enregistrées avec leur appart), à défaut de date le
1er appart du tri. Cas rare ; l'important est de ne pas mélanger deux achats.
complète de l'annexe (le front va la chercher via GET /api/ventes/:id, car
elle n'est plus dans la liste du board). byId contient toujours tous les
enregistrements, donc la fiche individuelle marche pour tout id.
commission ; il calcule à partir de toutes les ventes indépendamment du board).
Dossiers actifs d'abord, Perdu en dernier. Ordre : Contrat en Attente, Entrega en Attente, Commission en attente, Avis demandé, Commission recue, puis Perdu.
Moteur qui calcule combien chaque agent doit toucher sur ses ventes encaissées, et
vue "commissions à payer". Ce module ne fait AUCUNE écriture Airtable : il lit les
ventes en lecture seule et tient un registre LOCAL des paiements dans SQLite.
Date reception commission renseignée ET Commission > 0 ET un Agent lié. (~77 ventes.)
Séparément, la liste "à compléter" = ventes au statut Commission recue avec
Commission manquante (>0) OU Date reception commission manquante (~16). Données
incomplètes, montrées pour ne rien perdre, mais PAS payables.
Chaque vente dont la commission est reçue génère une ou deux lignes de commission,
suivies et payées séparément (colonne beneficiary = agent | affilie) :
1. Ligne agent (l'agent qui a fait la vente) : taux via agentRate() :
Affilié != MOA / Resident Paraguay / vide ; aujourd'hui Ladislas).2. Ligne affilié (UNIQUEMENT sur vente affiliée externe) : 33% versés à
l'affilié (Ladislas), EN PLUS de la ligne agent. C'est un second
bénéficiaire, pas un remplacement : sur une vente Ladislas, l'agent touche
son 33% ET Ladislas touche son 33% (≈66% de la commission distribués).
payable = round(Commission * taux). La devise (USD/PYG) est conservée, JAMAIS
convertie. computeRows() émet les deux lignes avec line_key = <saleId>:<type>.
Le calcul des affiliés a été réconcilié avec l'Airtable le 2026-08-24 : 19 lignes
Ladislas, 35 230 $ payable, 10 264 $ déjà payés, 24 966 $ restant (identique à la
table "Paiement Agents").
EXCLUDED_AGENTS dans commissions.js (comparaison normalisée sans accents/casse) :
Matthieu Bouthors, Sebastien Beaupied (CEO) et MOA Realestate (compte
maison). Ils ne produisent pas de ligne agent (pas de commission d'agent pour
eux) et sont retirés du panneau top-seller. ATTENTION : l'exclusion porte sur la
LIGNE AGENT seulement. Si une vente affiliée Ladislas a pour agent un CEO, la
ligne affilié de Ladislas (33%) apparaît quand même (le bénéficiaire est
l'affilié, pas l'agent). Pour exclure un autre profil, ajouter son nom dans
EXCLUDED_AGENTS.
Le lookup natif Nom (from Nom client) de la table "Unités vendues" est vide
côté Airtable (mal configuré). Le nom du client est donc résolu via le lien
Nom client (ids -> table Contacts tbla4ms3q9vSLj8Y5, champ primaire
Full name). commissions.js et ventes.js chargent une map id -> Full name
(loadContacts(), cache 5 min) et exposent client sur chaque ligne. Affiché en
colonne "Client" dans la page commissions (à payer / payées / à compléter) et le
tableau des ventes. Si un jour le lookup Airtable est réparé, on pourra revenir à
la lecture directe du lookup.
data/commissions.db, via better-sqlite3)payments (id, sale_id, beneficiary, amount, date, method, note, created_at) : un paiement par ligne. beneficiary = agent | affilie : le payé est suivi
PAR (vente, bénéficiaire), pas par vente (une vente affiliée a deux lignes à
solder indépendamment). Colonne ajoutée par ALTER TABLE au démarrage.
agent_topseller (agent, top) : flag 45% par agent (défaut 0).meta (key, value) : porte le flag seeded_v2 (garde anti double-seed). (tblWgGvYUX9IPPC4Q) ; pour chaque ligne, bénéficiaire déduit du libellé
Unité vendue ("... (Affilié)" -> affilie, sinon agent), somme de
Commission payée par (vente, type). Le seed efface d'abord les anciennes lignes
method='seed' (migration depuis le seed par-vente v1) puis réinsère. Flag
meta.seeded_v2 + transaction atomique, ne se rejoue jamais. Pour re-seeder
après correction Airtable : supprimer la clé seeded_v2 de meta et les lignes
method='seed', puis relancer.
a_payer si paid==0, partiel si 0<paid<payable, payee si paid>=payable(tolérance 1 unité de devise). Tri : à payer d'abord, plus gros restant en tête.
totals : a_payer_count, affilie_count, a_payer_montant (somme des restants a_payer+partiel), paye_total, plus restant_par_devise, paye_par_devise,
restant_affilie_par_devise et restant_agent_par_devise (ventilation par type
de bénéficiaire). IMPORTANT : USD et PYG ne s'additionnent pas ; l'UI affiche le
détail par devise, a_payer_montant reste un cumul brut fourni pour l'API.
Les ventes sont lues avec un cache 60 s ; les paiements et flags top seller sont lus
frais à chaque calcul, donc un nouveau paiement ou une bascule top seller se reflète
immédiatement (payable des ventes DIRECTES de l'agent recalculé à 45%).
Toutes les routes (sauf /health, /login) exigent une session équipe valide.
| Méthode | Route | Rôle | |||
|---|---|---|---|---|---|
| GET | /health | Sonde de santé (public). {ok:true} | |||
| GET | /login , POST /login | Page + soumission du login. POST accepte email+password (compte perso) ou password seul, email vide (équipe) | |||
| GET | /logout | Efface le cookie de session | |||
| GET | / | Tableau (index.html), protégé | |||
| GET | /api/me | Session courante (UI) | |||
| GET | /api/ventes/meta | Options de champ + couleurs de statut + comptage par statut + writable | |||
| GET | /api/ventes/board | Toutes les ventes avec lookups résolus (cache 60 s) | |||
| GET | /api/ventes/:id | Fiche d'une vente | |||
| POST | /api/ventes/:id | {field, value} : PATCH ciblé validé (uniquement les champs éditables) | |||
| GET | /commissions | Page "commissions à payer" (commissions.html), protégée | |||
| GET | /api/commissions | Lignes calculées (agent + affilié) + totaux + liste "à compléter". Filtres ?status=, ?beneficiaire= (alias ?agent=), `?type=agent\ | affilie` | ||
| GET | /api/commissions/agents | Agents directs distincts + flag top seller (panneau de bascule ; hors affiliés) | |||
| POST | /api/commissions/:saleId/pay | `{amount, type?:'agent'\ | 'affilie', date?, method?, note?}` : paiement LOCAL sur la ligne (vente+bénéficiaire) ; montant > 0 et <= restant + 1 | ||
| POST | /api/commissions/topseller | `{agent, top:0\ | 1}` : bascule le flag top seller d'un agent (45% sur ses ventes directes) | ||
| GET | /api/dossiers/meta | Liste des jalons proposés pour le suivi | |||
| GET | /api/dossiers/summary | Résumé par vente : nb d'événements, dernière date, dernier jalon | |||
| GET | /api/dossiers/:saleId/events | Timeline d'un dossier (récent -> ancien) | |||
| POST | /api/dossiers/:saleId/events | {kind, date?, note?} : ajoute un événement de suivi (author = user connecté) | |||
| DELETE | /api/dossiers/:saleId/events/:id | Supprime un événement (correction) | |||
| GET | /api/avis/meta | Canaux de contact possibles + actions valides | |||
| GET | /api/avis/board | 3 listes calculées : eligibles (Commission reçue, aucune ligne locale), en_cours (demandé/relancé), obtenus (avis laissé/sans suite) | |||
| POST | /api/avis/:saleId | `{action:'demande'\ | 'relance'\ | 'obtenu'\ | 'sans_suite', channel?, review_url?, note?}` : upsert LOCAL (aucun envoi, aucune écriture Airtable) |
Chaque vente est un dossier que l'équipe fait avancer. Deux briques, dans le
modal de la fiche de vente (section « Suivi du dossier », en haut) :
1. Pipeline / stepper : les étapes = les options du champ Airtable
Statut de l'achat (Contrat en Attente -> Entrega en Attente -> Commission en
attente -> Commission recue -> Avis demandé, + Perdu à part). Cliquer une étape
écrit le statut dans Airtable (via POST /api/ventes/:id champ statut). Le
stepper REMPLACE l'ancien select statut du modal.
2. Timeline d'événements datés + notes (LOCAL SQLite data/dossiers.db, table
dossier_events(id, sale_id, date, kind, note, author, created_at)). L'équipe
ajoute un jalon (Contrat signé, Acompte reçu, Paiement complet reçu,
Entrega (livraison), Commission reçue, Avis client demandé,
Relance client, Note) avec une date (défaut aujourd'hui) et une note libre.
author = l'utilisateur connecté (email perso ou equipe). Événements
affichés du plus récent au plus ancien ; suppression possible (correction).
Rien de tout ça n'écrit dans Airtable côté timeline (Airtable ne modélise pas ce
journal) ; seul le changement d'étape (statut) est propagé à Airtable.
Ajouté 2026-09-02 (décision Matt) : MOA vend un produit cher à peu de clients
(immobilier), donc pas de mass-mailing d'avis. La bonne pratique pour ce
profil est une demande personnelle (WhatsApp/appel/en personne, par
Gary/l'agent lui-même), au bon moment (dossier réellement clos), avec **au
plus une relance**, puis on arrête. Ce module ne fait AUCUN envoi automatique :
il aide juste Gary à ne pas oublier qui solliciter et qui relancer.
Statut de l'achat = Commission recue (dossier 100% clos côté MOA) ET qu'aucune ligne locale
n'existe encore pour elle. Calculé à la volée dans GET /api/avis/board
(réutilise loadRows() exportée de ventes.js, même cache 60 s, aucune
lecture Airtable supplémentaire).
data/avis.db, table `avis_requests(sale_id PK,status, channel, relances, asked_at, last_action_at, review_url, note,
updated_by, updated_at)`) : Airtable ne modélise pas ce suivi, on ne lui a
ajouté aucun champ.
demande -> relance (répétable, compteur relances) -> terminal obtenu (avec
review_url optionnel, réutilisable comme témoignage) ou sans_suite
(refus/pas de réponse, on arrête de relancer). Chaque action journalisée
dans le même data/journal.jsonl que les écritures de vente
(action:'avis.<demande|relance|obtenu|sans_suite>').
En attente de réponse avec badge relance après 10 j sans action / Historique).
Détail complet UI : apps/moa-crm/MANUAL.md.
Avis demandé (option du champ Airtable Statut de l'achat) et le jalon Avis client demandé/Relance client
(timeline dossiers.js) : ces deux éléments existaient déjà mais rien ne
rendait la démarche proactive pour Gary (il fallait y penser lui-même).
Ils restent en place (rien retiré, pas de risque sur l'historique existant)
mais le suivi actif des demandes d'avis passe désormais par CE module.
Deux voies de connexion, même cookie signé HMAC-SHA256 (même primitive que rp-dashboard/auth.js).
1. Équipe (mot de passe partagé) : laisser le champ email VIDE sur la page de login, saisir le mot de passe d'équipe. Session attribuée à equipe.
2. Compte personnel (email + mot de passe) : saisir son email + son mot de passe. Session attribuée à l'email (les modifs sont journalisées sous cet email dans data/journal.jsonl, au lieu de equipe).
moaopssess, HttpOnly, SameSite=Lax, 30 jours. Token : ts.b64url(user).sig (les anciens tokens ts.sig restent acceptés en session equipe).TEAM_PASSWORD. Défaut si absent : moaops2026 (à changer en posant TEAM_PASSWORD dans .env).DASH_SECRET (défaut de dev moa-ops-dev-secret ; poser une vraie valeur en prod).data/accounts.json)data/accounts.json : { "<email>": { "hash": "salt:hex" | null, "label": "..." } }. Les mots de passe NE sont JAMAIS stockés en clair : hash scrypt (sel 16 o, clé 64 o), vérif en temps constant.hash: null (« en attente »). La première connexion réussie avec cet email fixe le mot de passe (min. 6 car.) et le hache ; il est exigé ensuite. Évite de faire transiter un mot de passe en clair (chat, .env).matt.bouthors@gmail.com (label « Matt »), en attente au 2026-08-24. L'email « matt.bouthors » sans domaine est normalisé vers @gmail.com."hash": null sur l'entrée du compte dans data/accounts.json ; la prochaine connexion le redéfinira. Ajouter un membre = ajouter une entrée {"hash": null, "label": "..."}.Aucune valeur de secret dans ce fichier. Emplacement des clés : /root/aios/.env.
AIRTABLE_API_KEY : token de LECTURE (accès à la base "Moa" confirmé OK).AIRTABLE_WRITE_TOKEN : token d'ÉCRITURE (PATCH). Point d'attention (2026-08-24) : ce token n'a PAS encore accès à la base "Moa" (appSVJQULMJNXy9Hl), il renvoie 403. Il faut ajouter la base "Moa" + le scope data.records:write à ce token dans les réglages Airtable (builder hub → personal access tokens). Tant que ce n'est pas fait, la lecture marche mais les écritures échouent en 502 (Airtable 403) proprement, sans corrompre de donnée.TEAM_PASSWORD, DASH_SECRET : optionnels (voir Auth)..env, dans data/accounts.json (hash scrypt uniquement, jamais de clair).ss -ltnp | grep 8338 avant de lancer.moa-ops devient un backend interne : (1) server.js requireTeam lit désormais X-Internal-User (en plus de X-Internal-Key) pour attribuer les événements de suivi / le journal à l'utilisateur RÉEL du CRM (admin ou Gary) au lieu de « internal » ; (2) le CRM relaie toute l'API via son proxy générique /api/moaops/* (gaté admin + coordinateur). URL publique fermée : le vhost nginx ventes-moa.panelbay.com ne proxy plus vers 8338, il redirige 301 vers crm-moa.panelbay.com (backup de l'ancien vhost : /root/ventes-moa.vhost.bak-preclose-20260824). Retiré du portail new-tab. Le service moa-ops.service continue de tourner sur 127.0.0.1:8338 (appelé par le CRM). Aucun changement des modules ventes.js/commissions.js/dossiers.js (seul requireTeam touché). Restart moa-ops fait. Détails côté CRM : voir apps/moa-crm/MANUAL.md (entrée du même jour).ventes.js (lecture/écriture Airtable "Unités vendues", cache 60 s, PATCH validé sur 8 champs éditables, journal data/journal.jsonl), frontend index.html (tableau, tabs par statut avec comptage, recherche unité/projet/client/agent, modal d'édition autosave, thème MOA forest/crème Fraunces+Inter). Testé : /health OK, /api/ventes/meta renvoie 116 ventes dont 93 "Commission recue", /api/ventes/board renvoie les lookups résolus, validation du PATCH OK (rejets 400 sur champ interdit / select invalide / date invalide / montant négatif). Écriture Airtable bloquée par le scope du token AIRTABLE_WRITE_TOKEN (403 sur la base "Moa", à corriger côté Airtable). Non déployé (127.0.0.1:8338, ni systemd ni nginx).commissions.js (moteur + routeur) et de la page public/commissions.html (onglets À payer / Payées / À compléter, action "marquer un paiement", panneau "agents top seller"), liée depuis l'en-tête des ventes. Règle de taux exacte : 33% affilié externe (Ladislas), sinon 40% (direct) ou 45% (top seller), payable = round(Commission * taux), devise conservée sans conversion. Registre de paiements LOCAL en SQLite data/commissions.db (dépendance better-sqlite3 ajoutée) : tables payments, agent_topseller, meta ; AUCUNE écriture Airtable dans ce module. Seed unique depuis l'ancienne table "Paiement Agents" (tblWgGvYUX9IPPC4Q), somme Commission payée par vente. Ajout de restant_par_devise / paye_par_devise aux totaux car USD et PYG ne s'additionnent pas (l'UI affiche le détail par devise, jamais un cumul mixte sous un seul symbole). Testé (serveur sur un port dédié, sans toucher au service systemd) : GET /api/commissions renvoie 77 lignes encaissables + 16 à compléter ; répartition des taux 40% x 58 et 33% x 19 (aucun top seller par défaut) ; statuts a_payer 42 / payee 35 ; restant à verser 52 440 $ + 9 912 762 ₲ ; seed = 35 ventes soldées, total 87 057,25 $ (toutes USD). Bascule top seller vérifiée : la vente directe de Monica Ayala passe de 40% (9 912 762) à 45% (11 151 857), sa vente Ladislas reste à 33%. POST pay testé (1,50 $ sur une vente à 2 960, statut passé partiel, restant 2 958,50), gardes 400/404 vérifiées (montant 0, surpaiement, date invalide, vente inconnue), puis la ligne de test SUPPRIMÉE et le flag top seller de test RÉINITIALISÉ (DB propre : 35 lignes seed uniquement). ventes.js non modifié. Non déployé par ce chantier (pas de redémarrage du service, pas de nginx). Anomalie de données notée : commissions en USD ET en PYG dans la même table, d'où l'obligation d'un total par devise (pas de taux de change appliqué).AIRTABLE_WRITE_TOKEN_MOA (dans /root/aios/.env), scopé sur la base Moa avec data.records:write (le AIRTABLE_WRITE_TOKEN global reste scopé RP). ventes.js lit AIRTABLE_WRITE_TOKEN_MOA en priorité. PATCH aller-retour testé OK. Service systemd moa-ops.service (port 8338) créé et activé. Exposé en HTTPS sur https://ventes-moa.panelbay.com (nginx + Let's Encrypt, login équipe). Ajouté au portail (groupe MOA Real Estate). Brique 1 de la migration de l'automation Airtable "Import commission" ; restent brique 2 (moteur commissions, règle 33/40/45) et brique 3 (vue "à payer" dans le CRM MOA).commissions.js exclut désormais les CEO (Matthieu Bouthors, Sebastien Beaupied) de tout le module commissions : ils apparaissaient à tort dans « à payer ». Ajout de EXCLUDED_AGENTS (set normalisé sans accents/casse) + isExcludedAgent() ; computeRows() saute leurs ventes (avant « à payer » ET « à compléter ») et l'endpoint /api/commissions/agents les retire du panneau top-seller. Impact mesuré : 40 ventes encaissables (~53 253 USD de payable cumulé) qui gonflaient l'« à payer » ont disparu de la vue. Vérifié en live : Matthieu/Seb absents de la liste d'agents, des rows et de a_completer. Service redémarré.ventes.js : groupAnnexes() rattache toute annexe (Typologie Cochera/Baulera) à l'appartement du même clientId + projet ; la ligne toRow porte désormais clientId (lien Nom client) et un tableau annexes sur l'appart parent. Board + comptages par statut calculés sur la liste groupée (116 -> 100 ventes, 16 annexes rattachées, 0 orpheline). Frontend index.html : puces + Cochera/Baulera <n> sous l'appart (style .annx), cliquables (stopPropagation) ; openModal rendu async et va chercher la fiche d'une annexe via GET /api/ventes/:id si absente du board (puis l'ajoute à byId pour que stepper/édition/suivi fonctionnent). Commissions inchangées (annexes quasi sans commission). Vérifié en live (Playwright) : 100 lignes, 16 puces, clic sur une puce ouvre la fiche de l'annexe (ex. « 02 · Aether », Baulera) avec son stepper. Service redémarré.groupAnnexes() groupait par 1er client seulement (clientId), donc fusionnait les deux dossiers et collait TOUTES leurs annexes (2 cochera + 2 baulera) sur un seul appartement (celles de l'ancien achat remontaient sur le nouveau). Fix ventes.js : (1) clé de regroupement = ensemble complet des clients liés triés (clientKey = tous les ids de Nom client triés + joints) au lieu du 1er → « Akirov+FGA » et « Akirov+SIAPE » deviennent 2 dossiers distincts, chacun garde sa cochera + sa baulera ; (2) filet de sécurité : si un dossier a quand même plusieurs apparts, chaque annexe va à l'appart le plus proche en date (Creation, ajouté à READ_FIELDS et à toRow). Rappel : ce regroupement est purement de l'affichage (les annexes deviennent des puces), il n'écrit RIEN dans Airtable, donc aucune donnée à réparer. Vérifié sur les données réelles Akirov/Levy : les 4 groupes se séparent correctement (Akirov+FGA → 15A + Baulera 02 + Cochera 23 ; Akirov+SIAPE → L37 + Cochera 24 + Baulera 03 ; Levy+Akirov Veralta → 1-15-5 + C45 ; Levy Aether → 36K + ses 2 annexes). node --check OK, service redémarré (/health 200). MAJ aussi de la note infra (le service systemd + nginx existent, le manuel disait « pas de systemd »).dossiers.js (routeur + SQLite data/dossiers.db, table dossier_events) : endpoints meta / summary / list / add / delete, author = utilisateur connecté. Monté dans server.js. Frontend index.html : nouvelle section « Suivi du dossier » en tête du modal = (1) stepper cliquable des étapes du Statut de l'achat (remplace l'ancien select statut, écrit dans Airtable au clic) + (2) timeline d'événements (jalon + date + note libre, ajout/suppression, plus récent en tête). 8 jalons prédéfinis (Contrat signé, Acompte reçu, Paiement complet reçu, Entrega, Commission reçue, Avis client demandé, Relance client, Note). La timeline est locale (Airtable ne la modélise pas) ; seul le changement d'étape est propagé à Airtable. Testé bout en bout (API : add/list/summary/delete ; UI Playwright : stepper rendu avec étape courante, menu 8 jalons, événement affiché) puis événement de test supprimé (DB propre). Service redémarré.commissions.js : payments gagne une colonne beneficiary ('agent'|'affilie') via ALTER TABLE ; suivi du payé par (vente, bénéficiaire) au lieu de par vente ; computeRows() émet une ligne agent (agentRate, exclusion CEO/maison inchangée mais limitée à la ligne agent) + une ligne affilié 33% si isAffiliatedSale() ; makeLine() avec line_key=<saleId>:<type> ; totaux ventilés restant_affilie_par_devise/restant_agent_par_devise + affilie_count ; endpoint pay prend type ; filtres beneficiaire/type. Re-seed v2 par bénéficiaire depuis "Paiement Agents" (déduit agent/affilié du libellé, efface l'ancien seed par-vente, flag seeded_v2). Frontend commissions.html : colonne "Agent" -> "Bénéficiaire" (tag "Affilié" + "vente par <agent>" en sous-ligne), 4e carte stat "Dont affiliés", filtre par bénéficiaire (Ladislas inclus), bouton/modal de paiement portant le type. Réconcilié avec l'Airtable : 19 lignes Ladislas, payable 35 230 $, déjà payé 10 264 $ (6 ventes), restant 24 966 $ (13 ventes) — identique à "Paiement Agents". Vérifié en live (Playwright) : 18 à payer dont 13 affiliés, carte "Dont affiliés (13) = 24 965 $", lignes Ladislas à 33% avec l'agent en sous-ligne. Service redémarré.MOA Realestate (compte maison) ajouté à EXCLUDED_AGENTS (même traitement que les CEO). (2) Ajout du nom du client dans le module commissions ET le tableau des ventes. Le lookup Airtable Nom (from Nom client) étant vide, résolution via le lien Nom client -> table Contacts tbla4ms3q9vSLj8Y5 (Full name) : nouveau loadContacts() (map id->nom, cache 5 min) + resolveClient() dans commissions.js et ventes.js. Colonne "Client" ajoutée dans public/commissions.html (à payer / payées / à compléter, colspans MAJ 9->10 et 6->7, recherche incluant le client, sous-titre du modal de paiement) ; ventes.js peuple aussi client correctement (avant : "-" partout). Vérifié en live (Playwright) : en-tête Client présent, 1re ligne « Luke Damant », 0 ligne sans client (36 commissions, 116 ventes), aucun CEO/MOA Realestate dans le sélecteur d'agents. Service redémarré.server.js : comptes personnels dans data/accounts.json ({email:{hash,label}}), hash scrypt (jamais de clair), enregistrement du mot de passe à la 1re connexion (compte pré-créé hash:null). Page de login enrichie d'un champ email (vide = accès équipe). Token de session étendu à ts.b64url(user).sig (rétrocompat avec les anciens ts.sig -> equipe) ; les modifs sont désormais journalisées sous l'email de l'auteur. Compte pré-créé matt.bouthors@gmail.com (normalisation « matt.bouthors » -> @gmail.com). Testé : login équipe (email vide) OK 302 ; 1re connexion perso enregistre le hash puis mauvais mot de passe rejeté / bon accepté ; compte Matt remis en attente (hash=null) après test pour qu'il pose lui-même son mot de passe à sa 1re vraie connexion. Service redémarré.ventes.js : typologie (select), prix_paye (currency), whatsapp (phone), email (email validé), acheteur2_prenom / acheteur2_nom (line), langues (multiselect Langues parlées). Nouveaux types de coercition backend : phone, email (regex), line, multiselect (filtre sur options autorisées, dédoublonnage, vide -> null). Typologie et Prix payé quittent la zone lecture seule et sont désormais servis via v (alias row.typologie / row.prixPaye conservés pour le tableau, synchronisés à l'édition côté client). Frontend : chips togglables pour les langues (style .msel), inputs tel/email/texte, désactivés en lecture seule. Client et Agent (liens) restent lecture seule. Testé en live (https://ventes-moa.panelbay.com, login équipe) : modal rend les 2 sections + 15 champs, chips langues OK ; PATCH idempotent réécrit langues + whatsapp sans altérer la donnée ; rejets 400 vérifiés (langue hors liste « Klingon », email « pasunemail »). Service redémarré.POST /api/ventes/:id/annexe (body {typologies:["Cochera"|"Baulera"], unite?, prix?}). Cree un/deux enregistrement(s) "Unites vendues" de typologie annexe en recopiant les liens Nom client / Projets immobilier / Agent de la vente parente (:id), + Unite/Prix optionnels. Helpers atGetRecord (lecture du parent) et atCreate (creation, typecast:false). Journalise (vente.annexe.create) et invalide le cache. L'annexe est ensuite rattachee automatiquement au parent par groupAnnexes. Appele par le CRM via le proxy /api/moaops. Teste de bout en bout (creation reelle rattachee au parent "37 J", puis suppression du test).POST /api/ventes/:id/annexe accepte desormais items:[{typologie,unite,prix}] (compat ancien {typologies,unite,prix} conservee). Validation stricte : typologie in {Cochera,Baulera}, unite non vide, prix > 0, sinon 400. Chaque item cree un enregistrement avec son propre Unite + Prix paye (+ liens client/projet/agent recopies du parent). Teste : 400 si prix ou numero manquant ; creation de 2 items (Cochera+Baulera) avec prix, rattaches au parent, puis supprimes.GET /api/ventes/options (agents + projets existants deduits des ventes, + jeux d'options des selects) et POST /api/ventes/create (cree un enregistrement "Unites vendues" Airtable). Agent et projet sont resolus en record id via des maps nom->id (READ_FIELDS enrichi de Agent + Projets immobilier), donc AUCUN doublon cree ; le client est cree/relie par nom via typecast (nouvel acheteur = nouveau Contact). atCreate(fields, typecast=false) desormais parametrable. Champs valides via coerce(), statut par defaut "Contrat en Attente". /api/ventes/options defini AVANT /api/ventes/:id (sinon capture comme id). Route de creation reservee via le proxy CRM (admin + coordinateur). Teste : create + verif Airtable (agent lie, client cree, langues, prix) + delete du record de test = OK.agentMap (selecteur "+ Nueva venta") completee par le registre officiel Airtable, pas seulement les ventes passees. Signale par Gary (WhatsApp) : impossible de choisir un agent nouvellement recrute ou sans encore aucune vente saisie (le selecteur ne proposait QUE des noms deduits des ventes Airtable existantes, cf. entree du 2026-08-28). Nouvelle table lue : "Agents Immobilier" (tblXzb5xxZPN5irCH, meme base "Moa"), champ formule Nom de l'Agent (= First Name + Last Name), filtre Statut='Actif'. ventes.js : loadAgentsRegistry() (cache 5 min, meme pattern que loadContacts) construit Map<nom_normalise, {id,name}> depuis ce registre ; loadRows() initialise desormais agentMap avec ce registre EN PRIORITE puis complete avec les noms deduits des ventes existantes (couvre les affilies/agents "Inactif" ayant deja un historique, ex. Axel Chauffaud). AUCUNE nouvelle permission (lecture seule, meme AIRTABLE_API_KEY). Verifie via le proxy CRM (token Gary) : agents passe de 10 a 17 noms (Antonella Ozorio, Lucas Enciso, Thiago Galeano, Vsevolod Talalaev desormais presents, en plus d'Abel/Manuel qui avaient un historique). node --check OK, moa-ops.service redemarre. Limite restante, attendue : un nom totalement absent du registre Airtable (ni "Agents Immobilier" ni aucune vente) reste invisible du selecteur -> il faut d'abord l'onboarder officiellement (fiche Airtable + compte CRM), ce module ne cree pas d'agent. Cas reel au moment du fix : "Karen" (evoquee par Gary) introuvable partout, signale a Matt sans action prise.avis.js : suivi qualitatif des demandes d'avis client (e-reputation). Demande Matt (produit cher, peu de clients -> pas de mass-mailing, demande personnelle + qualitative). Nouveau routeur independant, SQLite local dedie data/avis.db (table avis_requests), AUCUNE ecriture Airtable, AUCUN envoi automatique (Gary/l'agent fait la demande lui-meme et vient juste la consigner). Eligibilite = vente au statut Commission recue sans ligne locale ; calculee dans GET /api/avis/board en reutilisant loadRows(), desormais exportee de ventes.js (seul changement dans ce fichier). Endpoints GET /api/avis/meta, GET /api/avis/board (3 listes : eligibles/en_cours/obtenus), POST /api/avis/:id ({action:'demande'|'relance'|'obtenu'|'sans_suite', channel?, review_url?, note?}, valide action+canal, journalise dans le meme data/journal.jsonl que les ventes). Monte dans server.js (app.use('/', requireTeam, avisRouter)), couvert automatiquement par le proxy generique du CRM (aucun changement cote moa-crm/backend/server.js). Coexiste avec l'ancien statut Airtable "Avis demandé" et le jalon timeline "Avis client demandé"/"Relance client" (rien retire, mais desormais ce module porte le suivi actif). Detail UI (onglet "Avis", Gary/admin) : moa-crm/MANUAL.md. node --check OK sur avis.js/ventes.js/server.js. Teste de bout en bout en direct sur 127.0.0.1:8338 (cle interne, hors proxy CRM) : /api/avis/board renvoie les vraies ventes eligibles (donnees Airtable reelles, non modifiees) ; cycle complet demande->relance->obtenu sur un sale_id de test fictif (jamais un vrai dossier), rejets 400 verifies (action/canal invalides), entrees journal confirmees, ligne de test supprimee de avis.db apres coup. moa-ops.service redemarre.