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

census.py

Language Python15.1 KB356 lines

07-Automation/partner_pipeline/census.py

  1. """The XML census: refresh-xml and reconcile-deals."""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. import shutil
  6. import sys
  7. from pathlib import Path
  8. from .config import NON_PARTNER, guard_or_exit
  9. from .history import load_merge_registry
  10. from .paths import CLIENTS_DIR, OUT_DIR, TODAY, XML_MAP
  11. from .stages import FUNNEL_DISPLAY, FUNNEL_KEYS, stage_status
  12. from .stubs import index_existing_stubs, mirror_stub_pipeline, safe_stub_name
  13. def norm_key(s) -> str:
  14. """Lowercase letters and digits only: how names are compared across Plinth,
  15. Bitrix and the census."""
  16. return re.sub(r"[^a-z0-9]", "", str(s or "").lower())
  17. def _map_chunk_id(r: dict) -> str | None:
  18. """Census key for a client with no Bitrix contact yet: the Plinth user_id, which is
  19. stable and unique, namespaced as pl-<id> so it can never collide with a Bitrix
  20. contact or tracker id (those key the pipeline-mapped chunks)."""
  21. uid = r.get("user_id")
  22. if uid in (None, ""):
  23. return None
  24. return f"pl-{uid}"
  25. def _resolve_stub(r: dict, idx: dict) -> tuple[str, dict]:
  26. """Return (stub_file_name, stub_frontmatter) for a plan row. Prefers the real stub
  27. on disk (write-stubs runs first), so identity mirrors the stub per the XML contract;
  28. falls back to the deterministic safe-name if somehow absent."""
  29. email = (r.get("email") or "").strip().lower()
  30. name = f"{(r.get('first_name') or '')} {(r.get('last_name') or '')}".strip().lower()
  31. # name first: for a shared company mailbox (two different people, one email) the
  32. # name disambiguates to the right stub; email is the fallback for the common
  33. # unique-email case (same result there).
  34. rec = idx["by_name"].get(name) or idx["by_email"].get(email)
  35. if rec:
  36. return rec["path"].name, rec.get("fm", {})
  37. return safe_stub_name(r.get("first_name"), r.get("last_name"), r.get("email")) + ".md", {}
  38. def _gen_map_summary(full_name, user_group, company, industry, rel_path) -> str:
  39. s = f"{full_name} is a {user_group} client" if user_group else f"{full_name} is a partner client"
  40. if company:
  41. s += f" at {company}"
  42. if industry:
  43. s += f" in the {industry} space"
  44. s += (". Not currently mapped to any outreach pipeline "
  45. "(logged for accounting; pipeline placement to be populated later). "
  46. f"Full enrichment profile: {rel_path}.")
  47. return s
  48. def _new_client_chunk(ET, r: dict, idx: dict):
  49. """Build a self-contained <client> chunk with an empty pipeline section
  50. (<pipelines count="0" />) for a partner client not yet in any Bitrix pipeline.
  51. Element order matches the schema-3.0 pipeline-mapped chunks."""
  52. cid = _map_chunk_id(r)
  53. file_name, fm = _resolve_stub(r, idx)
  54. rel_path = f"/03-Clients/client-stubs/{file_name}"
  55. first = (fm.get("first_name") or r.get("first_name") or "")
  56. last = (fm.get("last_name") or r.get("last_name") or "")
  57. full = f"{first} {last}".strip() or (r.get("email") or "")
  58. email = (fm.get("email") or r.get("email") or "")
  59. company = (fm.get("company") or r.get("company") or "")
  60. title = (fm.get("title") or "")
  61. industry = (fm.get("industry") or "")
  62. linkedin = (fm.get("linkedin") or "")
  63. website = (fm.get("company_website") or "")
  64. user_group = (r.get("assigned_tier") or "")
  65. # schema 3.0 dual identifiers, mirroring the stub. bitrix_id is the real CRM contact id
  66. # (empty for a Plinth-sourced partner client with no Bitrix contact yet); plinth_id
  67. # is the Plinth backend user_id, which we always have.
  68. bitrix_id = (fm.get("bitrix_id") or "")
  69. plinth_id = (fm.get("plinth_id") or r.get("user_id") or "")
  70. c = ET.Element("client", {"id": cid})
  71. def sub(tag, text=""):
  72. e = ET.SubElement(c, tag)
  73. if text not in (None, ""):
  74. e.text = str(text)
  75. return e
  76. sub("full_name", full)
  77. sub("first_name", first)
  78. sub("last_name", last)
  79. sub("email", email)
  80. sub("bitrix_id", bitrix_id) # schema 3.0
  81. sub("plinth_id", plinth_id) # schema 3.0
  82. sub("company", company)
  83. sub("title", title)
  84. sub("industry", industry)
  85. sub("tier") # no legacy tracker tier for Plinth-sourced partner clients
  86. sub("user_group", user_group)
  87. sub("linkedin", linkedin)
  88. sub("company_website", website)
  89. sub("summary", _gen_map_summary(full, user_group, company, industry, rel_path))
  90. stub = ET.SubElement(c, "stub")
  91. ET.SubElement(stub, "file_name").text = file_name
  92. ET.SubElement(stub, "relative_path").text = rel_path
  93. ET.SubElement(c, "pipelines", {"count": "0"}) # empty on purpose; populate later
  94. ET.SubElement(c, "last_modified").text = TODAY
  95. return c, email.strip().lower(), cid
  96. def cmd_refresh_xml(args):
  97. guard_or_exit()
  98. import xml.etree.ElementTree as ET
  99. if not XML_MAP.exists():
  100. sys.exit(f"XML map not found: {XML_MAP}")
  101. backup = OUT_DIR / f"partner-client-master-list.backup-{TODAY}.xml"
  102. OUT_DIR.mkdir(parents=True, exist_ok=True)
  103. shutil.copy2(XML_MAP, backup)
  104. tree = ET.parse(XML_MAP)
  105. root = tree.getroot()
  106. # merge registry: an absorbed account must not exist in the census. Drop any
  107. # absorbed pl-<id> chunk (self-healing if one was re-added by hand or sync);
  108. # Bitrix-keyed chunks are never touched by this.
  109. aliases, _ = load_merge_registry()
  110. removed_merged = []
  111. if aliases:
  112. for el in list(root.findall("client")):
  113. cid = el.get("id") or ""
  114. if cid.startswith("pl-") and cid[3:] in aliases:
  115. root.remove(el)
  116. removed_merged.append(cid)
  117. def _name_of(el):
  118. f = (el.findtext("first_name") or "").strip().lower()
  119. l = (el.findtext("last_name") or "").strip().lower()
  120. return f"{f} {l}".strip()
  121. # Index the existing map. Distinguish the pipeline-mapped chunks (real Bitrix
  122. # contact / tracker ids) from the map-only chunks we key by pl-<user_id>.
  123. # by_id : every chunk by its id (idempotency across runs for pl- chunks)
  124. # by_email_pl : pipeline chunk emails only (never duplicate a Bitrix identity)
  125. # seen_person : (email, name) -> pl- chunk, so two Plinth accounts that are the
  126. # same person (same name + email) share one chunk, while two
  127. # different people on one company mailbox each get their own.
  128. by_id, by_email_pl, seen_person = {}, {}, {}
  129. for client in root.findall("client"):
  130. cid = client.get("id")
  131. if cid:
  132. by_id[cid] = client
  133. e = (client.findtext("email") or "").strip().lower()
  134. if (cid or "").startswith("pl-"):
  135. seen_person[(e, _name_of(client))] = client
  136. elif e:
  137. by_email_pl[e] = client
  138. idx = index_existing_stubs()
  139. plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
  140. stamped = added = skipped = 0
  141. for r in plan["rows"]:
  142. # Map every partner client, whether or not they sit in a pipeline yet.
  143. if r["assigned_tier"] in NON_PARTNER:
  144. continue
  145. email = (r.get("email") or "").strip().lower()
  146. name = f"{(r.get('first_name') or '')} {(r.get('last_name') or '')}".strip().lower()
  147. cid = _map_chunk_id(r)
  148. def _stamp(el):
  149. lm = el.find("last_modified")
  150. if lm is None:
  151. lm = ET.SubElement(el, "last_modified")
  152. lm.text = TODAY
  153. # 1) this exact Plinth account is already mapped (prior run) -> stamp
  154. if cid and cid in by_id:
  155. _stamp(by_id[cid])
  156. stamped += 1
  157. continue
  158. # 2) already a pipeline-mapped (Bitrix) chunk for this email -> stamp, don't duplicate
  159. if email and email in by_email_pl:
  160. _stamp(by_email_pl[email])
  161. stamped += 1
  162. continue
  163. # 3) same person we already added under another Plinth account -> stamp, don't duplicate
  164. if (email, name) in seen_person:
  165. _stamp(seen_person[(email, name)])
  166. stamped += 1
  167. continue
  168. # 4) otherwise this partner client is not yet in the map -> add an empty-pipeline chunk
  169. if not cid and not email:
  170. skipped += 1 # nothing stable to key on; leave for a human
  171. continue
  172. chunk, e2, cid2 = _new_client_chunk(ET, r, idx)
  173. root.append(chunk)
  174. if cid2:
  175. by_id[cid2] = chunk
  176. seen_person[(e2, name)] = chunk
  177. added += 1
  178. ET.indent(tree, space=" ")
  179. tree.write(XML_MAP, encoding="UTF-8", xml_declaration=True)
  180. total = len(root.findall("client"))
  181. if removed_merged:
  182. print("MERGE REGISTRY: removed absorbed chunk(s) from the census: "
  183. + ", ".join(removed_merged))
  184. print(f"XML: {stamped} existing chunks stamped, {added} new empty-pipeline chunks added "
  185. f"(id=pl-<user_id>, <pipelines count=\"0\" />). "
  186. f"Partner clients accounted for this run: {stamped + added}"
  187. + (f"; {skipped} skipped (no email/user_id)" if skipped else "")
  188. + f". Map now holds {total} <client> chunks. Backup: {backup}")
  189. print("REMINDER: append one LEDGER entry for this change-set.")
  190. def _regen_summary_clause(c):
  191. s = c.find("summary")
  192. if s is None or not s.text:
  193. return
  194. ps = c.findall("pipelines/pipeline")
  195. NUM = {1: "one", 2: "two", 3: "three"}
  196. if ps:
  197. parts = [f"{p.findtext('display_name')} ({p.findtext('status')}, "
  198. f"{p.findtext('stage')})" for p in ps]
  199. n = len(parts)
  200. clause = (f"Currently mapped to {NUM.get(n, n)} outreach pipeline"
  201. f"{'s' if n > 1 else ''}: "
  202. f"{' and '.join(parts) if n <= 2 else '; '.join(parts)}.")
  203. else:
  204. clause = ("Not currently mapped to any outreach pipeline (logged for "
  205. "accounting; pipeline placement to be populated later).")
  206. s.text = re.sub(r"(Not currently mapped to any outreach pipeline[^.]*\.|"
  207. r"Currently mapped to [^:.]+:[^.]*\.)", clause, s.text, count=1)
  208. def cmd_reconcile_deals(args):
  209. """Absorb a Bitrix deals pull (JSON: {"deals":[{deal_id,cat,stage,title,created}]})
  210. into the XML census + stubs: one block per (client, funnel) recording the newest
  211. deal. Deals with no / ambiguous name match are reported, never guessed."""
  212. guard_or_exit()
  213. import xml.etree.ElementTree as ET
  214. from collections import defaultdict
  215. deals = []
  216. for path in args.deals:
  217. deals += json.loads(Path(path).read_text(encoding="utf-8")).get("deals", [])
  218. if not XML_MAP.exists():
  219. sys.exit(f"XML map not found: {XML_MAP}")
  220. backup = OUT_DIR / f"partner-client-master-list.backup-{TODAY}-reconcile.xml"
  221. OUT_DIR.mkdir(parents=True, exist_ok=True)
  222. shutil.copy2(XML_MAP, backup)
  223. tree = ET.parse(XML_MAP)
  224. root = tree.getroot()
  225. byname = defaultdict(list)
  226. for c in root.findall("client"):
  227. byname[norm_key(c.findtext("full_name"))].append(c)
  228. pair = defaultdict(list)
  229. unmatched, ambiguous = [], []
  230. for d in deals:
  231. if d.get("cat") not in FUNNEL_KEYS:
  232. continue
  233. cand = byname.get(norm_key(d.get("title")), [])
  234. if not cand:
  235. unmatched.append(f"{d['cat']}/{d.get('stage')}/{d.get('deal_id')} {d.get('title')}")
  236. continue
  237. if len(cand) > 1:
  238. withp = [x for x in cand if x.findall("pipelines/pipeline")]
  239. if len(withp) == 1:
  240. cand = withp
  241. else:
  242. ambiguous.append(f"{d.get('title')} -> "
  243. f"{[x.get('id') for x in cand]} (deal {d.get('deal_id')})")
  244. continue
  245. pair[(id(cand[0]), FUNNEL_KEYS[d["cat"]])].append((cand[0], d))
  246. new = updated = current = 0
  247. touched = set()
  248. def _dt(s):
  249. m = re.search(r"([A-Z][a-z]{2}) (\d{1,2}) (\d{4})", s or "")
  250. MON = {x: i + 1 for i, x in enumerate(
  251. "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split())}
  252. if m:
  253. return f"{m.group(3)}-{MON[m.group(1)]:02d}-{int(m.group(2)):02d}"
  254. return (s or "")[:10] if re.match(r"\d{4}-\d{2}-\d{2}", s or "") else ""
  255. for (_, key), items in pair.items():
  256. items.sort(key=lambda x: int(x[1]["deal_id"]))
  257. c, d = items[-1]
  258. stage, status = stage_status(d["cat"], d["stage"])
  259. pls = c.find("pipelines")
  260. if pls is None:
  261. pls = ET.SubElement(c, "pipelines")
  262. blk = None
  263. for p in pls.findall("pipeline"):
  264. if (p.get("name") or p.get("key")) == key:
  265. blk = p
  266. break
  267. if blk is not None:
  268. old = (blk.findtext("bitrix_deal_id") or "").strip()
  269. if old and int(old) > int(d["deal_id"]):
  270. current += 1
  271. continue
  272. changed = False
  273. for tag, val in (("bitrix_deal_id", d["deal_id"]), ("stage", stage),
  274. ("status", status), ("display_name", FUNNEL_DISPLAY[d["cat"]])):
  275. e = blk.find(tag)
  276. if e is None:
  277. e = ET.SubElement(blk, tag)
  278. if (e.text or "").strip() != val:
  279. e.text = val
  280. changed = True
  281. ed = blk.find("entered_date")
  282. if ed is None:
  283. ed = ET.SubElement(blk, "entered_date")
  284. if not (ed.text or "").strip():
  285. ed.text = _dt(d.get("created"))
  286. changed = changed or bool(ed.text)
  287. if changed:
  288. updated += 1
  289. touched.add(id(c))
  290. else:
  291. current += 1
  292. else:
  293. p = ET.SubElement(pls, "pipeline", {"name": key})
  294. ET.SubElement(p, "client_ref", {"id": c.get("id") or ""}).text = \
  295. c.findtext("full_name")
  296. for tag, val in (("display_name", FUNNEL_DISPLAY[d["cat"]]),
  297. ("bitrix_category_id", d["cat"]),
  298. ("bitrix_deal_id", d["deal_id"]), ("stage", stage),
  299. ("status", status), ("entered_date", _dt(d.get("created"))),
  300. ("last_touch", ""), ("next_step", ""), ("notes", "")):
  301. e = ET.SubElement(p, tag)
  302. if val:
  303. e.text = val
  304. new += 1
  305. touched.add(id(c))
  306. if id(c) in touched:
  307. pls.set("count", str(len(pls.findall("pipeline"))))
  308. _regen_summary_clause(c)
  309. lm = c.find("last_modified")
  310. if lm is None:
  311. lm = ET.SubElement(c, "last_modified")
  312. lm.text = TODAY
  313. fn = c.findtext("stub/file_name")
  314. if fn:
  315. mirror_stub_pipeline(CLIENTS_DIR / fn, key, stage, status,
  316. _dt(d.get("created")))
  317. ET.indent(tree, space=" ")
  318. tree.write(XML_MAP, encoding="UTF-8", xml_declaration=True)
  319. print(f"RECONCILE: {new} new blocks, {updated} updated, {current} already current; "
  320. f"{len(unmatched)} deals unmatched, {len(ambiguous)} ambiguous (not attached). "
  321. f"Backup: {backup}")
  322. for u in unmatched[:20]:
  323. print(" unmatched:", u)
  324. for a in ambiguous[:20]:
  325. print(" ambiguous:", a)
  326. print("REMINDER: append one LEDGER entry for this change-set.")