Manuel d'instructions de cette app. À LIRE avant toute intervention, et à METTRE À JOUR après chaque modification (ajouter une ligne au CHANGELOG en bas).
Dernière génération/MAJ : 2026-09-11 · Statut : à jour
backend/data/bank.json, saisis à la main via POST /api/bank). Aucune écriture vers Airtable / Drive / catalog.db./root/workspace/apps/cockpitindex.html, vanilla JS)cockpit.service (sudo systemctl restart cockpit.service)sudo systemctl restart cockpit.service. Backend (backend/*.py) => restart requis. Frontend (frontend/index.html) => servi no-cache, PAS de restart (recharger la page).backend/app.py — serveur HTTP. Sert le frontend + endpoints JSON : /api/moa, /api/rp (acceptent ?period=<key>[&from&to], cache par (endpoint, période) 5 min), /api/bank (GET/POST). Voir §5.backend/periods.py — résolution des périodes (resolve(key,from,to) → {key,label,start,end} ISO) + contains(period,date) + months_overlapping. Le filtrage compare des chaînes ISO.backend/bank.py — solde bancaire + prévision trésorerie. Store data/bank.json {entity:{balance:number|null, currency, extras:[{label,amount}], updated}} (MOA=USD, RP=PYG). forecast(entity) = solde − obligations. Obligations (MAJ 24/08/2026, anti-double-comptage) : les salaires ne sont PAS déduits (payés en fin de mois) ; les autres charges fixes (loyer, IPS, services) sont déduites AU PRORATA des jours restants dans le mois (_prorata_reste_mois() = (jours_mois − jour + 1) / jours_mois, jour courant compté comme à venir) ; les commissions dues aux agents (MOA seul, via moa_kpis.pending_agent_commissions() = somme du champ "Commission restante " de Paiement Agents, cache 5 min ; com_agents_dues) et les extras manuels sont déduits en plein. Raison : au fil du mois Matt paie ses charges (le solde baisse déjà), donc déduire le forfait mensuel entier double-comptait (le 30, ça reredéduisait tout). Champs exposés : salaires (info, salaires_deduits:false), autres_charges_mois (forfait complet), autres_charges (part proratisée réellement déduite), prorata_frac, jours_restants, jours_mois. state() renvoie les 2 entités. Migre l'ancien bank_balance.json (texte libre) au 1er chargement.backend/moa_kpis.py — KPI MOA (Airtable base "Moa" appSVJQULMJNXy9Hl). compute(period) → {period, scoped, snapshot}. scoped (dépend de la période) : ca_vendu, nb_ventes, com_tombees (via Date reception commission), sources (Affilié), promoteurs, ladislas (leads Ladislas MOA/immo depuis Odoo, cache data/moa_ladislas.json rafraîchi par moa_ladislas_sync.py ; PAS les leads Ladislas résidence de Younes qui restent côté RP). snapshot : stock (catalog.db), commissions, par_type/par_affilie, leaderboard, finance + finance_extra (point d'équilibre avec/sans salaires, poids salaires, total encaissé/dépensé, ratio). Records Airtable cachés 5 min (_raw()), les périodes se tranchent en mémoire.backend/moa_finance.py — Finances MOA (voir §4). Combine charges (Gastos Fijos Moa 2026.xlsx) + revenus (data/moa_ingresos.json).backend/moa_ingresos_sync.py — agrège les factures de commission MOA (dossier Drive INGRESOS) en CA mensuel USD → data/moa_ingresos.json. Lancé par cron (voir §6), pas en live.backend/rp_kpis.py — compute(period) même contrat. scoped : nb_clients (Clientes créés période), ca_estime (mix produits EUR), sources_clients, crm (funnel Younes rp-crm.db : pipeline courant + nouveaux_periode/gagnes_periode/closing). snapshot : produits (mix cumul), etapes, finance + finance_extra. backend/rp_finance.py = finance RP (onglet "Gastos Fijos" de Gastos y Rentabilidad Resident 2026.xlsx, USD + rate_pyg_usd).backend/meta_ads_sync.py — sync dépenses pub Meta (cron, lecture seule). Tire les dépenses du Moa Ad Account (959850966689586) via l'API REST Windsor.ai (connecteur facebook, clé WINDSOR_API_KEY dans .env) + les leads Meta russes depuis Odoo (Vsevolod, user_id 22, préfixe "RU Lead") pour le CAC. Écrit data/meta_ads.json (rows par campagne/jour + ru_leads_by_day). Windsor plafonne l'historique à ~90 j.backend/series.py — séries temporelles mois-par-mois / semaine-par-semaine par catégorie (leads, pub, vente/clients, finances) pour MOA et RP. compute(entity, cat, gran, length) réutilise les loaders existants (moa_kpis._raw, meta_ads._load, moa_finance.finance, rp_kpis._raw, rp_finance.finance), bucketise par mois (défaut 12) ou semaine ISO (défaut 13) et renvoie {buckets, metrics, rows (buckets×metrics), totals, table?, note?}. Métriques money portent cur (USD/EUR) ; CPC/CAC sont fine (2 décimales) + agg:"avg" (total = moyenne, pas somme). Les finances sont forcées en maille mensuelle (compta). Lecture seule.backend/meta_ads.py — KPI pub Meta scopés par période (pur calcul sur le cache, comme les autres modules). compute(period) → {updated, currency, stale, range, scoped:{spend, impressions, clicks, cpc, ctr, jours, campagnes[], serie[], ru:{spend, leads, cac}}}. CAC russe = dépense campagne RU FORM ÷ leads Meta russes de la période.backend/data/meta_ads.json — cache dépenses pub (généré par le sync, ne pas éditer à la main).backend/airtable.py — mini client Airtable (PAT dans .env).backend/data/moa_ingresos.json — cache CA mensuel MOA (généré, ne pas éditer à la main).backend/data/bank.json — solde bancaire numérique + dépenses à venir (MOA USD / RP PYG), écrit par POST /api/bank. (Ancien bank_balance.json texte libre = déprécié, migré automatiquement.)frontend/index.html — tout le rendu. State {tab, period, from, to, cat, gran, win}. Onglets du haut = entités (MOA / RP / Comparaison / Rapports). Sur MOA et RP : barre latérale d'icônes (CATS/ICONS, SVG sans texte) = catégories. render()→renderEntity() construit la nav + dispatche : cat==='overview' → renderOverview() (ancienne vue KPI+trésorerie, barre de période visible) ; sinon renderSeries(entity, cat) (vue temporelle : toggle Mois/Semaine + sélecteur de fenêtre 6/12/18 mois ou 8/13/26 sem., tuiles résumé, graphe à barres de la métrique principale, tableau complet buckets×métriques + ligne total, ventilation optionnelle). syncChrome() masque la barre de période hors Vue d'ensemble. cashCard() = carte trésorerie éditable (POST /api/bank). Servi no-cache.appSVJQULMJNXy9Hl (tables Unités vendues, Paiement Agents, Agents Immobilier) et base RP.moa-catalog/catalog.db (SQLite, lecture seule) pour le stock d'unités.moarealestate.sa@gmail.com, dossier "8. CONTABILIDAD") :Gastos Fijos Moa 2026.xlsx (id 18wKwPKZRwOGuZwyooX3Eqfaa1Gj74aAv), onglet Gastos Fijos y Variables. Détail mensuel charges fixes + variables en PYG, avec Sub Totales USD par mois (taux du mois). moa_finance télécharge en direct (get_media, cache 30 min), somme fixes+variables et reconstruit la répartition par poste en USD via taux_mois = SubTotal PYG / SubTotal USD.INGRESOS (id 1ZtVCu4RICU_B5EG5BJAIG6V6a1TDgY4R), factures électroniques e-kuatia (une = une commission de vente). moa_ingresos_sync.py lit la couche texte des PDF (pdfplumber, pas d'OCR), extrait Fecha/Moneda/TOTAL DE LA OPERACIÓN, convertit en USD, exclut les ANULADAS, compte les Notas de crédito en négatif, écrit data/moa_ingresos.json.moa_finance.finance() renvoie : mois[], ca_usd[], charges_usd[], resultat_usd[], rate_pyg_usd[], dernier{mois,ca_usd,charges_usd,resultat_usd,marge_pct,postes{}}, totaux{}. Mois "réels" = ceux qui ont un CA facturé (les charges vont jusqu'à décembre en projection, ignorées tant qu'il n'y a pas de CA).Gastos y Rentabilidad Resident 2026.xlsx onglet Gastos Fijos (voir memory rp-compta-drive).GET /api/moa?period=<key>[&from&to] — KPI MOA {period, scoped, snapshot}. period ∈ week|month|30d|prev_month|prev_week|7d|90d|quarter|ytd|all|custom (custom exige from/to ISO). Cache par (endpoint, période) 5 min.GET /api/rp?period=<key>[&from&to] — idem RP.GET /api/bank — solde + prévision trésorerie des 2 entités : {moa:{balance,currency,salaires,autres_charges,extras,obligations,projection,negatif,...}, rp:{...}}.GET /api/adspend?period=<key>[&from&to] — dépenses pub Meta (Moa Ad Account) scopées (défaut 30d). {updated, currency, stale, range, scoped:{spend, impressions, clicks, cpc, ctr, campagnes[], serie[], ru:{spend, leads, cac}}}. Cache 5 min. Onglet MOA, carte "Publicité Meta". Source = cache meta_ads.json.GET /api/series?entity=<moa|rp>&cat=<leads|pub|vente|clients|finances>&gran=<month|week>&length=<n> — série temporelle d'une catégorie (voir series.py). {entity, category, gran, buckets[], metrics[], rows[], totals[], table?, note?}. Cache 5 min par (entity, cat, gran, length). Alimente la vue « onglet catégorie » de la barre latérale MOA/RP. pub = MOA seulement ; clients = RP ; finances toujours mensuel.GET /api/finance?period=<key>[&from&to] — rapport financier combiné MOA + RP + comparaison par ligne de dépense (finance_report.py), scopé par période (défaut all). {moa:{totaux,mois,ca_usd,charges_usd,resultat_usd,postes_totaux,postes_mensuels}, rp:{...charges only...}, comparaison:[{ligne,moa_usd,rp_usd,total_usd,moa_pct,rp_pct,commun}], totaux}. Tout en USD (RP converti du ₲ au taux mensuel). Cache 5 min. Onglet "Rapports financiers".POST /api/bank — body {"entity":"moa"|"rp", "balance"?:number|null, "extras"?:[{label,amount}]}. Écrit data/bank.json, renvoie l'état complet (2 entités). Pas de cache.GET /api/rp_affiliates — commissions affiliés RP (Ladislas 25%), rp_affiliates.py. {affilie, rate_pct, items:[{caso,personnes,pack,base_usd,commission_usd,payable,deposited,reliquat_client_usd,deja_paye,raison_non_payable}], a_payer_usd, en_attente_usd, deja_paye_usd, eur_usd, pyg_usd}. Cache 5 min. Onglet RP, section "Commissions affiliés". Le a_payer_usd est déduit du disponible RP dans bank.py (converti en ₲). Voir Pièges.GET / et statique — sert frontend/./api/balance texte libre, remplacé par /api/bank.)/root/aios/.env (lu par backend/airtable.py). Base "Moa" appSVJQULMJNXy9Hl.lib/google_workspace.py. MOA finances = compte moarealestate.sa@gmail.com ; RP finances = compte par défaut. Les fichiers/dossiers Drive sont en LECTURE SEULE (get_media uniquement, jamais d'écriture).20 5 * * * → moa_ingresos_sync.py (agrège les factures INGRESOS chaque matin 05:20, log dans backend/data/ingresos_sync.log). 40 5 * * * → meta_ads_sync.py (dépenses pub Meta via Windsor + leads RU Odoo, log backend/data/meta_ads_sync.log). Les charges MOA et toute la partie RP se rafraîchissent en live (pas de cron).WINDSOR_API_KEY dans /root/aios/.env. Le connecteur Meta Ads de Windsor tourne sur SA propre app validée, ce qui contourne l'app Meta "Leads Pub" de MOA (restreinte côté Meta = token direct mort). Plan Windsor gratuit = 1 source de données : c'est Facebook Ads (Moa Ad Account) qui l'occupe ; ne pas y rebrancher Instagram sinon la source pub saute. La clé s'obtient dans Windsor → onglet "Preview and Destination" → destination API (elle est dans l'URL connectors.windsor.ai/all?api_key=...)..env / lib/google_workspace.Gastos Fijos Moa 2026.xlsx a des onglets Rentabilidad General et AV4 (ratios) qui contiennent encore les chiffres 2025 de Resident (template copié, non mis à jour pour MOA) : ne pas s'en servir. Seul l'onglet Gastos Fijos y Variables est fiable pour MOA.Gastos Fijos y Variables, toutes les cellules de mois sont en PYG même quand la colonne Moneda indique USD (le label = devise du contrat, le montant est normalisé en PYG). La conversion USD passe par la ligne Sub Totales USD (taux du mois).moa_finance ne garde que les mois avec CA facturé pour éviter d'afficher des projections comme du réel.Armado de EEFF general 2026.xlsx, onglets Moa MM.2026) sont figés à mars 2026 : on ne les utilise plus pour le live (remplacés par Gastos Fijos + INGRESOS). Janvier n'a aucune commission (normal).moa_ingresos.json.http://127.0.0.1:8342. (Les endpoints /api/* exigent en plus le cookie de session ck_session : générable en local via python3 -c "import auth; print(auth.make_token(auth.primary_email()))" puis -H "Cookie: ck_session=<tok>" pour tester au curl.)/api/rp, /api/moa, /api/mp) : rp_finance télécharge le xlsx compta depuis Drive (≈6,5 s) + Airtable (≈3,4 s) + CRM. Un thread de préchauffage (_prewarm_loop, cf. CHANGELOG 2026-09-11) garde le cache chaud (month+week) pour éviter que ces 16 s atterrissent sur le navigateur ("Failed to fetch"). Si on ajoute un nouvel endpoint KPI lourd, l'ajouter à _computers (il sera préchauffé automatiquement). Ne pas baisser _PREWARM_EVERY au-dessus de TTL (300 s) sinon fenêtre froide.range est indiqué dans la carte). (2) Les leads Meta russes (Odoo Vsevolod) ont un create_date qui reflète souvent la date d'import groupé, pas la vraie date du lead : le CAC est donc fiable au niveau période (ex. 30 j) mais pas jour par jour. (3) La carte n'affiche pas de ROAS volontairement : aucune donnée ne relie encore une commission encaissée à un lead pub (cycle de vente long), un ROAS serait inventé. (4) Le CAC "leads russes" ne couvre que le funnel RU FORM ↔ Vsevolod ; les campagnes Espagne / Recruit Agent / Properties test ne sont pas rattachées à un compteur de leads (dépense affichée, pas de CAC dédié). (5) Si la carte dit "donnée à rafraîchir", le cache a plus de 30 h : relancer python3 backend/meta_ads_sync.py.rp_finance.finance() télécharge+parse le xlsx compta depuis Drive ≈ 6,5 s + Airtable _raw() ≈ 3,4 s + base CRM), et la clé de cache contient la date de fin de période — pour "Ce mois" end = aujourd'hui, donc la clé change chaque jour : la 1re ouverture du jour tombait toujours à froid, et le fetch() navigateur lâchait ("Failed to fetch", erreur réseau JS, pas un HTTP≠200 ; nginx proxy_read_timeout=120 s et Cloudflare ~100 s ne coupaient pas — c'est le calcul long + re-render/onglets concurrents qui abortait). backend/app.py : (1) helper _kpi_cached(path, period) avec verrou par clé (_key_locks + _cache_lock) → un seul calcul par clé même sous requêtes parallèles (anti thundering-herd, plusieurs onglets ne lancent plus 3× le calcul de 16 s) ; le handler GET des endpoints KPI l'utilise au lieu du bloc cache inline. (2) Thread de préchauffage _prewarm_loop (démarré au boot, daemon=True) qui recalcule month+week pour moa/rp/mp toutes les 240 s (< TTL 300 s) → le cache ne repasse jamais froid, y compris au changement de jour. Coût réel modéré : les modules ont leurs propres caches internes (Airtable 5 min, finance Drive 30 min), donc la plupart des passes de prewarm re-touchent des caches chauds. Aucune modif de calcul métier ni d'écriture. Vérifié : redémarrage OK, /api/rp?period=month sert en ~25 ms une fois chaud (vs 16 s avant), week 7 ms, aucune erreur prewarm en journald. Restart cockpit effectué (backend)..catbtn (barre de gauche MOA/RP) affichaient l'icône seule (nom en infobulle only). Ajout d'un <span class="catlbl"> avec le nom (c.t) à côté de l'icône ; CSS : .catnav largeur fixe 172px, .catbtn passe de carré 46×46 à une ligne icône+texte (gap, padding, font 13px 600, text-align left), nouvelle classe .catlbl (ellipsis), media-query ≤640px qui masque les libellés (icône seule sur mobile, infobulle conservée). MOA : Vue d'ensemble / Leads / Publicité / Ventes / Finances ; RP : Vue d'ensemble / Leads / Clients / Finances. Frontend statique servi depuis le disque : simple rafraîchissement de page, pas de restart.moa_kpis.py : pending_agent_commissions() (carte trésorerie via bank.py) et le KPI a_payer (dashboard MOA) filtrent désormais les lignes Paiement Agents via le lien Vente → ne comptent Commission restante que si la vente liée est « Commission recue ». Helpers _received_sale_ids() + _line_sale_received() (+ constante COMMISSION_RECUE). Impact : 808 Venire (Loidts, vente encore « Commission en attente ») exclu ; pending agents 37 282 → 35 559 $, projection trésorerie −1 053 → +670 $. Vérifié : seul le 808 est exclu, 0 ligne sans lien Vente. Partie B (création auto de la ligne agent) : déjà en place, aucune modif faite : l'automatisation Airtable « Import des unites deja vendues pour commission » (wflAeDZ6EtGth8JzE, table Unités vendues) se déclenche sur Statut = « Commission recue » (+ Agent renseigné, Commission > 0, « Paiement agent créé » = false), crée la ligne dans Paiement Agents (lien Vente + Agent) et coche « Paiement agent créé ». Le 808 avait une ligne orpheline car sa vente avait été passée à « Commission recue » par erreur puis remise en attente ; on la laisse (la case cochée empêche un doublon quand la vente sera vraiment encaissée, et le filtre la fait compter à ce moment-là). Restart cockpit effectué.Commission saisi affichaient "à estimer" et n'étaient pas comptés. revenue_forecast.py : ajout d'une table de taux par bâtiment COMMISSION_RATES (Veralta 4,5 / Venire 5,5 / Hassler 5,5 / Aether 4,4 / Invicta 5,5 ; TVA incl., donnés par Matt, cf. mémoire moa-commission-rates-promoteurs), estimation = Prix payé (converti en USD au taux moa-catalog/rate.json si Devise=PYG) × taux du bâtiment (clé = sous-chaîne du Nom de l'achat). moa_pipeline() renvoie désormais estime/taux par item + total_estime_usd, total_avec_estime_usd, sans_taux. Frontend forecastMOA : ligne "à estimer" affiche "≈ X $ (est. Y%)", 2e KPI "Pipeline avec estimations", note explicative. Vérifié : réel 3 446 $, estimé 32 714 $, total 36 160 $, 0 dossier sans taux. Rien n'est écrit dans Airtable : le vrai montant reste saisi à la main quand la commission tombe (les taux servent uniquement à l'estimation indicative). NB : le seul dossier Venire réalisé (808) donne un taux implicite de 3,51 % vs 5,5 % théorique ; sans impact sur les estimations actuelles (aucun Venire "à estimer"), à réconcilier plus tard. Restart cockpit effectué.com_agents_compta), déjà sorties donc hors projection. bank.py : obligations = fixe_mensuel + com_agents_dues + extras_total (au lieu de com_agents_dues + extras_total). Frontend : la carte déduit désormais explicitement les charges fixes ; la ligne com_agents_compta est affichée en muted comme "exclu : déjà sorti". Vérifié /api/bank MOA : obligations 46 053 (= 50 567 initial − 4 514 commissions du mois dernier), projection −1 053 (solde 45 000). Restart cockpit effectué. (Remplace la logique de l'entrée précédente qui, à tort, ne déduisait pas les charges fixes.)charges_tot + extras + com_agents_dues, soit des charges déjà réglées ET un doublon des commissions → projection faussement négative (-5 567 $). Fix bank.py forecast() : (1) le poste "Commissions agents" (lu dans dernier.postes, USD, converti au taux du mois pour RP) est retranché de autres_charges → 4 082 $ de vraies charges fixes (loyer, IPS, services), et exposé à part via le nouveau champ com_agents_compta ; nouveau champ fixe_mensuel = salaires + autres_charges. (2) obligations = com_agents_dues + extras_total uniquement (ce qui reste réellement à payer), les charges fixes du mois ne réduisent plus la projection (considérées réglées). Frontend index.html : carte trésorerie restructurée en 2 blocs (Charges fixes mensuelles de référence, avec commissions réglées affichées en muted hors total ; Reste à payer = commissions dues + dépenses manuelles), classes CSS .subhead/.muted. Résultat vérifié sur /api/bank (MOA, juillet 2026) : salaires 4 689, autres charges 4 082, commissions réglées 4 514 (séparées), commissions dues 37 282, obligations 37 282, projection +7 718 (solde 45 000) au lieu de -5 567. Règle métier posée par Matt : les commissions agents ne doivent JAMAIS apparaître comme charge fixe, toujours séparées. Restart cockpit effectué (backend).moa_kpis.py : helper _pyg_per_usd() (taux du jour moa-catalog/rate.json, cache 1h, repli 6000) + _usd(fields, field) qui convertit Prix payé / Commission selon la Devise (vide=USD, PYG=÷taux). Appliqué à ca_vendu, panier, sources, promoteurs, commissions tombées/reçues/en attente et au détail (chaque ligne porte devise). Vérifié : volume 'Tout' passe de ~549 M$ à ~7,64 M$, ligne Veralta = 91 699 $ (549 209 010 ₲). Restart cockpit requis.toggleDetail(key) (frontend) referme tous les autres .detailpanel + retire .open des autres .detailbtn avant d'ouvrir le panneau cliqué. Cliquer un 2e « Voir le détail » ferme le 1er. Frontend statique, pas de restart.app.py : (1) GET /health (status + âge en secondes des caches meta_ads.json/moa_ingresos.json/moa_ladislas.json + nombre d'entrées de cache) ; (2) log applicatif minimal sur stderr/journald via log_request (méthode, path, code HTTP, durée ms) ; (3) validation des périodes custom _period_or_400 (ISO obligatoire, from<=to, sinon 400 au lieu d'un 500 non capté) sur /api/moa, /api/rp, /api/adspend, /api/finance ; (4) length de /api/series borné à MAX_SERIES_LEN=104 ; (5) messages d'erreur 500 rendus génériques côté client ("internal error"), le détail (repr(e)) étant loggé serveur (plus de chemins/exceptions renvoyés au navigateur). series.py : helper _cache_status + champ sources dans chaque réponse (état ok/stale/missing + horodatage par source : leads Ladislas, Meta, compta MOA/RP, CRM Younes) pour distinguer un vrai 0 d'une source tombée. Frontend index.html : (a) en-tête "Données · <source> au JJ/MM HH:MM" basé sur les vrais updated/ingresos_updated/meta_ads.updated (fini l'heure du navigateur trompeuse) ; (b) renderOverview en Promise.allSettled → blocs indépendants (trésorerie/pub/pipeline s'affichent même si Airtable tombe) + bandeau d'erreur réel ; (c) bandeau source (srcBanners) quand une source est manquante/périmée ; (d) graphe couleur par entité (RP en rouge) + graphe ligne (lineChart) pour les métriques pct/ratio (marge, CTR, CAC) exploitant kind de series.py, avec un sélecteur de métrique à tracer ; (e) skeletons + min-height sur #view/#catcontent (anti-CLS) ; (f) onglets du haut convertis en <button role="tab"> (accessibles clavier) ; (g) contraste labels de graphe (>=11px, --sub2) ; (h) bouton refresh + auto-refresh doux 5 min (aligné TTL) ; (i) money() affiche le code ISO (USD/EUR/PYG) + zebra striping sur .srtable et tables de comparaison. Non fait (hors périmètre / délégué) : traduction ES/i18n (exclu), rotation de secret (aucune : secrets déjà en .env, aucun secret en dur trouvé dans le code), en-têtes de sécurité HTTP nginx (item #13, à faire par l'orchestrateur au niveau vhost). Restart cockpit effectué. Vérifié : python3 -c ast.parse (app.py + series.py), /health OK, /api/moa?period=custom invalide → 400, length=9999 → borné à 104 buckets, log journald (méthode/path/code/durée), Playwright (en-tête horodaté réel, barres vertes MOA / rouges RP, graphe ligne sur CTR, zebra, tabs = <button>, bandeaux source missing/stale, sélecteur de métrique).series.py (compute(entity, cat, gran, length)) : bucketise par mois (défaut 12) ou semaine ISO (défaut 13) en réutilisant les loaders existants, renvoie {buckets, metrics, rows (buckets×metrics), totals, table?, note?}. Métriques : MOA Leads (Ladislas + Meta RU), MOA Pub (dépense/clics/CPC/CTR/leads RU/CAC + par campagne), MOA Ventes (volume/nb/commissions reçues), RP Leads (nouveaux/gagnés + par source), RP Clients (nouveaux + CA estimé €). Finances forcées en maille mensuelle (CA/charges/résultat/marge pour MOA ; charges pour RP). Route GET /api/series?entity=&cat=&gran=&length= (cache 5 min). Frontend : state +{cat, gran, win}, renderEntity/renderOverview/renderSeries, tuiles résumé (total/moyenne/meilleure période/tendance), graphe à barres de la métrique principale, tableau détaillé buckets×métriques + ligne total, toggle Mois/Semaine + sélecteur de fenêtre. CPC/CAC en 2 décimales et agrégés en moyenne (pas somme). Frontend servi no-cache (recharger la page) ; backend = restart requis. Vérifié via /api/series (MOA leads/pub/vente/finances, RP leads/clients/finances) + Playwright (Pub mensuel, Finances mensuel, RP Clients hebdo).meta_ads_sync.py (cron 40 5) tire les dépenses du Moa Ad Account via l'API REST Windsor.ai (clé WINDSOR_API_KEY dans .env ; contourne l'app Meta "Leads Pub" de MOA restée restreinte côté Meta, token direct mort) + les leads Meta russes d'Odoo (Vsevolod user_id 22) → data/meta_ads.json. meta_ads.compute(period) scope spend/impressions/clics/CPC/CTR + détail par campagne + série quotidienne + funnel RU (CAC = dépense RU FORM ÷ leads russes). Route GET /api/adspend?period= (cache 5 min). Frontend : fetchAdspend() + adSpendCard() (4 KPI dont dépense avec sparkline SVG, tableau par campagne, CAC russe) inséré dans renderMOA après le prévisionnel ; render() fetch la pub en parallèle pour l'onglet MOA. Montants fins (CPC/CAC/dépense) en 2 décimales ; CAC masqué (—) si la campagne RU ne tourne pas sur la période. ROAS volontairement absent (pas de rattachement commission↔lead pub, un ROAS serait inventé). Restart cockpit requis (backend). Vérifié : /api/adspend (30d = 389,93 $ / CPC 0,12 / CTR 2,7 % / CAC RU 4,44 $ sur 55 leads) + Playwright (carte rendue, sparkline montrant la chute de dépense depuis le 30/07, détail 3 campagnes). Devise confirmée USD. Limites connues : Windsor plafonne à ~90 j ; create_date des leads RU = date d'import groupé (CAC fiable par période, pas par jour) ; CAC couvre le seul funnel RU. Pièges détaillés en §7.rp-crm.db source='ladislas' = les leads Ladislas résidence de Younes (RP), qui s'affichaient donc à tort dans MOA. Correction : (1) les leads Ladislas MOA/immo vivent dans Odoo (tag "Relance Ladislas" id 6, 538 leads). Nouveau backend/moa_ladislas_sync.py (lecture seule Odoo) : lit ces leads, parse la vraie date depuis "Date lead:" de la description (le DERNIER token est toujours EU %d/%m/%Y ; garde-fou dates futures), écrit data/moa_ladislas.json. moa_kpis.ladislas() lit ce cache au lieu de rp-crm (+ import os ajouté). Résultat : prev_month 6, ytd 183, all 538, réparti Fév 2024 → 2026 (fini les 172 RP dans MOA). (2) Import auto Gmail MOA → Odoo : moa-automations/moa_ladislas_import.py lit moarealestate.sa@gmail.com (WPForms thewanderinginvestor.com), dédup email/tel contre tout Odoo, garde anti-lead-de-test, crée le lead manquant dans le pool MOA (user_id=2, stage New Lead, tag 6, date EU dans la description). Recherche préalable (rigoureuse, comptage sender-independent par subject:"Contact Matt to invest", paginé) : 230 mails, 115 leads uniques, 115/115 déjà dans Odoo, 0 manquant ; le formulaire a été livré par 2 expéditeurs (info@wp.thewanderinginvestor.com + un autre) mais s'arrête en avril 2026 (les mails de mai sont des réponses de fil, pas des formulaires). L'importeur query = le sujet (pas un expéditeur unique) pour rester robuste si Ladislas rechange d'adresse. → 0 vrai lead à créer (le seul "nouveau" de la 1re passe était un test). Crons (Asunción) : 10 6 sync, 20 7 import --since 30. Restart cockpit requis (backend). RP inchangé (les leads de Younes restent dans la partie RP).rp-crm.db, source='ladislas') avaient created_at = date d'import groupé (172 en juillet), faussant le "mois par mois". Fix : nouvelle colonne received_at dans prospects (rp-crm), backfillée depuis la vraie date de l'email d'origine récupérée via le gmail_id déjà stocké dans raw (boîte resident.paraguay@gmail.com, messages().get(format=metadata) → internalDate), 179/179 résolus, 0 erreur. moa_kpis.ladislas() compte désormais par COALESCE(received_at, created_at). Résultat : juillet 172→31, répartition réelle Fév 24 / Mars 39 / Avr 31 / Mai 24 / Juin 24 / Juil 31 / Août 6. Restart cockpit requis (backend). Limite / suivi : les FUTURS leads Ladislas retombent sur created_at tant que l'importer (rp-crm/sources/ladislas_import.py + /api/ingest) ne pousse pas received_at ; écart négligeable (import quotidien, poller /30min). Script de backfill one-shot : /tmp/ladislas_dates_commit.py (rejouable, idempotent sur la colonne).moa_kpis.pending_agent_commissions() (nouveau, cache 5 min) = somme du champ "Commission restante " de Paiement Agents ; piège : all_records renvoie des records bruts {id,createdTime,fields}, il faut lire r["fields"] (comme compute), pas r.get(...) direct. bank.forecast() ajoute com_agents_dues aux obligations pour MOA uniquement (RP=0), exposé dans /api/bank. Effet réel : projection MOA passe de +88 217 à -457 $ (88 674 $ dus aux agents, cohérent avec le KPI "Reste à payer aux agents"). Frontend cashCard : nouvelle ligne "Commissions à payer aux agents" (affichée seulement si >0) + hint mis à jour. (2) Les compteurs "Volume vendu" (ventes) et "Commissions tombées" (commissions reçues) portent désormais dans leur case un bouton "Voir le détail" (kpi() accepte un 5e arg extra) ; le clic (toggleDetail) ouvre le panneau .detailpanel (réutilise venteRows/comRows) sous la ligne des KPI, remplaçant les anciens <details class="drill"> en .cols. Restart cockpit requis (backend). Vérifié via /api/bank (MOA com_agents_dues 88 674 / obligations 101 959 / projection -457 ; RP 0).revenue_forecast.py + route GET /api/forecast (non scopée période, mois courant, cache TTL). MOA = commissions des dossiers en cours dont la commission n'est pas encore reçue (statuts "Commission en attente" / "Contrat en Attente" sur Unités vendues) : somme des commissions renseignées + compte des dossiers sans montant (exclus du total). RP = croisement agenda × Airtable : (1) events "Residence T ..." de l'agenda Google resident.paraguay@gmail.com du mois (le QUAND/QUI, via googleapiclient calendar v3 + creds lib/google_workspace compte matt.bouthors) ; (2) montant = soldes encore dus de la table Pagos (Estado=Pendiente, types Saldo/Cuotas/Depósito), rattachés par nom de dossier (Casos "Nombre del caso", match par tokens de nom de famille). ⚠️ Les champs monétaires de la table Casos (Monto total / Depósito pagado / Saldo pendiente) sont VIDES : la seule source d'argent fiable RP est la table Pagos. Le champ Clientes "Fecha de depósito de residencia temporaria" est historique (rempli après coup), d'où l'usage de l'agenda pour le prévisionnel. Multi-devise (EUR majoritaire + USD) : total converti en USD via open.er-api.com (EUR base, repli 1.08). Expose aussi autres_soldes_usd (soldes Pendiente non rattachés à un dépôt programmé ce mois = pipeline). Frontend : fetchForecast() + cartes forecastMOA() (onglet MOA) et forecastRP() (onglet RP), chacune avec KPI + drilldown <details>. Restart cockpit requis (backend). Vérifié via /api/forecast (MOA 3 446 $ / 7 dossiers dont 6 sans montant ; RP 3 923 $ / 2 dépôts : Mingori 04/08 1500€, Sidney 11/08 1900€ ; 77 105 $ autres soldes) + Playwright. Limite connue : le match agenda↔dossier repose sur le nom de famille (robuste sur surnames type Mingori/Sidney/Hebras-Garcia) ; un client "Residence T" déjà soldé (Pago completo) compte 0.moa_kpis.compute calcule 3 parts qui somment au total reçu : com_part_affilies (paiements dont le bénéficiaire EST l'apporteur de la vente : nom de l'agent payé contient la source Affilié (from Vente), ex. Ladislas — capture nettement les lignes type=None), com_part_agents (= reversé total payable_p − affiliés = agents + responsables ; les associés ont un payable nul), com_part_entreprise (= com_tombees − payable_p, ce que la boîte garde). + com_pct_* et com_affilies_noms (montant par affilié). Frontend : la KPI card "Reversé aux agents" (trompeuse, agrégeait agents+affiliés) devient "Reste à l'entreprise" (vert, % net), et un nouveau panneau "Où va la commission reçue" affiche une barre empilée agents(orange)/affiliés(bleu)/entreprise(vert) + 3 lignes montant/%. CSS .split/.splitbar/.seg. Renommage interne : l'ancien const split (tables par type/affilié) → splitTables pour éviter la collision. Restart cockpit requis (backend). Vérifié via /api/moa (les 3 parts somment au total, ex. Mois dernier 70 243 $ = agents 23 395 / affiliés 11 321 Ladislas / entreprise 35 526 ; Tout = 74 280 / 34 220 / 211 925) + Playwright.moa_kpis.compute ajoute deux listes à scoped : ventes_detail (les ventes créées dans la période, triées par prix) et com_detail (les commissions reçues dans la période, triées par montant) — attention, deux ensembles distincts (une vente peut être créée un mois et sa commission tomber un autre). Chaque ligne : projet, unité, typologie, client, promoteur, affilié, agent, prix, commission, statut, date. Résolution du nom client via la table Contacts (champ Full name, le champ Nom client de "Unités vendues" ne renvoie que des record IDs) — chargée dans _raw() (clients map). Frontend : deux <details class="drill"> côte à côte sous les KPI cards (helpers venteRows/comRows, CSS .drill). Restart cockpit requis (backend). Vérifié via /api/moa?period=prev_month (6 ventes / 11 commissions) + Playwright (dropdowns ouverts, noms clients résolus : James King, Issa Ayoub, etc.).GET /api/finance?period= : finance_report.report(period) scope les mois via periods.months_overlapping (mois recouverts par la période) ; moa_finance/rp_finance exposent mois_num (+ postes_totaux/postes_mensuels déjà là) ; la barre de période est désormais affichée sur l'onglet Finances (défaut = Tout). (2) Cohérence MOA : moa_kpis.compute déplace par_type/par_affilie/leaderboard du snapshot vers scoped (filtrés par la date de réception de commission de la vente liée, Date reception commission (from Vente) sur Paiement Agents), ajoute com_reversee_agents/com_nette_periode/pct_agents_periode et un résumé finance scopé (CA/charges/résultat des mois de la période). Ne restent en snapshot que les vraies données instantanées/cumul : dette agents (reste à payer/déjà payé/dû total), commissions reçues cumul, stock, rentabilité globale — regroupées frontend sous "Position agents & stock · instantané · hors filtre période". (3) Cohérence RP : rp_kpis ajoute scoped.finance (charges des mois de la période) ; frontend tague "Étape du dossier" instantané, sources clients + charges à la période. Frontend : renderMOA/renderRP/renderFinance relus, chaque bloc porte un tag <span class="tag"> avec le libellé de période ou "instantané/cumul", note explicite quand un mois n'a pas encore de compta (ex. RP en retard d'un mois, mois courant vide). Restart cockpit requis (backend). Vérifié Playwright : MOA "Mois dernier" → répartitions & leaderboard scopés (Ladislas 22 642 $, finance Juillet 59 645 $) ; Finances réagit au sélecteur (Année vs Mois dernier vs cumul). Piège assumé : compta mensuelle → une semaine/mois courant peut être vide (message d'aide affiché), RP souvent en retard d'un mois.finance_report.py (aucun download propre : réutilise moa_finance.finance() et rp_finance.finance() cachés) + route GET /api/finance. Les deux modules finance exposent désormais postes_totaux (cumul USD) et postes_mensuels (ajout additif, _compute des deux). Les libellés de postes des 2 comptas sont alignés sur un référentiel commun (ALIGN : "Eau & électricité"/"Services..." → Services ; "Marketing & pub"/"Publicité..." → Marketing ; "Impôts (IVA)" → Impôts ; les identiques — Loyer, Salaires, IPS, Maintenance, Fournitures — s'alignent d'office). Frontend : onglet fin (barre de période masquée, données mensuelles cumulées), renderFinance() = cartes P&L par entreprise (MOA CA/charges/résultat/marge ; RP charges + moy 6 mois + taux) + tableau comparatif par ligne (MOA $ et % charges, RP $ et % charges, total, barre double MOA/vert vs RP/bleu, lignes "seul" taguées) + évolution mensuelle par société. Tout en USD (RP converti du ₲ au taux mensuel de la compta ; le CA RP n'est pas tenu dans cette compta donc pas de marge RP). CSS .dualbar/.lgd/tr.common. Restart cockpit requis (backend). Vérifié via /api/finance + Playwright (5 sections rendues, 17 lignes de dépense, ex. migrations RP 44 959 $, salaires MOA 18 017 / RP 26 483, loyer MOA 14 977 / RP 5 074).periods.py ; moa_kpis/rp_kpis réécrits en compute(period) → {period, scoped, snapshot}, records Airtable cachés 5 min (_raw()) et tranchés en mémoire par période ; app.py route ?period= avec cache par période. (2) Onglet Comparaison : 2 périodes côte à côte (écart + variation %), sélecteur entité. (3) Trésorerie prévisionnelle (bank.py, remplace le solde texte libre) : solde numérique MOA=USD / RP=PYG, prévision fin de mois = solde − (charges du mois compta, salaires inclus, tirés de moa_finance/rp_finance + dépenses manuelles extras), projection négative en rouge avec alerte. POST /api/bank. Migration auto de l'ancien bank_balance.json. (4) Nouveaux KPI : commissions tombées (par date de réception), répartition commissions par type d'agent + par affilié, point d'équilibre avec/sans salaires, poids des salaires (% du CA), rentabilité globale (total encaissé vs dépensé + ratio + coût/vente), leads Ladislas reçus + conversion (depuis rp-crm.db), intake prospects Younes par période + closing. Restart cockpit requis (backend). Vérifié via curl (moa/rp/bank, périodes + custom) + Playwright (3 onglets, saisie solde, projection négative). Limite connue : suivi des appels entrants Younes pas encore branché (pas de log d'appels dans le CRM) ; finances à maille mensuelle (pas de coût par jour pour les sous-périodes).contenteditable : l'utilisateur clique, tape son solde (texte libre, devise incluse), Entrée ou clic ailleurs enregistre. Persistance : nouvel endpoint POST /api/balance (+ GET) → backend/data/bank_balance.json, un enregistrement {text,updated} par entité. Frontend : helpers fetchBalance/balanceCard/balSave, CSS .kpi.bal (hint ✎, focus ring bleu MOA / rouge RP). Restart cockpit requis (backend, do_POST ajouté). Vérifié via /api/balance (GET/POST) + Playwright (les 2 onglets, saisie + persistance).moa_finance.py lisait Armado de EEFF general 2026.xlsx (figé Fév-Mars). Maintenant : (1) nouveau moa_ingresos_sync.py agrège les factures de commission du dossier Drive INGRESOS (56 factures, pdfplumber, exclut ANULADAS, notas de crédito négatives) → data/moa_ingresos.json (CA mensuel USD) ; câblé au cron 05:20. (2) moa_finance.py réécrit : charges = Gastos Fijos Moa 2026.xlsx onglet Gastos Fijos y Variables (fixes+variables, Sub Total USD, répartition par poste via taux du mois), revenus = le JSON INGRESOS ; combine en CA/charges/résultat/marge par mois. Schéma de sortie inchangé → frontend compatible. (3) Frontend : titre "compta officielle" → "MOA Real Estate S.A. (gestion)", note source + date d'agrégation des factures. Résultat live : CA cumulé 252 697 $, charges 56 305 $, résultat 196 392 $, marge 78 % (Fév-Juil). Restart cockpit requis (backend). Vérifié via /api/moa + Playwright.Affilié sur Clientes était sous-rempli ; vérif croisée avec le fichier officiel de Ladislas "matt sebastien leads.xlsx" reçu par mail + les mails d'accroche RP qui nomment l'affilié → méthode fiable). Règles Matt : seul Ladislas génère une com à suivre = 25% du total encaissé du dossier (acompte + solde), payable quand le client a payé 100% ET a déposé sa résidence ; Guillermo garde l'acompte comme sa com (jamais rien à verser, exclu) ; Meriam en voie de sortie (manuel). Nouveau module rp_affiliates.py (lecture seule Airtable) : regroupe PAR CASO (couples/familles partagent les mêmes Pagos, sinon double comptage), calcule com + statut payable/en attente + raison (client doit encore X, ou pas encore déposé) + déjà payé. Nouvel endpoint GET /api/rp_affiliates (cache 5 min). bank.py : le total a_payer_usd (payable, pas encore payé) est déduit du disponible RP dans la prévision de trésorerie (converti en ₲), exposé sous com_affilies. Frontend renderRP() : nouvelle section "Commissions affiliés · Ladislas" (3 KPI à payer/en attente/déjà payé + table par dossier), juste après la trésorerie. Nouveau champ Airtable créé sur la table Casos (base RP appGzbfkhHiO95jd1) : Comisión afiliado pagada (date, fld2kLOE1pGwoUJvj) = quand la com Ladislas a été versée pour ce dossier (vide = pas payé) ; le module retombe proprement sur "non payé" si le champ manque. Écriture Airtable faite : correction du tag Affilié d'Aurelien Lavergne (Ladislas → Guillermo, c'était un faux positif, mail d'accroche signé Le Guiz). À faire par Matt : marquer les dossiers déjà réglés (anciens dépôts 2024) dans le nouveau champ pour que "à payer" ne montre que les nouveaux (4 dossiers 2026 ≈ 5 100 $ : Guerrida/Marouan, Burt/Elliot, Van der Kelen, Heidbrink). Liste + détail : workspace/clients-ladislas-RP.md. Testé : rp_affiliates.compute() + bank.forecast('rp') OK, import app OK, endpoint route répond, node --check implicite (section rendue). Restart cockpit fait.revenue_forecast._pending_and_casos tokenisait uniquement Nombre del caso, or ces 2 dossiers portent leur nom de famille dans ID del caso (CASE-Olivares avec Nombre VIDE ; CASE-Ferrari avec Nombre=michael) → aucun token en commun avec le titre du RDV → classés inconnu. Fix : nouveaux helpers _caso_toks(cf) (union des tokens de Nombre del caso + ID del caso, préfixe case retiré pour éviter les faux positifs) et _caso_nom(cf) (repli d'affichage sur ID del caso quand Nombre vide), utilisés dans pend et casos. Aucune écriture Airtable (correctif code only). Vérifié sur sept. 2026 : les 2 RDV passent en du avec leur solde Pendiente/Saldo réel (Olivares 2 700 EUR, Ferrari 4 300 EUR), total encaissements prévus 13 128 USD (contre 3 dossiers/~6 128 avant). Reste 1 RDV légitimement sans solde (Basse Gaillet, déjà soldé). Restart cockpit fait.render() (changement d'onglet, de catégorie, de période, refresh manuel, auto-refresh 5 min) vidait le conteneur en skel() (squelettes gris) avant de refetch → écran vide à chaque fois. Fix 100% frontend (index.html, aucun changement backend/API) : nouveau cache d'instantané HTML par vue SNAP (clés ovKey/serKey/promKey/finKey/cmp:* construites à partir de tab+cat+période+granularité+fenêtre). Helpers paintLoading() (affiche l'instantané précédent + bandeau .refbar "Actualisation…" si dispo, sinon squelette), paintError() (échec réseau → garde les données précédentes + bandeau .stalebar orange au lieu d'un écran d'erreur qui effaçait tout), commitSnap() (mémorise le HTML après chaque rendu réussi). Branché sur TOUTES les vues : renderEntity (le shell nav+contenu réaffiche l'instantané), renderOverview (MOA/RP/MP, y compris le mode dégradé Airtable KO), renderSeries, renderPromoteurs, cmpRun (Comparaison), renderFinance. Résultat : au 1er affichage d'une vue → squelette normal ; ensuite tout re-render montre instantanément les dernières données avec une pastille "Actualisation…", remplacées par les fraîches dès qu'elles arrivent. Les handlers inline (drill <details>, cash card) survivent au round-trip innerHTML. Testé : node --check du bloc script OK, index.html servi frais depuis le disque à chaque requête (app.py:178, pas de cache mémoire) → pas de restart nécessaire.mp_finance.py/mp_kpis.py en dispo:False) est remplacé par une vraie intégration, LECTURE SEULE STRICTE (.claude/rules/cerveau-moa-sheet-readonly.md, uniquement spreadsheets().values().get, jamais d'écriture). 4 onglets combinés : data (résas en cours, pas de colonne commission) + data_archive (résas du mois clôturé, colonne commission réelle, taux observé 20% flat, ZÉRO chevauchement d'ID avec data — vérifié) + frais extraordinaires (frais annexes type early check-in/nuit sup/annulation, taux de commission propre par ligne) + facturation (nb ménages/mois/appart × tarif/appart = seul coût actuellement trackée). Commission : taux réel quand connu (data_archive), sinon estimée au taux standard 20% pour les résas pas encore clôturées (source_commission par ligne le précise, se corrige automatiquement une fois la résa archivée). Résultat : CA brut Airbnb, commission MOA Properties, coût ménage, marge (commission − ménage, PAS un résultat net : aucune charge fixe MOA Properties suivie hors ménage), mois par mois (juin 2026 → janvier 2027, activité très jeune) + cumul par appartement (8 appartements, dont OGA 902A absent de data mais présent dans les 3 autres onglets) + détail des 140 réservations/frais. Chiffres au 31/08 : commission cumulée 2 782 $, ménage 810 $, marge 1 972 $. bank.py bénéficie au passage : mp_finance.finance()["dernier"] expose le ménage du dernier mois PASSÉ (pas un mois futur avec une résa isolée, sinon le prévisionnel de trésorerie aurait été faussé) comme "charge" pour _finance_month, donc la prévision de trésorerie MP prend maintenant en compte le ménage à venir, prorata inclus, en plus du solde manuel. Frontend renderMP() réécrit : cartes scopées période (résas/CA brut/commission), trésorerie, table mensuelle (CA→commission→ménage→marge), table par appartement, détail des 140 lignes togglable, bandeau des limites (activité jeune, taux estimé, pas de charges fixes suivies). 4 gaps affichés en clair (mêmes principes que RP/MOA : pas de fausse précision). Testé : ast.parse/node --check OK, mp_kpis.compute()/moa_kpis.compute()/rp_kpis.compute() re-vérifiés sans régression (aucun NaN/Infinity), bank.state() renvoie les 3 entités, bank.forecast('mp') fonctionne (charges ménage août 2026 = 360 $, prorata correct). Restart cockpit fait. MOA : mêmes 6 catégories que RP, adaptées au modèle commission MOA. CA = factures INGRESOS (e-kuatia, déjà utilisé) ; A. Coûts directs = commission payable aux agents reconstruite LIGNE À LIGNE depuis Airtable "Paiement Agents" (par mois de Date reception commission), plus fin que la ligne compta "Commissions agents" (celle-ci EXCLUE de C pour ne pas compter 2x, même logique que "GASTOS MIGRACIONES" côté RP) ; B = poste "Marketing & pub" ; C = Loyer+Eau/élec+Maintenance+Fournitures+Honoraires pro+Seprelad+Bien-être équipe+Mobilier & équipement+Autres ; D = poste Impôts (IVA) ; E = poste "Frais financiers" (directement dispo pour MOA, contrairement à RP où E n'était que partiel) ; Salaires+IPS à part (même logique RP : non ventilables sans décompte d'heures) ; F non isolable (gap identique). Marge brute par vente = Commission reçue − reversé agents/affiliés (part_agence, déjà calculé ailleurs dans moa_kpis.py, réutilisé), sur TOUTES les ventes encaissées (Statut de l'achat = Commission recue, pas juste la période sélectionnée) : 77 ventes, marge moyenne 73,3%, médiane 67%. Différence clé avec RP : un costos_usd=0 ici est une vraie vente 100% agence (pas d'agent externe), PAS un gap de données comme pour RP (donc pas de flag costos_manquants). Backend : nouvelles fonctions _giannina_cascade_moa() + _margen_par_vente() dans moa_kpis.py, exposées sous snapshot.finance.giannina (copie de moa_finance.finance() avant d'y ajouter la clé, pour ne pas muter le cache module). Frontend : réutilise TELS QUELS giannaCascadeRows()/giannaClientRows() (déjà écrits pour RP, génériques) → nouvelle section "Cascade de rentabilité" + "Marge brute par vente" dans renderMOA(), juste après le bloc Finances existant.
MOA Properties (MP) : squelette prêt, PAS de données réelles (pas encore de compte bancaire ni de compta, demande explicite de Matt : "être tranquille" pour la suite plutôt que repartir de zéro). Nouveaux fichiers mp_finance.py (renvoie dispo:False + doc détaillée de ce qu'il faudra brancher : FILE_ID compta, mapping POSTES, source de CA à trancher entre compta bancaire et Google Sheet "Cerveau MOA" côté Matt) et mp_kpis.py (contrat {period,scoped,snapshot} identique à MOA/RP, pour que toute la plomberie générique marche déjà : /api/mp ajouté à _computers dans app.py). bank.py étendu à 3 entités : CURRENCY["mp"]="USD", FINANCE_MODULES (dict générique remplaçant le ternaire moa/rp), load()/state() couvrent "mp" → la trésorerie manuelle (solde + dépenses à venir) fonctionne déjà pour MP dès maintenant, avant même la compta (Matt peut saisir un solde dès qu'il ouvre un compte). Frontend : 3e onglet "MOA Properties" (couleur violette --mprop, dédiée pour ne pas confondre avec --mp = bleu déjà utilisé par l'onglet Comparaison), CATS.mp (juste "Vue d'ensemble" pour l'instant), renderOverview() en dispatch à 3 branches, nouvelle fonction renderMP() : bandeau "🚧 en préparation" + carte trésorerie fonctionnelle + bloc "Finances" expliquant que la cascade apparaîtra automatiquement une fois branchée. Comparaison (onglet cmp) volontairement PAS étendue à MP (comparer une entité sans données n'aurait pas de sens, à faire quand MP aura de vraies données). Testé : ast.parse/node --check OK sur tous les fichiers touchés, moa_kpis.compute() et bank.state() revérifiés sans régression sur MOA/RP (mêmes chiffres qu'avant), mp_kpis.compute() renvoie une structure propre sans planter, JSON de sortie validé sans NaN/Infinity pour les 2 endpoints. Restart cockpit fait.
rp_finance.py refactorisé : le classeur est téléchargé et parsé UNE fois (_load_workbook()), partagé entre le calcul existant (_compute_gastos, inchangé) et le nouveau (_compute_cascade), exposé sous finance.giannina. Cascade mensuelle (déc 2025→juin 2026) : CA (Rentabilidad por cliente dès qu'il existe pour le mois, sinon repli Clientes Resident — vérifié mars présent dans les 2 onglets avec EXACTEMENT le même total 175 123 137 ₲, jamais compté 2x) − A. Coûts directs (Costos ND-Migraciones "Gastos del día" mensuel, ou colonne Costos de Rentabilidad por cliente quand Giannina l'a déjà rattachée) = Marge brute (+%) − B. Marketing (poste Publicidad) = Contribution − C. Admin (Loyer+Services+Maintenance+Fournitures) − D. Impôts (IVA+IRE via Rentabilidad por cliente, ou poste Impuestos en repli) − E. Financier (Comisión bancaire, mars→juin seulement) = Résultat avant salaires − Salaires+IPS (non ventilables entre A/B/C sans décompte d'heures par employé, affichés à part plutôt que fondus dans une catégorie, comme Giannina l'a signalé) = Résultat après salaires. F. Rémunération du dirigeant : PAS de résultat net final calculable, gap explicite (aucune ligne dédiée dans la compta actuelle). Marge brute par client : les factures de mars→juin avec résumé (moyenne 49,4%, médiane 51,8% sur 23 factures fiables) + top 5 meilleures/pires marges + table complète. Piège corrigé en cours de route : Giannina stocke IVA/IRE/Comisión/Costos en NÉGATIF dans "Rentabilidad por cliente" (déjà pensés comme déductions) → abs() partout, sinon la marge brute calculée explosait à 130-170% (bug détecté et corrigé avant mise en prod). 2e piège : pour juin, la colonne Costos par client est encore vide (pas rattachée par Giannina) → le "Margen %" brut du fichier retombe sur un artefact IVA (~90,9% pour tous, pas une vraie marge) → ces factures sont exclues du classement/de la moyenne (costos_manquants:true, gardées dans la liste brute pour transparence). 4 gaps affichés en clair dans l'UI (pas de fausse précision) : A manquant pour décembre 2025, E (différence de change) jamais trouvée dans la compta, F (rémunération dirigeant) non isolable, Salaires+IPS non ventilés + n factures juin à coût manquant. Frontend index.html (renderRP) : nouvelle section "Cascade de rentabilité" (table 10 colonnes par mois) + "Marge brute par client" (2 KPI + top 5/flop 5 + détail complet togglable) + bandeau gaps. Testé : ast.parse/node --check OK, cascade recalculée à la main sur 2-3 mois pour vérifier les signes et l'absence de double-compte CA mars, JSON de sortie validé sans NaN/Infinity. Restart cockpit fait. Aucune écriture dans le classeur (lecture seule, comme le reste de rp_finance.py)._res_t_events/rp_forecast ne comparaient que des tokens de mots (Nombre del caso, filtre 4 lettres mini, zéro tolérance typo). Fix structurel côté source (pas un patch d'algo) : les RDV se créent désormais depuis le dashboard ops (rp-dashboard, module rdv.js + bouton "🗓 Nouveau RDV"), qui colle l'ID Airtable exact du dossier en métadonnée invisible de l'événement Google Calendar (extendedProperties.private.caso_id). revenue_forecast._res_t_events lit désormais ce champ et le propage ; rp_forecast fait un lookup exact sur cet ID en priorité (statut du/paye/inconnu déterminé sans ambiguïté), et ne retombe sur l'ancien matching par mots QUE pour les événements créés hors dashboard (historique, pas de régression). Testé bout en bout sur un mois de test (dossier Heidbrink, solde réel 6 460 €) : nouveau lookup le classe correctement en du avec le bon montant, événement de test supprimé après vérif. Suite (hors cockpit, côté Google Calendar) demandée par Matt : repasser l'accès de l'équipe sur l'agenda RP en lecture seule pour que la création passe exclusivement par le dashboard (hors scope OAuth actuel, action manuelle Matt). Voir MANUAL.md de rp-dashboard pour le détail du nouveau formulaire. Restart cockpit fait (le module est importé au démarrage du process Python).bank.py forecast() : (1) salaires exclus des obligations (payés en fin de mois, jamais à déduire ; toujours affichés pour info avec salaires_deduits:false) ; (2) autres charges fixes (loyer, IPS, services) déduites au prorata via _prorata_reste_mois() = (jours_mois − jour + 1) / jours_mois (jour courant compté comme à venir) ; (3) commissions dues agents + extras inchangés (en plein, vraies sorties futures) ; (4) exclusion de com_agents_compta inchangée. Nouveaux champs exposés : salaires_deduits, autres_charges_mois (forfait complet), autres_charges (part proratisée déduite), prorata_frac, jours_restants, jours_mois. fixe_mensuel retiré (n'était consommé nulle part). Frontend cashCard : salaires en muted "(payés en fin de mois : non déduits)", ligne autres charges affiche "prorata J/N j restants" + montant proratisé / forfait complet, hint réécrit. S'applique aux 2 entités (même logique de trésorerie ; à confirmer pour RP si ses salaires ne sont pas en fin de mois). Restart cockpit fait. Vérifié le 24/08 (prorata 8/31) : MOA obligations 29 958 (autres 4 187→1 081 proratisé, salaires 4 689 non déduits, com. dues 28 878), projection +7 542 (solde 37 500) vs négative auparavant. ast.parse OK, state() OK MOA+RP.bad, "À payer plus tard" warn) ont désormais chacune un bouton "Voir le détail" (pattern kpi(...,extra) + toggleDetail + .detailpanel, comme les autres fiches) au lieu de la table par agent toujours visible + le <details> unique. Panneau detail-apdue = table récap par agent (dû/lignes) + détail par opération des lignes dûes ; panneau detail-apfut = détail des lignes à venir. Frontend renderMOA() : bloc agentsDue réécrit (apRows(list), filtres apDue/apFut sur r.due, boutons btn-apdue/btn-apfut). Backend inchangé (snapshot.agent_payouts fournit déjà detail avec flag due, by_agent, due_now, future ; paid désormais inutilisé côté front mais laissé au payload). Restart cockpit fait. node --check OK.tot + le totHint dans forecastMOA() (frontend), et les 2 champs agence_net_realise_usd / agence_total_est_usd + la variable locale agence_net_realise dans revenue_forecast.moa_pipeline(). Conservé : le fix devise _pyg_safe sur payable_recu/payable_by_sale (c'est lui qui corrige le taux de reversement 95%→34% et le net pipeline 2 998→18 449, indépendant de la fiche retirée). Restart cockpit fait. Vérifié : ast.parse + node --check OK, /api/forecast → reversal_rate_pct=34, agence_net_est_usd=18449, champs total absents.moa_kpis.py : nouveau helper _agent_payouts(paf, recu_ids, names) qui sépare due_now (reste à payer sur ventes dont MOA a DÉJÀ encaissé sa commission = dette de trésorerie réelle, à sortir du cash) de future (reste à payer sur ventes pas encore encaissées, dû seulement une fois MOA payé, règle Matt), plus paid (cumul déjà versé), by_agent (par agent : due/future/nb) et detail (par ligne : agent, opération, payable, payé, restante, statut due/future, date réception com). Exposé dans snapshot.agent_payouts. Fix devise au passage : paye et a_payer (lignes snapshot existantes) passaient en _num brut → passés à _pyg_safe_usd (la ligne Veralta 9,9M ₲ les faussait). Frontend renderMOA() : section agentsDue insérée juste après la carte Trésorerie — 3 cartes (À payer maintenant bad / À payer plus tard warn / Déjà payé good) + hint explicatif + table par agent + <details> détail par opération (lignes "à venir" surlignées .fc-warnrow, badges 💰 Dû maintenant / ⏳ À venir). Valeurs vérifiées (/api/moa?period=month → snapshot.agent_payouts) : due_now 42 993 $ (Ladislas 24 966 / Raphaël Sterckx 12 326 / Mathis 2 513 / Monica 1 625 / Facundo 1 562), future 1 723 (Nais), paid 72 941, 23 lignes. Cohérent avec pending_agent_commissions() = 42 994 (déjà utilisé dans la projection trésorerie). Restart cockpit fait. Frontend no-cache (recharger). ast.parse + node --check OK.revenue_forecast.moa_pipeline(), payable_recu (et payable_by_sale) sommaient *Commission payable* en USD brut ⇒ la ligne Veralta (9 912 762 ₲) portait le reversé historique à ~10M, donc le taux de reversement rate = payable_recu/recu_com explosait et se faisait plafonner à 95%. Résultat faux affiché : "Estimé net pour l'agence" 2 998 $ / reversé ~25 959 $ / ~95%. Fix : nouveau helper _pyg_safe(v, rate) (valeur > 100 000 traitée comme ₲, /taux du jour) appliqué à payable_recu et payable_by_sale. Taux réel 34% (au lieu de 95), net pipeline agence 18 449 $ (au lieu de 2 998), reversé estimé 10 508 $. (2) Nouvelle fiche "Total estimé pour l'agence" = net déjà acquis sur les commissions DÉJÀ reçues (agence_net_realise_usd = recu_com − payable_recu = 220 770 $, part agence après reversement) + net estimé sur le pipeline à venir (agence_net_est_usd = 18 449) ⇒ agence_total_est_usd = 239 219 $. Backend revenue_forecast.py : 2 nouveaux champs au retour de moa_pipeline(). Frontend index.html forecastMOA() : carte tot (verte) ajoutée à la grille + hint explicatif (totHint). Restart cockpit fait. Frontend no-cache (recharger). Vérifié : ast.parse + node --check OK, /api/forecast → reversal_rate_pct=34, agence_net_est_usd=18449, agence_net_realise_usd=220770, agence_total_est_usd=239219. NB : le net réalisé est tout-cumulé (toutes commissions reçues), volontairement pas scopé période, pour matcher le pipeline (lui aussi tout-cumulé). Le vrai correctif source (ligne Veralta en ₲ dans Airtable) reste à faire.moa_kpis.py : nouvel index reverse_by_vente (somme de *Commission payable* de la table Paiement Agents par vente liée, via le lien Vente), _ligne() prend un vid et renvoie reverse (reversé) + part_agence (round(commission − reverse)) ; ventes_detail/com_detail reconstruits depuis les records uv (avec id) au lieu des seuls fields. Générralisation du fix devise : _commission_restante_usd renommé en _pyg_safe_usd (alias conservé) et appliqué à TOUS les *Commission payable* (répartitions par type/affilié, leaderboard, payable_p, part affiliés, index reverse) — la ligne Veralta (payable 9 912 762 ₲) faussait sinon aussi part_agence et le "Bénéfice réel agence". Frontend comRows() : colonnes Projet (client en sous-ligne) · Agent · Commission · Part agence (reversé en sous-ligne) · Reçue le (colonne Statut retirée). Restart cockpit fait. Frontend no-cache (recharger). Vérifié : /api/moa?period=month renvoie 4 lignes avec agent/commission/part_agence/reverse cohérents (ex. Veralta com 4 124 = agence 2 499 + reversé 1 625), ast.parse + node --check OK.auth_basic nginx (popup navigateur, user "Matt", ne persiste pas). Maintenant : vrai écran de connexion dans l'app. Nouveau backend/auth.py : identifiant = le même que le dashboard finances (email matt.bouthors@gmail.com + mot de passe), vérifié contre un hash apr1 stocké dans backend/data/auth.json (copié de /etc/nginx/.finances_htpasswd, chmod 600, contient aussi un secret HMAC aléatoire). Le mot de passe n'est jamais en clair : vérif via openssl passwd -apr1 -salt <salt> -stdin. Session = cookie signé HMAC ck_session (HttpOnly, Secure, SameSite=Lax), validité ~13 mois (plafond navigateur) renouvelé à chaque ouverture de la page → reste connecté en permanence. app.py : import auth, helpers _send_html/_redirect, gating en tête de do_GET/do_HEAD/do_POST (/login GET sert le form + POST vérifie et pose le cookie ; /logout efface ; /health public ; tout le reste → 302 /login en HTML ou 401 sur /api/*). auth_basic retiré des 2 confs nginx (cockpit.panelbay.com.conf + cockpit.bouthors-m.tenga.run.conf), nginx -t OK + reload. Restart cockpit fait. Vérifié : GET / sans cookie → 302 /login, /login → 200 (form email+password), /api/bank sans cookie → 401, avec cookie valide → 200, HEAD sans cookie → 302, plus de WWW-Authenticate. Couplage à connaître : si Matt change le mot de passe du dashboard finances, recopier le hash de /etc/nginx/.finances_htpasswd dans backend/data/auth.json (apr1_hash). L'ancien .cockpit_htpasswd (user "Matt") n'est plus utilisé.moa_kpis.pending_agent_commissions() sommait le champ Airtable *Commission restante* en supposant tout en USD. Or 1 ligne sur 101 était saisie en guaraníes : vente "Monica Ayala - Mono 1-16-7 – Veralta" (record recErLKlXfqfy8Msx, agent recdEsxR0L2tMVpu2, commission reçue 2026-08-14), *Commission (from Vente)* = 24 781 905 ₲ (au lieu de ~4 062 USD), *Commission restante* = 9 912 762 ₲. Sommée en USD, cette seule ligne portait les "commissions dues" à 9,95M et la projection trésorerie MOA à -9,9M. Fix : nouveaux helpers _latest_pyg_rate() (dernier rate_pyg_usd de moa_finance, fallback 7000) + _commission_restante_usd() = toute valeur > 100 000 traitée comme des ₲ et convertie au taux du mois (6100 en août). Résultat : commissions dues 9,95M → 42 994 USD, projection MOA -9,9M → -11 870 USD (réaliste : 42 994 com + 8 876 charges vs 40 000 au compte, cohérent avec l'audit "cash libre réel ~13k"). Le vrai correctif reste à faire à la SOURCE (le PAT cockpit est lecture seule) : corriger la commission de la vente Veralta en USD dans l'Airtable MOA (base appSVJQULMJNXy9Hl). Restart cockpit fait. Vérifié : pending_agent_commissions() = 42 994, /api/bank MOA projection -11 870.CA compta − (charges − ligne commissions compta) − payable = elle MÉLANGEAIT deux ledgers (CA compta 59 645 $ vs commissions Airtable 70 243 $ pour juillet). Nouvelle formule (cohérente, source unique du partage = Airtable) : part_agence − charges_op où part_agence = com_tombees − payable_p (commissions encaissées par MOA − reversé agents+affiliés, période) et charges_op = charges compta − ligne « Commissions agents » compta (on retire la ligne commissions de la compta pour ne pas double-compter). moa_kpis.compute bloc fin_p : nouveaux champs part_agence, ca_commissions (=com_tombees), charges_op, charges_projetees, charges_proj_mois ; benefice_marge désormais rapportée aux commissions encaissées (com_tombees). Résultat juillet : 16 158 → 26 755 $ (marge 38 %). PROJECTION mois en cours : si la période ne recouvre AUCUN mois comptable saisi (idx vide, ex. le mois courant avant que Giannina ne boucle), on projette charges_op sur le dernier mois comptable connu (charges_projetees=True, charges_proj_mois), les commissions restant réelles (Airtable à date). Dès que la compta du mois est saisie, idx n'est plus vide → calcul exact automatiquement. Frontend renderMOA bloc finance : 3 cas (compta réelle → 4 cartes dont « Résultat compta » gardé pour référence + « Bénéfice réel agence » ; projection → cartes Part agence / Charges projetées / Bénéfice réel avec badge ⚠️ « projection · charges de <mois> » sur la carte + hint explicatif ; ni compta ni commissions → hint). kpi() accepte un 5e arg extra (badge). Piège connu (signalé à Matt) : en projection, on compare des charges PLEIN MOIS à des commissions MOIS-À-DATE ⇒ en début de mois le bénéfice projeté est structurellement bas/négatif (août au 11 : part agence 3 770 vs charges projetées 8 771 = −5 001 $), ça se redresse à mesure que les commissions rentrent. Restart cockpit fait. Vérifié : ast.parse+node --check OK, /api/moa?period={prev_month,month} (26755 réel / −5001 projeté), Playwright (juillet : 4 cartes sans warning, 26 755 $ ; août : badge projection + −5 001 $). Écart compta↔Airtable (59 645 vs 70 243) affiché en note dans le hint (réconciliation à faire).revenue_forecast.rp_forecast : on PARCOURT désormais les RDV du mois (au lieu des dossiers) et on classe chaque RDV en du (solde Pendiente rattaché → compté), paye (dossier existe mais 0 en attente) ou inconnu (aucun dossier Airtable). Nouveau _pending_and_casos() (remplace _pending_by_caso) renvoie aussi TOUS les casos avec has_pending pour distinguer payé/inexistant. Sortie enrichie : events_detail (liste par RDV avec statut), nb_paye, nb_inconnu, et warn = (nb RDV ≠ nb dépôts avec solde). Vérifié sur août : Sidney=du (2194$), Mingori/Langlet=payé (déjà soldés), 4 RDV=inconnu (Defresne/Blizt/Muiruri/Perou pas dans Airtable) ⇒ warn=True. Frontend forecastRP : chip ⚠️ N RDV sans solde à vérifier en extra de la carte "Dépôts résidence T repérés" (clic → ouvre + scroll le détail #fc-rp-drill) ; le <details> s'ouvre automatiquement si warn et liste TOUS les RDV avec badge de statut (💰 À encaisser / ✅ Déjà soldé / ⚠️ Aucun dossier), lignes non-du surlignées (.fc-warnrow) + hint explicatif. CSS .fcb/.fcb-ok/paid/warn, .fc-warnrow, .fc-warnchip. Restart cockpit fait (backend). Frontend no-cache (recharger). Vérifié : ast.parse + node --check OK, /api/forecast renvoie warn=True/res_t_events=7/nb_depots=1/nb_paye=2/nb_inconnu=4/events_detail=7, Playwright (chip "6 RDV sans solde", détail auto-ouvert, 7 badges, 6 lignes surlignées).rp_pagos.py (lecture seule, source = table Airtable Pagos base RP appGzbfkhHiO95jd1, cache 5 min) : (a) soldes() = tout ce qui reste dû aujourd'hui (Estado del pago='Pendiente') regroupé par dossier (nom = Casos.Nombre del caso via Caso Vinculado), INSTANTANÉ indépendant de la période → total 74 413 € / 29 dossiers ; (b) encaisse(period) = paiements réellement reçus (Estado='Pagado') datés dans la période par leur Fecha de pago réelle (PAS Fecha de creación, qui est la date d'import groupé 2026-07-02) → CA réel scopé (août 4 532 €, ytd 220 361 €, tout 710 543 €). 3 devises gérées (EUR 184 / Guaraníes 122 / USD 3 sur les Pagado ; EUR+USD sur les Pendiente) ramenées en EUR (devise de réf. RP, comme le CA estimé) : USD→EUR au taux du jour (er-api, caché 1 h), ₲→EUR via le taux ₲/USD de moa-catalog/rate.json puis USD→EUR. rp_kpis.compute() : import rp_pagos ; scoped.ca_encaisse (période) + snapshot.soldes (instantané), tous deux en try/except (None si Airtable indispo). Frontend renderRP : 2 nouvelles cases dans la grille — "CA réel encaissé" (EU(enc.total_eur), vert, à côté de "CA estimé (période)") et "Soldes clients à encaisser" (EU(sol.total_eur), hl) — chacune avec bouton "Voir le détail" + panneau (detail-rpcareel : Dossier/Type/Montant devise/≈EUR/Payé le + ventilation par type ; detail-rpsoldes : Dossier/Détail soldes/devise d'origine/≈EUR). Row-builders rpEncaisseRows / rpSoldesRows + helper _dev(). Restart cockpit fait. Vérifié : ast.parse + node --check OK, /api/rp?period={month,ytd,all} renvoie ca_encaisse (4532/220361/710543) et soldes (74413 constant), Playwright (2 cases rendues, panneaux 5 lignes encaissé / 29 lignes soldes). Le CA estimé (mix produits) reste affiché à côté ; les deux mesures sont volontairement distinctes (estimé = valeur pipeline des clients signés sur la période ; réel = cash réellement encaissé, daté au paiement). NB : "Encaissements prévus ce mois" (forecast agenda×soldes) reste fragile et non corrigé (voir entrée audit ci-dessous).revenue_forecast._res_t_events — Google renvoyait les événements "journée entière" du 1er du mois suivant (leur start.date == timeMax, borne exclusive non respectée pour les all-day), ce qui comptait un dépôt de septembre ('residence T Myriam Olivares', 2026-09-01) dans le total d'août. Ajout d'un filtre strict start_s <= d < end_s sur la date parsée. Résultat : 8 → 7 rendez-vous en août. (2) Taux de closing passé en FENÊTRE GLISSANTE 90 JOURS (avant : cumul historique 42/732 affiché sous une case "période"). rp_kpis.crm() : nouveaux champs taux_closing_90, gagnes_90, entrants_90, gagnes90_detail = parmi les prospects ENTRÉS dans les 90 derniers jours (fenêtre fixe, indépendante du sélecteur), part de ceux aujourd'hui "gagné". taux_closing cumul conservé en réf. mais l'UI n'affiche plus que le 90 j. Frontend renderRP : case renommée "Taux de closing (90 j)" (pct(crm.taux_closing_90), sous-titre gagnes_90 / entrants_90 prospects entrés sur 90 j) ; bouton détail → gagnes90_detail. LIMITE connue : le CRM Younes n'a PAS de date de gain, seulement la date d'entrée ⇒ mesure de cohorte d'entrée. Elle lit BAS (90 j = 1.6% ; 5/317) car les deals qui closent en >90 j ne sont pas comptés dans une cohorte jeune. Le vrai fix (closing exact sur période) nécessiterait d'ajouter un champ "date de passage client" dans rp-crm. (3) Non corrigé, signalé à Matt : "Encaissements prévus ce mois" reste fragile (somme du solde TOTAL du dossier et non la seule échéance du mois ; lien agenda↔Airtable par ressemblance de nom ⇒ 1 seul dossier matché sur 7 RDV, ~83k$ de soldes non rattachés). En attente de décision (date d'échéance par paiement Airtable, ou ID dossier dans le titre agenda, ou renommer en "potentiel repéré"). (4) Bug 500 corrigé au passage : series.py _rp_leads dépaquetait les prospects à 4 valeurs for status, source, created, last_inbound in prospects alors que rp_kpis._raw() renvoie 5 colonnes depuis l'ajout de name (2026-08-10) ⇒ /api/series?entity=rp&cat=leads renvoyait 500 (graphe des leads RP cassé depuis hier). Passé en for status, source, created, last_inbound, *_ in prospects. Vérifié 200. Restart cockpit fait (backend revenue_forecast.py + rp_kpis.py + series.py). Frontend no-cache (recharger). Vérifié Playwright : case "Taux de closing (90 j)" rendue dans la Vue d'ensemble RP.kpi(...,extra) + toggleDetail + .detailpanel exclusif). Backend rp_kpis.py : crm() sélectionne aussi name (query prospects) et renvoie nouveaux_detail + gagnes_detail (nom/source/statut/date) ; compute() ajoute scoped.clients_detail (nom/package/CA €/source/étape/date, triés par date, via _cli_row, nom = "Nombre completo"). Frontend index.html : 3 row-builders RP (rpClientsRows, rpCaRows [filtré ca>0, trié desc], rpProspectsRows) + boutons "Voir le détail" sous Nouveaux clients (→ liste clients), CA estimé (→ clients chiffrés par CA), Nouveaux prospects Younes (→ prospects entrés), Taux de closing (→ prospects gagnés) ; panneaux #detail-rp{clients,ca,nouv,gagnes} insérés après la grille. Boutons affichés seulement si la liste existe (ex. pas de "gagnés" ce mois-ci → pas de bouton). Vérifié : ast.parse rp_kpis OK, node --check JS OK, /api/rp?period=all renvoie clients_detail 335 / nouveaux 732 / gagnés 42, Playwright (onglet RP, boutons rendus, panneau clients ouvert = 4 lignes réelles Maximilien/Brice/... sur "Ce mois"). Restart cockpit fait (backend). Frontend no-cache (recharger la page).backend/promoteurs.py (compute()) lit en direct la table Airtable "Promoteurs" (base Moa appSVJQULMJNXy9Hl, lecture seule via airtable.all_records) et la classe en Signés (Statut vide ou "Signé"), À signer ("A close"), Inactifs ("Inactif"). Ajoute des pistes marché : promoteurs présents dans moa-catalog/catalog.db mais absents de la table (dédup par nom normalisé + premier mot), avec leur volume de projets/unités/dispos (ex. Zuba 27 projets / 3758 unités, non signé). Route GET /api/promoteurs (cache TTL) ajoutée dans app.py (+ import promoteurs). Frontend index.html : catégorie promoteurs ajoutée à CATS.moa, icône promoteurs, dispatch dans renderEntity, fonction renderPromoteurs (KPIs Signés/À signer/Inactifs/Pistes + 4 tableaux : signés avec commission/liste prix/fin contrat/contact WhatsApp/Drive, prospects, pistes marché, inactifs), CSS .promo-kpis. Restart cockpit requis (nouveau module backend) : effectué. Vérifié : /api/promoteurs renvoie 14 signés, 9 à signer, 1 inactif, 8 pistes ; syntaxe OK. Données 100 % live (aucune écriture Airtable).promoteurs.py : nouveau _scrap_index() qui lit moa-catalog/catalog.db et calcule, par promoteur, le volume scrapé (projets, unités, dispos statut='libre', projets sans liste de prix, date MAX(unites.updated_at)). Rapprochement fiable Airtable↔catalogue par promoteurs.airtable_id (clé directe), fallback nom normalisé puis premier mot. _classify() renvoie un statut + liste de soucis : absent (jamais scrapé, aucune donnée catalogue), no_units (fiche présente mais 0 unité tarifée = liste de prix non scrapée), warn (projets sans liste de prix / aucune dispo / scrap ancien >45 j), ok. Chaque item (signés, prospects, inactifs) porte désormais p["scrap"]={status,issues,n_unites,n_libres,n_proj_vides,last_scrape,days_since}. counts.alerts = nb de signés en souci. Frontend renderPromoteurs : 5e KPI "Alertes scrap (signés)" (rouge si >0), colonne "Scrap unités" dans les tables signés (7 col.) et à-signer (4 col.) = badge coloré (✓ À jour / ⚠ Partiel / ✗ Pas de prix / ✗ Jamais scrapé) + compteur unités·dispo + date + soucis en texte. CSS .scrap-badge/.scrap-ok/.scrap-warn/.scrap-bad/.scrap-cnt/.scrap-dt/.scrap-iss, .promo-kpis en auto-fit + .pk.alert. Restart cockpit fait (backend). Vérifié /api/promoteurs : 10 alertes sur 14 signés (6 absents du catalogue : Avanza, Cima, Insignia, Marena, Nativus, Perseverencia ; Altea = pas de liste de prix ; Civis/Petra/Uno Tres = partiels). JS inline node --check OK. Frontend servi no-cache (pas de restart, recharger la page).{k:'promoteurs',...} de CATS.moa dans frontend/index.html (l'onglet disparaît de la barre MOA). Le code mort (renderPromoteurs, route /api/promoteurs, backend/promoteurs.py) est laissé en place mais n'est plus atteignable depuis l'UI ; il pourra être supprimé à un prochain nettoyage. La fonctionnalité (annuaire + statut de scrap des unités) vit désormais dans apps/moa-crm (vue "Promoteurs" réservée aux admins). Frontend no-cache : effectif au rechargement, pas de restart.backend/moa_leads_origin_sync.py lit la boîte moarealestate.sa@gmail.com (mails du formulaire site « Nouveau lead immo (VSL IMMO) » : Nom/Email/Numéro), rapproche par email les leads Odoo et leur AJOUTE le tag MOA Direct (jamais de suppression, jamais de création de lead) → le canal MOA se remplit tout seul à partir de maintenant. Le script écrit data/moa_leads_origin.json (compteurs cumulés par origine + ventilation mensuelle indicative). Cron 25 3 (crontab, log data/moa_leads_origin_sync.log). Côté cockpit : moa_kpis.py _leads_origine() lit le cache → snapshot.leads_origine ; frontend/index.html renderMOA affiche un bloc « Répartition des leads par origine » (table Origine / Leads / Part + barres, couleurs par canal) sous les KPI de la Vue d'ensemble MOA. Cumul (pas par période) car la create_date Odoo est souvent une date d'import groupé : les totaux par origine sont fiables, pas la ventilation mensuelle (signalé dans le hint). État initial : Ladislas 538, Resident Paraguay 106, MOA direct 2 (2 leads site déjà en base tagués), Meta 28, Autre 1110 (surtout l'import historique de mars). Restart cockpit fait. Vérifié : sync OK (tag créé, 2 leads site tagués, cache écrit), /api/moa leads_origine renvoie les 5 origines, Playwright (bloc rendu avec les bonnes lignes/%). Piège : ce recap lit Odoo (canal MOA CRM natif) ; distinct de la ligne « Saisis CRM (manuel) » de l'onglet Leads qui lit apps/moa-crm (autre système).apps/moa-crm, il doit apparaître dans les statistiques de leads du cockpit. (1) Côté CRM (apps/moa-crm/backend/server.js, POST /api/leads) : toute création manuelle est désormais taguée intake_source='manual' (distinct des imports auto odoo_import / airtable_vente). (2) Nouveau module cockpit backend/moa_crm_leads.py : lecture SEULE de moa-crm/backend/data/moa-crm.db (ouverture mode=ro, cache 5 min), renvoie les leads dont intake_source IS NULL OR NOT IN ('odoo_import','airtable_vente') = saisie humaine (manual / coordinateur_* / null), avec date de création, nom, source et agent (join users). (3) series.py _moa_leads : nouvelle métrique "Saisis CRM (manuel)" ajoutée au Total leads (à côté de Ladislas + Meta Russie) + table "Leads saisis à la main, par personne" + source dans le bandeau. (4) moa_kpis.py : _manual_crm_leads(period) (compte + ventilation par personne sur la période) exposé en scoped.leads_manuels ; frontend/index.html renderMOA ajoute la KPI "Leads saisis à la main (CRM)" (avec le top 3 des personnes). Rétroactif ? Non : "à partir de maintenant" — les 148 leads existants sont des imports (0 manuel aujourd'hui), donc seuls les nouveaux leads saisis à la main sont comptés. Restart moa-crm + cockpit faits. Vérifié bout-en-bout : lead créé via POST /api/leads → intake_source='manual' en base → /api/moa leads_manuels.recus=1 (par_agent Gary) + /api/series métrique manuel=1 + table par personne ; puis nettoyé (0 manuel). Base CRM jamais modifiée par le cockpit (read-only)..catnav width 172px → 196px (frontend/index.html, CSS). (2) Option "30 j" retirée du sélecteur de période (bouton data-k="30d" supprimé de la periodbar) ET de la comparaison (30d retiré de CMP_PERIODS). Le backend periods.py supporte toujours 30d (aucune suppression côté API). (3) Pipeline "Revenu à venir" : estimation du net agence. revenue_forecast.moa_pipeline() calcule le taux de reversement historique (Commission payable des ventes déjà reçues ÷ commissions reçues, ≈34%) et l'applique au pipeline (réel + estimé) pour estimer agence_net_est_usd (ce qui reste à l'agence) et reverse_agents_est_usd (part agents + affiliés) ; par dossier, on prend la Commission payable déjà saisie si elle existe, sinon l'estimation au taux, plafonnée à la commission du dossier. Frontend forecastMOA : KPI "Estimé net pour l'agence" + hint. (4) Finances : nouvelle case "Bénéfice réel agence" à côté de "Résultat". Souci important signalé : la compta (Gastos Fijos) contient déjà une ligne "Commissions agents" mais partielle (ex. 4 514$ en juillet) vs les commissions réellement dues (~34 716$ Airtable). moa_kpis.compute (bloc fin_p) ajoute com_compta (ligne compta des mois de la période, via postes_mensuels), com_reelles (= payable_p, Paiement Agents attribués au mois de la date de réception de la commission MOA), et benefice_reel = CA − (charges − com_compta) − com_reelles (on retire la ligne compta et on la remplace par la commission réelle pour ne pas double-compter) + benefice_marge. Frontend renderMOA (scopedFin) : KPI "Bénéfice réel agence" + hint explicatif. Résultat vérifié Juillet : Résultat 46 360$ (marge 78%) inchangé, Bénéfice réel 16 158$ (marge 27%, com_reelles 34 716$). Restart cockpit fait (backend moa_kpis.py + revenue_forecast.py). Frontend no-cache (recharger la page). Vérifié : ast.parse OK, /api/moa?period=prev_month benefice_reel=16158, Playwright (label "Vue d'ensemble" non tronqué, pas de bouton 30j, KPI net agence 23 371$, KPI Bénéfice réel 16 158$). Base de commission = payable (ce qui est dû aux agents ET affiliés), pas seulement "payée" : à basculer si Matt préfère la stricte trésorerie décaissée.frontend/index.html renderSeries : winOpts (maille mois) = [1,3,6,12,'all'] (la maille semaine reste [8,13,26]), winLbl gère le libellé « Mois dernier » (1) et « Tout » ('all'). Le sentinel 'all' demande length=104 (borne MAX_SERIES_LEN du backend) puis rogne côté client les mois de tête entièrement vides (firstActive) pour démarrer à la première activité réelle sans afficher des dizaines de barres vides (non appliqué aux Finances, fenêtre mensuelle fixe). Boutons : état actif comparé sur rawWin, setWin('all') quote le sentinel. Défaut inchangé (12 mois). Frontend no-cache (recharger la page), pas de restart. Vérifié : JS inline node --check OK.