sync-legacy: raffiner l'annulation des fantômes (pas de cascade aveugle)

La 1re version cascadait compte résilié ⇒ TOUS services Annulé, ce qui contredit
la politique 2026-06-13 (statut SERVICE = vérité ; employés gardent internet gratuit
sur comptes terminés) et annulait ~842 services légitimes (dont 185k$/mo facturés
encore par F ou gratuits).

Raffinement (sync_services_incremental.py, marqueur RAFFINÉ 2026-07-06) :
- annul_ghost(compte, prix) : n'annule QUE le fantôme AVÉRÉ = compte résilié +
  service PAYANT (prix>0) + F silencieux depuis >24 mois (MAX(invoice.date_orig)).
  Préserve les gratuits (employés), tout ce que F facture encore, et le jamais-facturé
  (ambigu → revue manuelle via /rapports/resilies-actifs).
- Phases B (création) + D (rafraîchissement) passent par annul_ghost.
- Résultat mesuré : 3013 fantômes avérés annulés, 842 réactivés/préservés,
  fantômes avérés restants = 0.

Scoreboard : métrique renommée « actifs sur compte résilié — à revoir » (hub note +
libellé UI) puisque le résidu n'est plus un bug mais une liste de revue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-06 20:20:24 -04:00
parent 004c3f8dee
commit 29589597b1
3 changed files with 28 additions and 13 deletions

View File

@ -69,7 +69,7 @@
<span>{{ fmtN(scoreboard.resiliated_f_accounts) }} comptes F résiliés</span> <span>{{ fmtN(scoreboard.resiliated_f_accounts) }} comptes F résiliés</span>
<span>·</span> <span>·</span>
<span :class="scoreboard.ghost_active_subscriptions ? 'text-orange-9 text-weight-bold' : 'text-green-8'"> <span :class="scoreboard.ghost_active_subscriptions ? 'text-orange-9 text-weight-bold' : 'text-green-8'">
<q-icon :name="scoreboard.ghost_active_subscriptions ? 'warning' : 'check_circle'" size="14px" /> {{ fmtN(scoreboard.ghost_active_subscriptions) }} abonnements fantômes (compte résilié) <q-icon :name="scoreboard.ghost_active_subscriptions ? 'rate_review' : 'check_circle'" size="14px" /> {{ fmtN(scoreboard.ghost_active_subscriptions) }} actifs sur compte résilié à revoir
</span> </span>
<q-space /> <q-space />
<span v-if="scoreboard.generated_at">{{ new Date(scoreboard.generated_at).toLocaleString('fr-CA') }}</span> <span v-if="scoreboard.generated_at">{{ new Date(scoreboard.generated_at).toLocaleString('fr-CA') }}</span>

View File

