From 29589597b1b2031e045ce1f3f31b9b3e7bdc2a09 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Mon, 6 Jul 2026 20:20:24 -0400 Subject: [PATCH] =?UTF-8?q?sync-legacy:=20raffiner=20l'annulation=20des=20?= =?UTF-8?q?fant=C3=B4mes=20(pas=20de=20cascade=20aveugle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/ops/src/pages/LegacySyncPage.vue | 2 +- .../targo-sync/sync_services_incremental.py | 37 +++++++++++++------ services/targo-hub/lib/legacy-sync.js | 2 +- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/apps/ops/src/pages/LegacySyncPage.vue b/apps/ops/src/pages/LegacySyncPage.vue index d8d6204..0d60ac9 100644 --- a/apps/ops/src/pages/LegacySyncPage.vue +++ b/apps/ops/src/pages/LegacySyncPage.vue @@ -69,7 +69,7 @@ {{ fmtN(scoreboard.resiliated_f_accounts) }} comptes F résiliés · - {{ fmtN(scoreboard.ghost_active_subscriptions) }} abonnements fantômes (compte résilié) + {{ fmtN(scoreboard.ghost_active_subscriptions) }} actifs sur compte résilié — à revoir {{ new Date(scoreboard.generated_at).toLocaleString('fr-CA') }} diff --git a/scripts/targo-sync/sync_services_incremental.py b/scripts/targo-sync/sync_services_incremental.py index ef9d4a7..d692b24 100644 --- a/scripts/targo-sync/sync_services_incremental.py +++ b/scripts/targo-sync/sync_services_incremental.py @@ -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(): diff --git a/services/targo-hub/lib/legacy-sync.js b/services/targo-hub/lib/legacy-sync.js index ba553ff..c875f5a 100644 --- a/services/targo-hub/lib/legacy-sync.js +++ b/services/targo-hub/lib/legacy-sync.js @@ -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'),