mattrguerra/meridian-outreach-vaultmainPrivate on GitHub, published here in full

history.py

Language Python9.9 KB242 lines

07-Automation/partner_pipeline/history.py

  1. """The local order history, the merge registry, and ingest."""
  2. from __future__ import annotations
  3. import json
  4. import sys
  5. from pathlib import Path
  6. from .config import load_config
  7. from .paths import DATA_DIR, HISTORY, MERGE_REGISTRY, NOW, TODAY
  8. def load_history(required: bool = True) -> dict:
  9. if not HISTORY.exists():
  10. if required:
  11. sys.exit(f"History store not found: {HISTORY}\n"
  12. "Run seed-baseline first (or ingest, for a no-baseline start).")
  13. return {"meta": {"schema": 1, "created": NOW, "baseline_source": None,
  14. "last_ingest": None, "previous_computed": {}},
  15. "customers": {}}
  16. return json.loads(HISTORY.read_text(encoding="utf-8"))
  17. def save_history(h: dict):
  18. DATA_DIR.mkdir(parents=True, exist_ok=True)
  19. tmp = HISTORY.with_suffix(".json.tmp")
  20. tmp.write_text(json.dumps(h, indent=1), encoding="utf-8")
  21. tmp.replace(HISTORY)
  22. def _cust(h: dict, user_id, email="") -> dict:
  23. key = str(user_id) if user_id not in (None, "", 0) else f"email:{(email or '').lower()}"
  24. return h["customers"].setdefault(key, {
  25. "user_id": user_id, "email": (email or "").lower(),
  26. "first_name": "", "last_name": "", "company": "", "phone": "",
  27. "store_user_group": "", "baseline": None, "orders": {},
  28. })
  29. def load_merge_registry() -> tuple[dict, list]:
  30. """Return ({absorbed_key: survivor_key}, raw_entries). Tolerant by design:
  31. a missing or malformed registry must never kill the scheduled run; it only
  32. means merges don't apply this week."""
  33. if not MERGE_REGISTRY.exists():
  34. return {}, []
  35. try:
  36. reg = json.loads(MERGE_REGISTRY.read_text(encoding="utf-8"))
  37. entries = reg.get("merges", [])
  38. except (json.JSONDecodeError, OSError, AttributeError) as e:
  39. print(f"WARNING: merge registry unreadable ({e}): continuing UNMERGED. "
  40. f"Fix {MERGE_REGISTRY} and re-run.")
  41. return {}, []
  42. aliases: dict[str, str] = {}
  43. survivors = set()
  44. for m in entries:
  45. s = str(m.get("survivor") or "").strip()
  46. if not s:
  47. print(f"WARNING: registry entry with no survivor skipped: {m}")
  48. continue
  49. survivors.add(s)
  50. for a in m.get("absorbed") or []:
  51. a = str(a).strip()
  52. if not a or a == s:
  53. continue
  54. if a in aliases and aliases[a] != s:
  55. print(f"WARNING: {a} absorbed by both {aliases[a]} and {s}: "
  56. "keeping the first, fix the registry.")
  57. continue
  58. aliases[a] = s
  59. bad = survivors & set(aliases)
  60. if bad:
  61. print(f"WARNING: {sorted(bad)} are both survivor and absorbed (chained "
  62. "merges are not supported): those entries may not fold fully.")
  63. return aliases, entries
  64. def apply_merges(metrics: dict, aliases: dict) -> tuple[dict, list[str]]:
  65. """Fold absorbed accounts' computed metrics into their survivors. Runs after
  66. compute_metrics, before classification, so tiers see combined lifetime value
  67. and absorbed accounts never classify, get stubs, or enter the XML census."""
  68. folded = []
  69. for a, s in aliases.items():
  70. if a not in metrics:
  71. continue # already folded / not in history: no-op
  72. if s not in metrics:
  73. print(f"WARNING: merge survivor {s} not in history: leaving "
  74. f"absorbed account {a} unmerged.")
  75. continue
  76. av, sv = metrics.pop(a), metrics[s]
  77. sv["ltv"] = round(sv["ltv"] + av["ltv"], 2)
  78. sv["repeat_revenue"] = round(sv["repeat_revenue"] + av["repeat_revenue"], 2)
  79. sv["total_orders_count"] += av["total_orders_count"]
  80. sv["post_baseline_orders"] += av["post_baseline_orders"]
  81. firsts = [d for d in (sv["first_order_date"], av["first_order_date"]) if d]
  82. sv["first_order_date"] = min(firsts) if firsts else ""
  83. sv["last_order_date"] = max(sv["last_order_date"], av["last_order_date"])
  84. sv["has_baseline"] = sv["has_baseline"] or av["has_baseline"]
  85. sv.setdefault("merged_from", []).append(a)
  86. folded.append(f"{a}->{s}")
  87. return metrics, folded
  88. def _iter_orders(raw) -> list[dict]:
  89. """Collect the order dicts from whatever shape the Plinth dump takes."""
  90. out = []
  91. def eat(obj):
  92. if isinstance(obj, list):
  93. for o in obj:
  94. eat(o)
  95. elif isinstance(obj, dict):
  96. if "order_map" in obj and isinstance(obj["order_map"], dict):
  97. for o in obj["order_map"].values():
  98. out.append(o)
  99. elif "order_ref" in obj:
  100. out.append(obj)
  101. else:
  102. for v in obj.values():
  103. if isinstance(v, dict) and "order_ref" in v:
  104. out.append(v)
  105. else:
  106. eat(v)
  107. eat(raw)
  108. return out
  109. def to_float(v) -> float:
  110. try:
  111. return float(str(v).replace("$", "").replace(",", "").strip() or 0)
  112. except (TypeError, ValueError):
  113. return 0.0
  114. def _order_date(o) -> str | None:
  115. d = o.get("closed_on") or ""
  116. return d.split(" ")[0] if d else None
  117. def cmd_ingest(args):
  118. cfg = load_config()
  119. suspect_at = int(cfg.get("suspect_row_count") or 50)
  120. h = load_history(required=False)
  121. added = updated = seen = 0
  122. for path in args.orders:
  123. raw = json.loads(Path(path).read_text(encoding="utf-8"))
  124. orders = _iter_orders(raw)
  125. seen += len(orders)
  126. for o in orders:
  127. oid = str(o.get("order_ref"))
  128. if oid in (None, "None", ""):
  129. continue
  130. c = _cust(h, o.get("account_ref"), o.get("email"))
  131. for f, k in (("first_name", "first_name"),
  132. ("last_name", "last_name"),
  133. ("company", "company_name"),
  134. ("phone", "phone"),
  135. ("store_user_group", "customer_group")):
  136. v = o.get(k)
  137. if v not in (None, ""):
  138. c[f] = str(v)
  139. em = (o.get("email") or "").strip().lower()
  140. if em:
  141. c["email"] = c["email"] or em
  142. if em not in c.get("known_emails", []):
  143. c.setdefault("known_emails", []).append(em)
  144. rec = {
  145. "grand_total": to_float(o.get("grand_total")),
  146. "subtotal": to_float(o.get("subtotal")),
  147. "date": _order_date(o),
  148. "status": o.get("status_code"),
  149. "last_seen": TODAY,
  150. }
  151. if oid in c["orders"]:
  152. prev = c["orders"][oid]
  153. rec["first_seen"] = prev.get("first_seen", TODAY)
  154. if any(prev.get(k) != rec[k] for k in ("grand_total", "subtotal", "status", "date")):
  155. updated += 1
  156. c["orders"][oid] = rec
  157. else:
  158. rec["first_seen"] = TODAY
  159. c["orders"][oid] = rec
  160. added += 1
  161. h["meta"]["last_ingest"] = NOW
  162. save_history(h)
  163. # The convergence loop reads this line: keep paging while "added" > 0.
  164. print(f"INGEST: parsed={seen} added={added} updated={updated} "
  165. f"customers={len(h['customers'])}")
  166. if seen >= suspect_at * len(args.orders):
  167. print(f"NOTE: page(s) at/over suspect_row_count ({suspect_at}): likely truncated. "
  168. "Keep paging (offset) or narrow the date window until added=0.")
  169. def compute_metrics(h: dict, cfg: dict) -> dict:
  170. amount_field = cfg.get("amount_field", "grand_total")
  171. counted = set(cfg.get("counted_order_statuses") or [])
  172. excluded = set(cfg.get("excluded_order_statuses") or [])
  173. out = {}
  174. for key, c in h["customers"].items():
  175. base = c.get("baseline")
  176. as_of = (base or {}).get("as_of", "")
  177. post = []
  178. for oid, o in c["orders"].items():
  179. if o.get("status") in excluded:
  180. continue
  181. if counted and o.get("status") not in counted:
  182. continue
  183. d = o.get("date")
  184. # With a baseline, only orders strictly after as_of add to it.
  185. # Undated orders (not yet finished) are treated as recent -> post.
  186. if base and d and d <= as_of:
  187. continue
  188. post.append(o)
  189. post_sum = round(sum(to_float(o.get(amount_field)) for o in post), 2)
  190. post_dates = sorted(o["date"] for o in post if o.get("date"))
  191. if base:
  192. ltv = round(base["ltv"] + post_sum, 2)
  193. n = base["orders_count"] + len(post)
  194. first = base.get("first_order_date") or (post_dates[0] if post_dates else "")
  195. last = max(filter(None, [base.get("last_order_date", ""),
  196. post_dates[-1] if post_dates else ""])) if (
  197. base.get("last_order_date") or post_dates) else ""
  198. post_repeat = post_sum if base["orders_count"] > 0 else (
  199. round(post_sum - to_float(post[0].get(amount_field)), 2) if post else 0.0)
  200. repeat = round((base.get("repeat_revenue") or 0.0) + post_repeat, 2)
  201. else:
  202. ltv, n = post_sum, len(post)
  203. first = post_dates[0] if post_dates else ""
  204. last = post_dates[-1] if post_dates else ""
  205. amts = [to_float(o.get(amount_field)) for o in
  206. sorted(post, key=lambda x: x.get("date") or "9999")]
  207. repeat = round(sum(amts[1:]), 2) if len(amts) > 1 else 0.0
  208. out[key] = {
  209. "user_id": c.get("user_id"), "email": c.get("email", ""),
  210. "first_name": c.get("first_name", ""), "last_name": c.get("last_name", ""),
  211. "company": c.get("company", ""), "phone": c.get("phone", ""),
  212. "store_user_group": c.get("store_user_group", ""),
  213. "has_baseline": bool(base), "post_baseline_orders": len(post),
  214. "total_orders_count": n, "ltv": ltv, "repeat_revenue": repeat,
  215. "first_order_date": first or "", "last_order_date": last or "",
  216. }
  217. return out