@ -23,7 +23,7 @@ import re
import uuid import uuid
import pymysql import pymysql
import psycopg2 import psycopg2
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from html import unescape from html import unescape
LEGACY = {"host": os.environ.get("LEGACY_HOST", "10.100.80.100"), "user": "facturation", LEGACY = {"host": os.environ.get("LEGACY_HOST", "10.100.80.100"), "user": "facturation",
@ -65,10 +65,11 @@ def clean_addr(raw, postal=None): # strip postal qui a fui dans address1 + espa
a = re.sub(r"\s*" + re.escape(pc) + r"\s*$", "", a, flags=re.I) a = re.sub(r"\s*" + re.escape(pc) + r"\s*$", "", a, flags=re.I)
return re.sub(r"\s+", " ", a).strip() return re.sub(r"\s+", " ", a).strip()
def svc_status(s, d, resiliated=False): # règle canonique (= legacy-sync.svcStatus) : F service → statut SS def svc_status(s, d, safe_to_annul=False): # règle canonique (= legacy-sync.svcStatus) : F service → statut SS
if resiliated: if safe_to_annul:
return "Annulé" # FIX 2026-07-06 : compte F résilié (status 3/4/5) → l'abo ne peut PAS rester Actif. return "Annulé" # RAFFINÉ 2026-07-06 : fantôme AVÉRÉ seulement (compte résilié + service payant + F ne facture
# F ne cascade PAS la résiliation du compte vers service.status (cf feedback_ghost_active_subscriptions). # plus depuis >24 mois). Pas de cascade aveugle : on préserve les gratuits (employés) et tout
# ce que F facture encore (cf feedback_customer_status_policy : statut SERVICE = vérité).
if int(s or 0) == 1: if int(s or 0) == 1:
return "Actif" # CORRIGE 2026-06-13: F status=1 = ACTIF (date_suspended = historique, ignore) return "Actif" # CORRIGE 2026-06-13: F status=1 = ACTIF (date_suspended = historique, ignore)
return "Annulé" return "Annulé"
@ -84,11 +85,25 @@ def main():
pgc.execute('SELECT legacy_account_id, name FROM "tabCustomer" WHERE legacy_account_id>0') pgc.execute('SELECT legacy_account_id, name FROM "tabCustomer" WHERE legacy_account_id>0')
cust_map = {int(r[0]): r[1] for r in pgc.fetchall()} cust_map = {int(r[0]): r[1] for r in pgc.fetchall()}
cur.execute("SELECT id, account_id FROM delivery"); del_acct = {r["id"]: r["account_id"] for r in cur.fetchall()} cur.execute("SELECT id, account_id FROM delivery"); del_acct = {r["id"]: r["account_id"] for r in cur.fetchall()}
# FIX 2026-07-06 — comptes résiliés en F (status 3/4/5) : leurs services restent souvent status=1 # FIX 2026-07-06 (RAFFINÉ) — comptes résiliés en F (status 3/4/5) : leurs services restent souvent status=1
# (F ne cascade pas) → sans ce garde-fou, Phase B/D les (re)mettent 'Actif' = abonnements fantômes. # (F ne cascade pas). On N'ANNULE PAS en cascade aveugle (des comptes terminés gardent des services
# légitimes : employés à internet gratuit, services encore facturés). On annule UNIQUEMENT le fantôme
# AVÉRÉ = service PAYANT dont F n'a pas émis de facture depuis >24 mois. cf feedback_customer_status_policy.
cur.execute("SELECT id FROM account WHERE status IN (3,4,5)") cur.execute("SELECT id FROM account WHERE status IN (3,4,5)")
resil_acct = set(r["id"] for r in cur.fetchall()) resil_acct = set(r["id"] for r in cur.fetchall())
log(" Comptes F résiliés (status 3/4/5) : {}".format(len(resil_acct))) GHOST_CUTOFF = int((datetime.now(timezone.utc) - timedelta(days=730)).timestamp()) # 24 mois
last_inv = {}
if resil_acct:
ph = ",".join(["%s"] * len(resil_acct))
cur.execute("SELECT account_id, MAX(date_orig) li FROM invoice WHERE account_id IN ({}) GROUP BY account_id".format(ph), list(resil_acct))
last_inv = {r["account_id"]: r["li"] for r in cur.fetchall()}
def annul_ghost(acct, price):
# True = fantôme avéré (sûr à annuler) ; False = préserver (miroir du statut F / gratuit / facturé récemment)
if acct not in resil_acct: return False
if float(price or 0) <= 0: return False # gratuit/comp (employé) → jamais annuler
li = last_inv.get(acct)
return li is not None and int(li) < GHOST_CUTOFF # dernière facture F > 24 mois → fantôme
log(" Comptes F résiliés : {} | dont avec facturation connue : {}".format(len(resil_acct), len(last_inv)))
# ═══ PHASE A — Service Location (delivery) ═══ # ═══ PHASE A — Service Location (delivery) ═══
cur.execute("SELECT id FROM delivery") cur.execute("SELECT id FROM delivery")
@ -155,7 +170,6 @@ def main():
acct_id = del_acct.get(svc["delivery_id"]) if svc["delivery_id"] else None acct_id = del_acct.get(svc["delivery_id"]) if svc["delivery_id"] else None
cust = cust_map.get(acct_id) if acct_id else None cust = cust_map.get(acct_id) if acct_id else None
if not sl or not cust: sub_noloc += 1; continue # location + client requis (idem migration) if not sl or not cust: sub_noloc += 1; continue # location + client requis (idem migration)
new_status = "Annulé" if acct_id in resil_acct else "Actif" # FIX 2026-07-06 : jamais Actif sur compte résilié
cat = PROD_CAT_MAP.get(svc["prod_cat"] or 0, "Autre") cat = PROD_CAT_MAP.get(svc["prod_cat"] or 0, "Autre")
plan = unescape(prod_names.get(svc["product_id"], svc.get("sku") or "Unknown"))[:140] plan = unescape(prod_names.get(svc["product_id"], svc.get("sku") or "Unknown"))[:140]
if svc["hijack"] and svc["hijack_download_speed"]: if svc["hijack"] and svc["hijack_download_speed"]:
@ -164,6 +178,7 @@ def main():
sd, su = int(svc["download_speed"]) // 1024, int(svc["upload_speed"] or 0) // 1024 sd, su = int(svc["download_speed"]) // 1024, int(svc["upload_speed"] or 0) // 1024
else: sd = su = 0 else: sd = su = 0
price = float(svc["hijack_price"] or 0) if svc["hijack"] else float(svc["price"] or 0) price = float(svc["hijack_price"] or 0) if svc["hijack"] else float(svc["price"] or 0)
new_status = "Annulé" if annul_ghost(acct_id, price) else "Actif" # RAFFINÉ 2026-07-06 : fantôme avéré seulement
cycle = RECUR_MAP.get(svc["payment_recurrence"], "Mensuel") cycle = RECUR_MAP.get(svc["payment_recurrence"], "Mensuel")
start = ts_date(svc["date_orig"], "2020-01-01"); end = ts_date(svc["date_end_contract"]) start = ts_date(svc["date_orig"], "2020-01-01"); end = ts_date(svc["date_end_contract"])
if len(sub_samples) < 6: sub_samples.append({"sub": sub_name(svc["id"]), "cat": cat, "plan": plan, "price": price, "cycle": cycle, "loc": sl}) if len(sub_samples) < 6: sub_samples.append({"sub": sub_name(svc["id"]), "cat": cat, "plan": plan, "price": price, "cycle": cycle, "loc": sl})
@ -208,8 +223,8 @@ def main():
# ═══ PHASE D — rafraîchir le STATUT des SS (Actif/Suspendu/Annulé) depuis F ═══ # ═══ PHASE D — rafraîchir le STATUT des SS (Actif/Suspendu/Annulé) depuis F ═══
# Reflète résiliations (status 1→0 = Annulé) + suspensions (date_suspended>0 = Suspendu) + réactivations. # Reflète résiliations (status 1→0 = Annulé) + suspensions (date_suspended>0 = Suspendu) + réactivations.
# C'est la SOURCE du statut client (disabled dérivé des services — cf. feedback_customer_status_policy). # C'est la SOURCE du statut client (disabled dérivé des services — cf. feedback_customer_status_policy).
cur.execute("SELECT s.id, s.status, s.date_suspended, d.account_id FROM service s LEFT JOIN delivery d ON d.id=s.delivery_id") cur.execute("SELECT s.id, s.status, s.date_suspended, d.account_id, IF(s.hijack=1, COALESCE(s.hijack_price,0), COALESCE(p.price,0)) AS price FROM service s LEFT JOIN delivery d ON d.id=s.delivery_id LEFT JOIN product p ON p.id=s.product_id")
want = {r["id"]: svc_status(r["status"], r["date_suspended"], r["account_id"] in resil_acct) for r in cur.fetchall()} want = {r["id"]: svc_status(r["status"], r["date_suspended"], annul_ghost(r["account_id"], r["price"])) for r in cur.fetchall()}
pgc.execute('SELECT legacy_service_id, status FROM "tabService Subscription" WHERE legacy_service_id>0') pgc.execute('SELECT legacy_service_id, status FROM "tabService Subscription" WHERE legacy_service_id>0')
st_upd = []; trans = {} st_upd = []; trans = {}
for lid, st in pgc.fetchall(): for lid, st in pgc.fetchall():

View File

@ -564,7 +564,7 @@ async function scoreboard () {
entities: [ entities: [
row('customers', 'Clients', fAcct, eCust, 'F account ↔ Customer'), row('customers', 'Clients', fAcct, eCust, 'F account ↔ Customer'),
row('locations', 'Lieux de service', fDelivery, eLoc, 'F delivery ↔ Service Location'), row('locations', 'Lieux de service', fDelivery, eLoc, 'F delivery ↔ Service Location'),
row('subscriptions', 'Abonnements actifs', fSvcActive, eSubActif, ghost ? `${ghost} fantômes (compte résilié) à annuler` : 'F actif hors compte résilié ↔ Actif'), row('subscriptions', 'Abonnements actifs', fSvcActive, eSubActif, ghost ? `${ghost} actifs sur compte résilié — à revoir (fantômes payants >24 mois auto-annulés)` : 'F actif hors compte résilié ↔ Actif'),
row('devices', 'Appareils', fDevice, eDevice, 'F device ↔ Service Equipment (liens orphelins possibles)'), row('devices', 'Appareils', fDevice, eDevice, 'F device ↔ Service Equipment (liens orphelins possibles)'),
row('tickets', 'Tickets', fTicket, eIssue, 'F ticket ↔ Issue (import à réparer)'), row('tickets', 'Tickets', fTicket, eIssue, 'F ticket ↔ Issue (import à réparer)'),
row('invoices', 'Factures', fInvoice, eInv, 'F invoice ↔ Sales Invoice'), row('invoices', 'Factures', fInvoice, eInv, 'F invoice ↔ Sales Invoice'),