history.py
Language Python9.9 KB242 lines
"""The local order history, the merge registry, and ingest."""from __future__ import annotationsimport jsonimport sysfrom pathlib import Pathfrom .config import load_configfrom .paths import DATA_DIR, HISTORY, MERGE_REGISTRY, NOW, TODAYdef load_history(required: bool = True) -> dict:if not HISTORY.exists():if required:sys.exit(f"History store not found: {HISTORY}\n""Run seed-baseline first (or ingest, for a no-baseline start).")return {"meta": {"schema": 1, "created": NOW, "baseline_source": None,"last_ingest": None, "previous_computed": {}},"customers": {}}return json.loads(HISTORY.read_text(encoding="utf-8"))def save_history(h: dict):DATA_DIR.mkdir(parents=True, exist_ok=True)tmp = HISTORY.with_suffix(".json.tmp")tmp.write_text(json.dumps(h, indent=1), encoding="utf-8")tmp.replace(HISTORY)def _cust(h: dict, user_id, email="") -> dict:key = str(user_id) if user_id not in (None, "", 0) else f"email:{(email or '').lower()}"return h["customers"].setdefault(key, {"user_id": user_id, "email": (email or "").lower(),"first_name": "", "last_name": "", "company": "", "phone": "","store_user_group": "", "baseline": None, "orders": {},})def load_merge_registry() -> tuple[dict, list]:"""Return ({absorbed_key: survivor_key}, raw_entries). Tolerant by design:a missing or malformed registry must never kill the scheduled run; it onlymeans merges don't apply this week."""if not MERGE_REGISTRY.exists():return {}, []try:reg = json.loads(MERGE_REGISTRY.read_text(encoding="utf-8"))entries = reg.get("merges", [])except (json.JSONDecodeError, OSError, AttributeError) as e:print(f"WARNING: merge registry unreadable ({e}): continuing UNMERGED. "f"Fix {MERGE_REGISTRY} and re-run.")return {}, []aliases: dict[str, str] = {}survivors = set()for m in entries:s = str(m.get("survivor") or "").strip()if not s:print(f"WARNING: registry entry with no survivor skipped: {m}")continuesurvivors.add(s)for a in m.get("absorbed") or []:a = str(a).strip()if not a or a == s:continueif a in aliases and aliases[a] != s:print(f"WARNING: {a} absorbed by both {aliases[a]} and {s}: ""keeping the first, fix the registry.")continuealiases[a] = sbad = survivors & set(aliases)if bad:print(f"WARNING: {sorted(bad)} are both survivor and absorbed (chained ""merges are not supported): those entries may not fold fully.")return aliases, entriesdef apply_merges(metrics: dict, aliases: dict) -> tuple[dict, list[str]]:"""Fold absorbed accounts' computed metrics into their survivors. Runs aftercompute_metrics, before classification, so tiers see combined lifetime valueand absorbed accounts never classify, get stubs, or enter the XML census."""folded = []for a, s in aliases.items():if a not in metrics:continue # already folded / not in history: no-opif s not in metrics:print(f"WARNING: merge survivor {s} not in history: leaving "f"absorbed account {a} unmerged.")continueav, sv = metrics.pop(a), metrics[s]sv["ltv"] = round(sv["ltv"] + av["ltv"], 2)sv["repeat_revenue"] = round(sv["repeat_revenue"] + av["repeat_revenue"], 2)sv["total_orders_count"] += av["total_orders_count"]sv["post_baseline_orders"] += av["post_baseline_orders"]firsts = [d for d in (sv["first_order_date"], av["first_order_date"]) if d]sv["first_order_date"] = min(firsts) if firsts else ""sv["last_order_date"] = max(sv["last_order_date"], av["last_order_date"])sv["has_baseline"] = sv["has_baseline"] or av["has_baseline"]sv.setdefault("merged_from", []).append(a)folded.append(f"{a}->{s}")return metrics, foldeddef _iter_orders(raw) -> list[dict]:"""Collect the order dicts from whatever shape the Plinth dump takes."""out = []def eat(obj):if isinstance(obj, list):for o in obj:eat(o)elif isinstance(obj, dict):if "order_map" in obj and isinstance(obj["order_map"], dict):for o in obj["order_map"].values():out.append(o)elif "order_ref" in obj:out.append(obj)else:for v in obj.values():if isinstance(v, dict) and "order_ref" in v:out.append(v)else:eat(v)eat(raw)return outdef to_float(v) -> float:try:return float(str(v).replace("$", "").replace(",", "").strip() or 0)except (TypeError, ValueError):return 0.0def _order_date(o) -> str | None:d = o.get("closed_on") or ""return d.split(" ")[0] if d else Nonedef cmd_ingest(args):cfg = load_config()suspect_at = int(cfg.get("suspect_row_count") or 50)h = load_history(required=False)added = updated = seen = 0for path in args.orders:raw = json.loads(Path(path).read_text(encoding="utf-8"))orders = _iter_orders(raw)seen += len(orders)for o in orders:oid = str(o.get("order_ref"))if oid in (None, "None", ""):continuec = _cust(h, o.get("account_ref"), o.get("email"))for f, k in (("first_name", "first_name"),("last_name", "last_name"),("company", "company_name"),("phone", "phone"),("store_user_group", "customer_group")):v = o.get(k)if v not in (None, ""):c[f] = str(v)em = (o.get("email") or "").strip().lower()if em:c["email"] = c["email"] or emif em not in c.get("known_emails", []):c.setdefault("known_emails", []).append(em)rec = {"grand_total": to_float(o.get("grand_total")),"subtotal": to_float(o.get("subtotal")),"date": _order_date(o),"status": o.get("status_code"),"last_seen": TODAY,}if oid in c["orders"]:prev = c["orders"][oid]rec["first_seen"] = prev.get("first_seen", TODAY)if any(prev.get(k) != rec[k] for k in ("grand_total", "subtotal", "status", "date")):updated += 1c["orders"][oid] = recelse:rec["first_seen"] = TODAYc["orders"][oid] = recadded += 1h["meta"]["last_ingest"] = NOWsave_history(h)# The convergence loop reads this line: keep paging while "added" > 0.print(f"INGEST: parsed={seen} added={added} updated={updated} "f"customers={len(h['customers'])}")if seen >= suspect_at * len(args.orders):print(f"NOTE: page(s) at/over suspect_row_count ({suspect_at}): likely truncated. ""Keep paging (offset) or narrow the date window until added=0.")def compute_metrics(h: dict, cfg: dict) -> dict:amount_field = cfg.get("amount_field", "grand_total")counted = set(cfg.get("counted_order_statuses") or [])excluded = set(cfg.get("excluded_order_statuses") or [])out = {}for key, c in h["customers"].items():base = c.get("baseline")as_of = (base or {}).get("as_of", "")post = []for oid, o in c["orders"].items():if o.get("status") in excluded:continueif counted and o.get("status") not in counted:continued = o.get("date")# With a baseline, only orders strictly after as_of add to it.# Undated orders (not yet finished) are treated as recent -> post.if base and d and d <= as_of:continuepost.append(o)post_sum = round(sum(to_float(o.get(amount_field)) for o in post), 2)post_dates = sorted(o["date"] for o in post if o.get("date"))if base:ltv = round(base["ltv"] + post_sum, 2)n = base["orders_count"] + len(post)first = base.get("first_order_date") or (post_dates[0] if post_dates else "")last = max(filter(None, [base.get("last_order_date", ""),post_dates[-1] if post_dates else ""])) if (base.get("last_order_date") or post_dates) else ""post_repeat = post_sum if base["orders_count"] > 0 else (round(post_sum - to_float(post[0].get(amount_field)), 2) if post else 0.0)repeat = round((base.get("repeat_revenue") or 0.0) + post_repeat, 2)else:ltv, n = post_sum, len(post)first = post_dates[0] if post_dates else ""last = post_dates[-1] if post_dates else ""amts = [to_float(o.get(amount_field)) for o insorted(post, key=lambda x: x.get("date") or "9999")]repeat = round(sum(amts[1:]), 2) if len(amts) > 1 else 0.0out[key] = {"user_id": c.get("user_id"), "email": c.get("email", ""),"first_name": c.get("first_name", ""), "last_name": c.get("last_name", ""),"company": c.get("company", ""), "phone": c.get("phone", ""),"store_user_group": c.get("store_user_group", ""),"has_baseline": bool(base), "post_baseline_orders": len(post),"total_orders_count": n, "ltv": ltv, "repeat_revenue": repeat,"first_order_date": first or "", "last_order_date": last or "",}return out