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>·</span>
<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>
<q-space />
<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 pymysql
import psycopg2
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from html import unescape
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)
return re.sub(r"\s+", " ", a).strip()
def svc_status(s, d, resiliated=False): # règle canonique (= legacy-sync.svcStatus) : F service → statut SS
if resiliated:
return "Annulé" # FIX 2026-07-06 : compte F résilié (status 3/4/5) → l'abo ne peut PAS rester Actif.
# F ne cascade PAS la résiliation du compte vers service.status (cf feedback_ghost_active_subscriptions).
def svc_status(s, d, safe_to_annul=False): # règle canonique (= legacy-sync.svcStatus) : F service → statut SS
if safe_to_annul:
return "Annulé" # RAFFINÉ 2026-07-06 : fantôme AVÉRÉ seulement (compte résilié + service payant + F ne facture
# 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:
return "Actif" # CORRIGE 2026-06-13: F status=1 = ACTIF (date_suspended = historique, ignore)
return "Annulé"
@ -84,11 +85,25 @@ def main():
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()}
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
# (F ne cascade pas) → sans ce garde-fou, Phase B/D les (re)mettent 'Actif' = abonnements fantômes.
# 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). 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)")
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) ═══
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
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)
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")
plan = unescape(prod_names.get(svc["product_id"], svc.get("sku") or "Unknown"))[:140]
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
else: sd = su = 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")
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})
@ -208,8 +223,8 @@ def main():
# ═══ 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.
# 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")
want = {r["id"]: svc_status(r["status"], r["date_suspended"], r["account_id"] in resil_acct) for r in cur.fetchall()}
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"], 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')
st_upd = []; trans = {}
for lid, st in pgc.fetchall():

View File

@ -564,7 +564,7 @@ async function scoreboard () {
entities: [
row('customers', 'Clients', fAcct, eCust, 'F account ↔ Customer'),
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('tickets', 'Tickets', fTicket, eIssue, 'F ticket ↔ Issue (import à réparer)'),
row('invoices', 'Factures', fInvoice, eInv, 'F invoice ↔ Sales Invoice'),