← Tous les manuels · Portail

MANUAL.md - moa-ops (MOA · Unités vendues)

Source de vérité opérationnelle de l'app. Lire AVANT toute intervention, mettre à jour APRÈS.

Identité

  • - Nom : MOA - Unités vendues (tableau opérationnel des ventes).
  • - Rôle : outil opérationnel de l'équipe MOA (Gary...) pour (1) faire avancer chaque dossier étape par étape et journaliser son suivi (contrat signé, paiement reçu, entrega, notes datées) — c'est le coeur du dashboard ; (2) tenir à jour les infos de la vente (statut, entrega, commission, dates, affilié, devise, dossier client) ; (3) suivre les commissions à payer (agents + affiliés). Remplace la saisie brute dans Airtable, en ligne, avec autosave.
  • - Répertoire : /root/workspace/apps/moa-ops
  • - Langue UI : français. Aucun em-dash (U+2014) où que ce soit.
  • - Périmètre v1 : mise à jour des ventes existantes (la création de vente et l'édition des liens Projet/Client/Agent viendront plus tard).
  • Infra

  • - Runtime : Node.js (ESM, "type":"module"), Express uniquement (aucune autre dépendance).
  • - Port : 8338 sur 127.0.0.1 (8336 et 8337 étaient déjà pris au moment du build). Surchargéable via env PORT.
  • - Écoute : 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.
  • - Lancement :
  • ```bash

    cd /root/workspace/apps/moa-ops

    npm install # première fois seulement (installe express)

    npm start # ou: node server.js

    ```

  • - Les clés sont chargées depuis l'environnement, avec repli automatique sur /root/aios/.env (lecture directe du fichier).
  • Structure

    ```

    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

    ```

    Données : Airtable (LECTURE + ÉCRITURE)

  • - Base "Moa" : appSVJQULMJNXy9Hl
  • - Table "Unités vendues" : tblfIJdn0MB5dHDJ4
  • - API REST : https://api.airtable.com/v0
  • - Cache lecture : 60 s (comme rp-dashboard). ?fresh=1 force une relecture.
  • - Écriture : PATCH ciblé sur un seul enregistrement, un seul champ à la fois, avec validation stricte (voir plus bas). typecast:false.
  • Champs éditables (seuls autorisés au PATCH)

    Clé API interneChamp AirtableTypeOptions / règle
    statutStatut de l'achatselectContrat en Attente, Entrega en Attente, Commission en attente, Commission recue, Avis demandé, Perdu
    date_suiviDate SuividateAAAA-MM-JJ
    entregaEntrega payéselectSI, NO, PERDU
    commissionCommissioncurrencynombre >= 0
    date_commissionDate reception commissiondateAAAA-MM-JJ (Airtable l'affiche en J/M/AAAA mais l'API attend AAAA-MM-JJ)
    affilieAffiliéselectLadislas, Resident Paraguay, MOA
    deviseDeviseselectUSD, PYG
    commentairesCommentairestextlibre
    typologieTypologieselectMono, 1 Hab, 2 Hab, 2 Hab Lockoff, 3 Hab, Baulera, Cochera, Lock-Off, Otro, Bonificacion
    prix_payePrix payécurrencynombre >= 0 (USD)
    whatsappWhatsapp du clientphonetexte libre (<= 40 car.)
    emailEmail du clientemailformat email validé avant PATCH
    acheteur2_prenomAcheteur 2 - Prenomlinetexte 1 ligne (<= 200 car.)
    acheteur2_nomAcheteur 2 - Nomlinetexte 1 ligne (<= 200 car.)
    languesLangues parléesmultiselectEspañ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.

    Champs affichés en LECTURE SEULE (jamais écrits)

    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é).

    Regroupement des annexes (cochera / baulera)

    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 :

  • - La clé est l'ensemble COMPLET des clients, pas le 1er seulement (corrigé
  • 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).

  • - S'il reste plusieurs apparts dans le même dossier (mêmes clients + projet),
  • 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.

  • - Une annexe sans appart dans son groupe reste une ligne normale (aucune perte).
  • - Les annexes restent éditables : la puce est cliquable et ouvre la fiche
  • 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.

  • - Les comptages par statut (onglets) sont calculés sur la liste groupée.
  • - Le module commissions n'est pas affecté (les annexes n'ont quasiment jamais de
  • commission ; il calcule à partir de toutes les ventes indépendamment du board).

    Tri du tableau

    Dossiers actifs d'abord, Perdu en dernier. Ordre : Contrat en Attente, Entrega en Attente, Commission en attente, Avis demandé, Commission recue, puis Perdu.

    Module Commissions (commissions.js)

    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.

    Règle "commission recue" (une vente est encaissable si TOUT est vrai)

    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.

    Deux bénéficiaires possibles par vente : agent ET affilié

    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() :

    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").

    Agents exclus (CEO, pas de commission d'agent)

    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.

    Nom du client (résolution via la table Contacts)

    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.

    Registre local (SQLite, 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).
  • - Seed v2 (par bénéficiaire) : au premier appel, lecture de "Paiement Agents"
  • (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.

    Statut d'une ligne et totaux

  • - 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.

    Recalcul

    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%).

    Endpoints

    Toutes les routes (sauf /health, /login) exigent une session équipe valide.

    MéthodeRouteRôle
    GET/healthSonde de santé (public). {ok:true}
    GET/login , POST /loginPage + soumission du login. POST accepte email+password (compte perso) ou password seul, email vide (équipe)
    GET/logoutEfface le cookie de session
    GET/Tableau (index.html), protégé
    GET/api/meSession courante (UI)
    GET/api/ventes/metaOptions de champ + couleurs de statut + comptage par statut + writable
    GET/api/ventes/boardToutes les ventes avec lookups résolus (cache 60 s)
    GET/api/ventes/:idFiche d'une vente
    POST/api/ventes/:id{field, value} : PATCH ciblé validé (uniquement les champs éditables)
    GET/commissionsPage "commissions à payer" (commissions.html), protégée
    GET/api/commissionsLignes calculées (agent + affilié) + totaux + liste "à compléter". Filtres ?status=, ?beneficiaire= (alias ?agent=), `?type=agent\affilie`
    GET/api/commissions/agentsAgents 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/metaListe des jalons proposés pour le suivi
    GET/api/dossiers/summaryRésumé par vente : nb d'événements, dernière date, dernier jalon
    GET/api/dossiers/:saleId/eventsTimeline 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/:idSupprime un événement (correction)
    GET/api/avis/metaCanaux de contact possibles + actions valides
    GET/api/avis/board3 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)

    Suivi du dossier (dossiers.js) — coeur opérationnel

    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.

    Module Avis (avis.js) — suivi qualitatif des demandes d'avis client

    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.

  • - Éligibilité : une vente devient éligible dès que 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).

  • - Stockage 100% LOCAL (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.

  • - Statuts (une seule ligne par vente, écrasée à chaque action) : 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>').

  • - Vue Gary/admin : onglet « Avis » du CRM MOA (3 sections : À demander /
  • En attente de réponse avec badge relance après 10 j sans action / Historique).

    Détail complet UI : apps/moa-crm/MANUAL.md.

  • - Relation avec l'ancien statut 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.

    Auth

    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).

  • - Cookie : moaopssess, HttpOnly, SameSite=Lax, 30 jours. Token : ts.b64url(user).sig (les anciens tokens ts.sig restent acceptés en session equipe).
  • - Mot de passe équipe : env TEAM_PASSWORD. Défaut si absent : moaops2026 (à changer en posant TEAM_PASSWORD dans .env).
  • - Secret de signature : env DASH_SECRET (défaut de dev moa-ops-dev-secret ; poser une vraie valeur en prod).
  • Comptes personnels (data/accounts.json)

  • - Fichier 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.
  • - Enregistrement à la première connexion : un compte pré-créé a 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).
  • - Compte pré-créé : matt.bouthors@gmail.com (label « Matt »), en attente au 2026-08-24. L'email « matt.bouthors » sans domaine est normalisé vers @gmail.com.
  • - Reset d'un mot de passe : remettre "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": "..."}.
  • - Le fichier se recrée (seed = Matt en attente) s'il est absent ; il n'écrase jamais un fichier existant au redémarrage.
  • Secrets

    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).
  • - Comptes personnels : pas dans .env, dans data/accounts.json (hash scrypt uniquement, jamais de clair).
  • Pièges

  • - Le port 8338 a été retenu car 8336 et 8337 étaient occupés lors du build. Vérifier avec ss -ltnp | grep 8338 avant de lancer.
  • - Airtable affiche "Date reception commission" en J/M/AAAA mais l'API REST attend et renvoie AAAA-MM-JJ : l'app gère la conversion à l'affichage.
  • - Les montants (Prix payé, Commission) sont des nombres bruts côté Airtable ; l'affichage ajoute le symbole selon la devise de la ligne ($ pour USD, ₲ pour PYG).
  • - Écriture bloquée = ce n'est PAS un bug de l'app, c'est le scope du token d'écriture (voir Secrets).
  • CHANGELOG

  • - 2026-08-24 (rapatriement dans le CRM + fermeture publique) : l'UI de suivi des ventes est rapatriée dans le CRM MOA (onglets Ventes MOA + Commissions, accès admin + Gary). 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).
  • - 2026-08-24 : création de l'app (v1). Serveur Express + login équipe (cookie HMAC, mot de passe partagé), routeur 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).
  • - 2026-08-24 (moteur commissions) : ajout du module 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é).
  • - 2026-08-24 (déploiement) : écriture débloquée via un token dédié 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).
  • - 2026-08-24 (exclusion CEO des commissions) : 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é.
  • - 2026-08-24 (regroupement cochera/baulera sous l'appart) : une cochera/baulera comptait comme une vente séparée alors que c'est une annexe du même dossier qu'un appart. 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é.
  • - 2026-08-24 (fix rattachement annexes multi-achats, signalé Matt) : bug — pour un client ayant acheté 2 fois dans le même immeuble (cas Akirov : achat « Akirov + FGA » et achat « Akirov + SIAPE » dans Aether), 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 »).
  • - 2026-08-24 (suivi d'avancement des dossiers) : ajout du coeur opérationnel demandé par Matt : faire avancer chaque dossier étape par étape + journaliser des événements datés. Nouveau module 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é.
  • - 2026-08-24 (commissions des affiliés) : ajout du calcul et du paiement des commissions d'AFFILIÉ (Ladislas), qui existaient dans Airtable ("Paiement Agents") mais pas dans le dashboard. Modèle revu : une vente affiliée externe génère DEUX lignes de commission à 33% (l'agent qui a fait la vente ET l'affilié), payées/suivies séparément. Backend 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é.
  • - 2026-08-24 (exclusion MOA Realestate + nom du client) : (1) 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é.
  • - 2026-08-24 (login personnel par email) : ajout d'une 2e voie de connexion à côté du mot de passe d'équipe. 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é.
  • - 2026-08-24 (dossier client éditable) : les infos client portées par la fiche de vente deviennent éditables dans le modal (section « Dossier client »), en plus du cycle de vie. 7 nouveaux champs éditables dans 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é.
  • - 2026-08-26 : Ajout manuel d'annexe (cochera / baulera) a un dossier. Nouvelle route 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).
  • - 2026-08-26 (v2) : Annexe : numero + prix obligatoires par item. 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.
  • - 2026-08-28 : Creation manuelle d'une vente (endpoints). Ajout de 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.
  • - 2026-08-31 : 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.
  • - 2026-09-02 : Nouveau module 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.