Organized
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bridge Redmine ticket entities → NMI agent CLI (mrscClient-php bin/nmi).
|
||||
|
||||
Pattern extract stays in redmine_ticket_entities.py (no NMI I/O).
|
||||
This module shells out to the NMI CLI for resolve / cs-brief using those hints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
# Prefer env overrides for agents/profiles
|
||||
DEFAULT_NMI_BIN = os.environ.get(
|
||||
"NMI_CLI",
|
||||
"/home/jason/projects/mrscClient-php/bin/nmi",
|
||||
)
|
||||
DEFAULT_ENV_FILE = os.environ.get(
|
||||
"NMI_ENV_FILE",
|
||||
"/home/jason/projects/mrscClient-php/examples/modern/.env",
|
||||
)
|
||||
DEFAULT_MODE = os.environ.get("NMI_MODE", "dev")
|
||||
|
||||
# Entity types that map cleanly into NMI resolve ids
|
||||
_TRACKING_TYPES = {
|
||||
"tracking_ups",
|
||||
"tracking_usps",
|
||||
"tracking_fedex_candidate",
|
||||
}
|
||||
_ORDER_TYPES = {"order_amazon", "order_id"}
|
||||
_ITEM_TYPES = {"item_no", "item_or_sku"}
|
||||
_ACCOUNT_TYPES = {"account_ref"}
|
||||
# NMI deep link templates (from obsidian-ldr/INBOX/WIP/Linking to Entities Within NMI.md)
|
||||
NMI_BASE = "https://app.ldrprep.com"
|
||||
NMI_LINKS = {
|
||||
"item_no": {
|
||||
"manager": lambda v: f"{NMI_BASE}/?page=itemManager&itemNo={v}",
|
||||
"statement": lambda v: f"{NMI_BASE}/?page=itemStatement&itemNo={v}",
|
||||
},
|
||||
"item_or_sku": {
|
||||
"manager": lambda v: f"{NMI_BASE}/?page=itemManager&itemNo={v}",
|
||||
},
|
||||
"tracking_ups": {"box_history": lambda v: f"{NMI_BASE}/?page=boxFinder&tracking_number={v}&action=search"},
|
||||
"tracking_usps": {"box_history": lambda v: f"{NMI_BASE}/?page=boxFinder&tracking_number={v}&action=search"},
|
||||
"tracking_fedex_candidate": {"box_history": lambda v: f"{NMI_BASE}/?page=boxFinder&tracking_number={v}&action=search"},
|
||||
"order_id": {"order_summary": lambda v: f"{NMI_BASE}/?page=requests&filter=view&id={v}"},
|
||||
"request_id": {"package_history": lambda v: f"{NMI_BASE}/?page=boxFinder&request_id={v}"},
|
||||
"account_ref": {"user_info": lambda v: f"{NMI_BASE}/?page=userInfo&accountNo={v}"},
|
||||
}
|
||||
|
||||
|
||||
# LDR unit item numbers commonly 9 digits starting with 2
|
||||
_ITEM_NO_BARE = re.compile(r"\b(2\d{8})\b")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NmiRuntimeProfile:
|
||||
"""Resolved NMI execution settings plus a preflight result."""
|
||||
|
||||
mode: str
|
||||
env_file: Path
|
||||
nmi_bin: Path
|
||||
endpoint: Optional[str]
|
||||
ready: bool
|
||||
problem: Optional[str] = None
|
||||
|
||||
|
||||
def resolve_runtime_profile(
|
||||
*,
|
||||
mode: Optional[str] = None,
|
||||
env_file: Optional[str] = None,
|
||||
nmi_bin: Optional[str] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
environ: Optional[dict[str, str]] = None,
|
||||
) -> NmiRuntimeProfile:
|
||||
"""Resolve the exact profile passed to PHP before an NMI subprocess starts.
|
||||
|
||||
An env file that is empty or missing is always rejected. It is too easy for
|
||||
the checked-in example `.env` to otherwise select a surprising dev/default
|
||||
path while making the TUI look successful.
|
||||
"""
|
||||
|
||||
env = os.environ if environ is None else environ
|
||||
requested_mode = (mode or env.get("NMI_MODE") or DEFAULT_MODE).strip().lower()
|
||||
normalized_mode = "production" if requested_mode in {"prod", "production"} else requested_mode
|
||||
selected_env_file = Path(env_file or env.get("NMI_ENV_FILE") or DEFAULT_ENV_FILE).expanduser()
|
||||
selected_nmi_bin = Path(nmi_bin or env.get("NMI_CLI") or DEFAULT_NMI_BIN).expanduser()
|
||||
|
||||
mode_upper = normalized_mode.upper()
|
||||
selected_endpoint = (
|
||||
endpoint
|
||||
or env.get(f"NMI_ENDPOINT_{mode_upper}")
|
||||
or env.get("NMI_ENDPOINT")
|
||||
or None
|
||||
)
|
||||
|
||||
if not selected_env_file.is_file():
|
||||
return NmiRuntimeProfile(
|
||||
mode=normalized_mode,
|
||||
env_file=selected_env_file,
|
||||
nmi_bin=selected_nmi_bin,
|
||||
endpoint=selected_endpoint,
|
||||
ready=False,
|
||||
problem=f"NMI env file does not exist: {selected_env_file}",
|
||||
)
|
||||
if selected_env_file.stat().st_size == 0:
|
||||
return NmiRuntimeProfile(
|
||||
mode=normalized_mode,
|
||||
env_file=selected_env_file,
|
||||
nmi_bin=selected_nmi_bin,
|
||||
endpoint=selected_endpoint,
|
||||
ready=False,
|
||||
problem=f"NMI env file is empty: {selected_env_file}",
|
||||
)
|
||||
if not selected_nmi_bin.is_file():
|
||||
return NmiRuntimeProfile(
|
||||
mode=normalized_mode,
|
||||
env_file=selected_env_file,
|
||||
nmi_bin=selected_nmi_bin,
|
||||
endpoint=selected_endpoint,
|
||||
ready=False,
|
||||
problem=f"NMI CLI does not exist: {selected_nmi_bin}",
|
||||
)
|
||||
|
||||
return NmiRuntimeProfile(
|
||||
mode=normalized_mode,
|
||||
env_file=selected_env_file,
|
||||
nmi_bin=selected_nmi_bin,
|
||||
endpoint=selected_endpoint,
|
||||
ready=True,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NmiLookupPlan:
|
||||
"""Deduped identifiers to try against NMI, highest confidence first."""
|
||||
|
||||
tracking: list[str] = field(default_factory=list)
|
||||
order_id: list[str] = field(default_factory=list)
|
||||
item_no: list[str] = field(default_factory=list)
|
||||
account: list[str] = field(default_factory=list)
|
||||
skipped: list[dict[str, str]] = field(default_factory=list)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not (self.tracking or self.order_id or self.item_no or self.account)
|
||||
|
||||
|
||||
def enrich_entities_with_item_nos(
|
||||
entities: list[dict[str, str]], text: str
|
||||
) -> list[dict[str, str]]:
|
||||
"""Add bare LDR-style item numbers when not already present."""
|
||||
seen = {(e.get("type"), (e.get("value") or "").upper()) for e in entities}
|
||||
out = list(entities)
|
||||
for m in _ITEM_NO_BARE.finditer(text or ""):
|
||||
val = m.group(1)
|
||||
key = ("item_no", val)
|
||||
if key in seen:
|
||||
continue
|
||||
# Avoid treating pure tracking-ish long numbers: item is exactly 9 digits
|
||||
out.append({"type": "item_no", "value": val, "confidence": "medium"})
|
||||
seen.add(key)
|
||||
return out
|
||||
|
||||
|
||||
def plan_from_entities(entities: Iterable[dict[str, str]]) -> NmiLookupPlan:
|
||||
plan = NmiLookupPlan()
|
||||
seen_t: set[str] = set()
|
||||
seen_o: set[str] = set()
|
||||
seen_i: set[str] = set()
|
||||
seen_a: set[str] = set()
|
||||
|
||||
# Sort high confidence first
|
||||
ordered = sorted(
|
||||
entities,
|
||||
key=lambda e: {"high": 0, "medium": 1, "low": 2}.get(e.get("confidence") or "high", 3),
|
||||
)
|
||||
|
||||
for e in ordered:
|
||||
kind = e.get("type") or ""
|
||||
val = (e.get("value") or "").strip()
|
||||
conf = e.get("confidence") or "high"
|
||||
if not val:
|
||||
continue
|
||||
if kind in _TRACKING_TYPES:
|
||||
if conf == "low" and kind == "tracking_fedex_candidate":
|
||||
plan.skipped.append(e)
|
||||
continue
|
||||
u = val.upper()
|
||||
if u not in seen_t:
|
||||
seen_t.add(u)
|
||||
plan.tracking.append(val)
|
||||
elif kind in _ORDER_TYPES:
|
||||
if val not in seen_o:
|
||||
seen_o.add(val)
|
||||
plan.order_id.append(val)
|
||||
elif kind in _ITEM_TYPES or kind == "item_no":
|
||||
# Prefer pure numeric item_no; SKU-like may still help as order/item search later
|
||||
if re.fullmatch(r"\d{6,12}", val):
|
||||
if val not in seen_i:
|
||||
seen_i.add(val)
|
||||
plan.item_no.append(val)
|
||||
else:
|
||||
plan.skipped.append({**e, "reason": "sku_not_numeric_item"})
|
||||
elif kind in _ACCOUNT_TYPES:
|
||||
if val not in seen_a:
|
||||
seen_a.add(val)
|
||||
plan.account.append(val)
|
||||
else:
|
||||
plan.skipped.append(e)
|
||||
return plan
|
||||
|
||||
|
||||
def _php_bin() -> str:
|
||||
return shutil.which("php") or "php"
|
||||
|
||||
|
||||
def run_nmi(
|
||||
args: list[str],
|
||||
*,
|
||||
mode: str = DEFAULT_MODE,
|
||||
env_file: str = DEFAULT_ENV_FILE,
|
||||
nmi_bin: str = DEFAULT_NMI_BIN,
|
||||
endpoint: Optional[str] = None,
|
||||
timeout: int = 60,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
cmd = [_php_bin(), nmi_bin, *args, f"--mode={mode}", f"--env-file={env_file}"]
|
||||
if endpoint:
|
||||
cmd.append(f"--endpoint={endpoint}")
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def try_resolve_ids(
|
||||
plan: NmiLookupPlan,
|
||||
*,
|
||||
mode: str = DEFAULT_MODE,
|
||||
env_file: str = DEFAULT_ENV_FILE,
|
||||
nmi_bin: str = DEFAULT_NMI_BIN,
|
||||
endpoint: Optional[str] = None,
|
||||
include_pictures: bool = False,
|
||||
max_attempts: int = 6,
|
||||
) -> dict[str, Any]:
|
||||
"""Try NMI resolve with identifiers from the plan until one succeeds."""
|
||||
attempts: list[dict[str, Any]] = []
|
||||
candidates: list[list[str]] = []
|
||||
|
||||
for item in plan.item_no:
|
||||
candidates.append(["resolve", f"--item-no={item}"])
|
||||
for tn in plan.tracking:
|
||||
candidates.append(["resolve", f"--tracking={tn}"])
|
||||
for oid in plan.order_id:
|
||||
candidates.append(["resolve", f"--order-id={oid}"])
|
||||
for acct in plan.account:
|
||||
candidates.append(["resolve", f"--account={acct}"])
|
||||
|
||||
if include_pictures:
|
||||
for c in candidates:
|
||||
c.append("--pictures")
|
||||
|
||||
for args in candidates[:max_attempts]:
|
||||
proc = run_nmi(args, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint)
|
||||
attempt = {
|
||||
"args": args,
|
||||
"returncode": proc.returncode,
|
||||
"stderr": (proc.stderr or "").strip()[:500],
|
||||
}
|
||||
if proc.returncode == 0 and (proc.stdout or "").strip():
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
attempt["error"] = f"json_decode: {exc}"
|
||||
attempts.append(attempt)
|
||||
continue
|
||||
attempt["ok"] = True
|
||||
attempts.append(attempt)
|
||||
return {"ok": True, "result": data, "attempts": attempts, "used_args": args}
|
||||
attempt["ok"] = False
|
||||
attempts.append(attempt)
|
||||
|
||||
return {"ok": False, "result": None, "attempts": attempts, "used_args": None}
|
||||
|
||||
|
||||
def try_cs_brief(
|
||||
plan: NmiLookupPlan,
|
||||
*,
|
||||
mode: str = DEFAULT_MODE,
|
||||
env_file: str = DEFAULT_ENV_FILE,
|
||||
nmi_bin: str = DEFAULT_NMI_BIN,
|
||||
endpoint: Optional[str] = None,
|
||||
max_attempts: int = 6,
|
||||
) -> dict[str, Any]:
|
||||
"""Try cs-brief with plan identifiers until one returns text."""
|
||||
attempts: list[dict[str, Any]] = []
|
||||
candidates: list[list[str]] = []
|
||||
for item in plan.item_no:
|
||||
candidates.append(["cs-brief", f"--item-no={item}"])
|
||||
for tn in plan.tracking:
|
||||
candidates.append(["cs-brief", f"--tracking={tn}"])
|
||||
for oid in plan.order_id:
|
||||
candidates.append(["cs-brief", f"--order-id={oid}"])
|
||||
for acct in plan.account:
|
||||
candidates.append(["cs-brief", f"--account={acct}"])
|
||||
|
||||
for args in candidates[:max_attempts]:
|
||||
proc = run_nmi(args, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint)
|
||||
attempt = {
|
||||
"args": args,
|
||||
"returncode": proc.returncode,
|
||||
"stderr": (proc.stderr or "").strip()[:500],
|
||||
}
|
||||
text = (proc.stdout or "").strip()
|
||||
if proc.returncode == 0 and text and "=== NMI CS BRIEF ===" in text:
|
||||
attempt["ok"] = True
|
||||
attempts.append(attempt)
|
||||
return {"ok": True, "text": text + ("\n" if not text.endswith("\n") else ""), "attempts": attempts, "used_args": args}
|
||||
attempt["ok"] = False
|
||||
if text:
|
||||
attempt["stdout_preview"] = text[:200]
|
||||
attempts.append(attempt)
|
||||
|
||||
return {"ok": False, "text": None, "attempts": attempts, "used_args": None}
|
||||
|
||||
|
||||
def brief_for_issue(
|
||||
issue: dict[str, Any],
|
||||
entities: list[dict[str, str]],
|
||||
*,
|
||||
mode: str = DEFAULT_MODE,
|
||||
env_file: str = DEFAULT_ENV_FILE,
|
||||
nmi_bin: str = DEFAULT_NMI_BIN,
|
||||
endpoint: Optional[str] = None,
|
||||
with_history: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
profile = resolve_runtime_profile(
|
||||
mode=mode,
|
||||
env_file=env_file,
|
||||
nmi_bin=nmi_bin,
|
||||
endpoint=endpoint,
|
||||
)
|
||||
runtime = {
|
||||
"mode": profile.mode,
|
||||
"env_file": str(profile.env_file),
|
||||
"nmi_bin": str(profile.nmi_bin),
|
||||
"endpoint": profile.endpoint,
|
||||
"ready": profile.ready,
|
||||
"problem": profile.problem,
|
||||
}
|
||||
|
||||
# Prefer public extract path text rebuild without private imports
|
||||
try:
|
||||
from redmine_ticket_entities import extract_entities_from_issue as _ex
|
||||
|
||||
# rebuild text via extract side-channel: re-run extract on issue already given
|
||||
# Use journals/subject from issue for bare item_no enrichment only
|
||||
chunks = []
|
||||
for key in ("subject", "description"):
|
||||
if issue.get(key):
|
||||
chunks.append(str(issue[key]))
|
||||
journals = issue.get("journals")
|
||||
if isinstance(journals, list):
|
||||
for j in journals[-15:]:
|
||||
if isinstance(j, dict) and j.get("notes"):
|
||||
chunks.append(str(j["notes"]))
|
||||
text = "\n".join(chunks)
|
||||
_ = _ex # silence unused if tree-shaken
|
||||
except Exception:
|
||||
text = str(issue.get("subject") or "") + "\n" + str(issue.get("description") or "")
|
||||
|
||||
ents = enrich_entities_with_item_nos(entities, text)
|
||||
plan = plan_from_entities(ents)
|
||||
out: dict[str, Any] = {
|
||||
"issue_id": issue.get("id"),
|
||||
"runtime": runtime,
|
||||
"plan": {
|
||||
"tracking": plan.tracking,
|
||||
"order_id": plan.order_id,
|
||||
"item_no": plan.item_no,
|
||||
"account": plan.account,
|
||||
"skipped": plan.skipped,
|
||||
},
|
||||
"entities": ents,
|
||||
}
|
||||
if not profile.ready:
|
||||
out["ok"] = False
|
||||
out["error"] = "nmi_runtime_not_ready"
|
||||
return out
|
||||
if plan.is_empty():
|
||||
out["ok"] = False
|
||||
out["error"] = "no_nmi_candidates"
|
||||
return out
|
||||
|
||||
brief = try_cs_brief(plan, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint)
|
||||
out["brief"] = brief
|
||||
out["ok"] = bool(brief.get("ok"))
|
||||
if not out["ok"]:
|
||||
# still try resolve for structured debug
|
||||
out["resolve"] = try_resolve_ids(
|
||||
plan, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint
|
||||
)
|
||||
out["error"] = "nmi_lookup_failed"
|
||||
# Pull item history (new getItemHistory) when requested and item_no present
|
||||
if with_history:
|
||||
out = enrich_with_history(out, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint)
|
||||
return out
|
||||
|
||||
|
||||
def format_private_note(payload: dict[str, Any]) -> str:
|
||||
"""Build a Redmine private note body from nmi brief result.
|
||||
|
||||
Enhanced with:
|
||||
- Deep NMI links (Item Manager, Box History, Request pages etc.) per the WIP note
|
||||
- Item history from the new getItemHistory endpoint (billings, who moved, condition changes)
|
||||
"""
|
||||
lines = [
|
||||
"=== Auto NMI pre-fetch (agent) ===",
|
||||
f"issue_id: {payload.get('issue_id')}",
|
||||
]
|
||||
plan = payload.get("plan") or {}
|
||||
lines.append(
|
||||
"candidates: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"tracking": plan.get("tracking"),
|
||||
"order_id": plan.get("order_id"),
|
||||
"item_no": plan.get("item_no"),
|
||||
"account": plan.get("account"),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Deep links from the WIP note
|
||||
ents = payload.get("entities") or []
|
||||
links = generate_nmi_links(ents)
|
||||
if links:
|
||||
lines.append("")
|
||||
lines.append("=== NMI Deep Links (Item Manager / Box History / Request pages / userInfo) ===")
|
||||
lines.extend(links[:12])
|
||||
|
||||
brief = payload.get("brief") or {}
|
||||
if brief.get("ok") and brief.get("text"):
|
||||
lines.append("")
|
||||
lines.append(brief["text"].rstrip())
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("NMI lookup did not resolve cleanly.")
|
||||
if payload.get("error"):
|
||||
lines.append(f"error: {payload['error']}")
|
||||
attempts = brief.get("attempts") or (payload.get("resolve") or {}).get("attempts") or []
|
||||
for a in attempts[:5]:
|
||||
lines.append(f"- attempt {a.get('args')}: rc={a.get('returncode')} err={a.get('stderr') or a.get('error')}")
|
||||
|
||||
# New history
|
||||
hist = payload.get("item_history") or {}
|
||||
if hist.get("ok"):
|
||||
lines.append("")
|
||||
lines.append("=== Item History (getItemHistory) ===")
|
||||
h = hist.get("history") or {}
|
||||
if isinstance(h, dict) and h:
|
||||
lines.append(json.dumps(h, indent=2, default=str)[:1600])
|
||||
elif hist.get("text"):
|
||||
lines.append(hist["text"][:1400])
|
||||
else:
|
||||
lines.append("(see raw for full history)")
|
||||
elif "item_history" in payload:
|
||||
lines.append("")
|
||||
lines.append("Item history note: " + str((payload.get("item_history") or {}).get("error", "no data")))
|
||||
|
||||
lines.append("")
|
||||
lines.append("_Generated by redmine_ticket_nmi; verify before customer-facing use._")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
def generate_nmi_links(entities: list[dict[str, str]]) -> list[str]:
|
||||
"""Produce a list of useful deep links based on resolved entities."""
|
||||
links = []
|
||||
seen = set()
|
||||
for e in entities:
|
||||
kind = e.get("type")
|
||||
val = e.get("value")
|
||||
if not kind or not val:
|
||||
continue
|
||||
templates = NMI_LINKS.get(kind, {})
|
||||
for label, tmpl in templates.items():
|
||||
url = tmpl(val)
|
||||
key = (kind, val, label)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
links.append(f"- {label} for {kind} {val}: {url}")
|
||||
return links
|
||||
|
||||
|
||||
def try_item_history(
|
||||
item_no: str,
|
||||
*,
|
||||
mode: str = DEFAULT_MODE,
|
||||
env_file: str = DEFAULT_ENV_FILE,
|
||||
nmi_bin: str = DEFAULT_NMI_BIN,
|
||||
endpoint: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
snapshot: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Call nmi item history for a single item_no. Returns structured result."""
|
||||
cmd = [
|
||||
"php",
|
||||
nmi_bin,
|
||||
"item",
|
||||
"history",
|
||||
f"--item-no={item_no}",
|
||||
f"--limit={limit}",
|
||||
]
|
||||
if snapshot:
|
||||
cmd.append("--snapshot")
|
||||
if mode:
|
||||
cmd.extend(["--mode", mode])
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
if endpoint:
|
||||
cmd.extend(["--endpoint", endpoint])
|
||||
|
||||
try:
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if res.returncode != 0:
|
||||
return {"ok": False, "error": res.stderr.strip() or res.stdout.strip(), "cmd": cmd}
|
||||
# Try JSON first
|
||||
try:
|
||||
data = json.loads(res.stdout)
|
||||
return {"ok": True, "history": data, "raw": res.stdout}
|
||||
except Exception:
|
||||
return {"ok": True, "text": res.stdout.strip(), "raw": res.stdout}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def enrich_with_history(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
mode: str = DEFAULT_MODE,
|
||||
env_file: str = DEFAULT_ENV_FILE,
|
||||
nmi_bin: str = DEFAULT_NMI_BIN,
|
||||
endpoint: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""If an item_no is present in the plan, fetch its history and attach."""
|
||||
plan = payload.get("plan") or {}
|
||||
item_no = plan.get("item_no")
|
||||
if not item_no:
|
||||
return payload
|
||||
hist = try_item_history(
|
||||
str(item_no),
|
||||
mode=mode,
|
||||
env_file=env_file,
|
||||
nmi_bin=nmi_bin,
|
||||
endpoint=endpoint,
|
||||
limit=60,
|
||||
snapshot=True,
|
||||
)
|
||||
payload["item_history"] = hist
|
||||
return payload
|
||||
|
||||
def try_auto_link_redmine_contact(
|
||||
*,
|
||||
contact_id: int,
|
||||
tracking: list[str] | None = None,
|
||||
order_id: list[str] | None = None,
|
||||
item_no: list[str] | None = None,
|
||||
mode: str = DEFAULT_MODE,
|
||||
env_file: str = DEFAULT_ENV_FILE,
|
||||
nmi_bin: str = DEFAULT_NMI_BIN,
|
||||
) -> dict[str, Any]:
|
||||
"""If we have a Redmine contact_id and identifiers from a ticket, try to resolve in NMI
|
||||
and automatically create the redmine_contact_links entry.
|
||||
|
||||
This implements the pattern: ticket mentions tracking/order/item -> successful
|
||||
cs-info/resolve -> the returned customer owns the shipment -> link the Redmine
|
||||
contact to that NMI user_account using the new junction table.
|
||||
"""
|
||||
if not contact_id:
|
||||
return {"ok": False, "reason": "no_contact_id"}
|
||||
|
||||
ids: list[str] = []
|
||||
if tracking:
|
||||
for t in tracking:
|
||||
ids.extend(["--tracking", str(t)])
|
||||
if order_id:
|
||||
for o in order_id:
|
||||
ids.extend(["--order-id", str(o)])
|
||||
if item_no:
|
||||
for i in item_no:
|
||||
ids.extend(["--item-no", str(i)])
|
||||
|
||||
if not ids:
|
||||
return {"ok": False, "reason": "no_identifiers"}
|
||||
|
||||
cmd = [nmi_bin, "link-contact", "--redmine-contact-id", str(contact_id), "--mode", mode]
|
||||
if env_file and env_file != DEFAULT_ENV_FILE:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
cmd.extend(ids)
|
||||
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||||
ok = proc.returncode == 0
|
||||
result = {
|
||||
"ok": ok,
|
||||
"contact_id": contact_id,
|
||||
"cmd": " ".join(cmd),
|
||||
"stdout": (proc.stdout or "")[:2500],
|
||||
"stderr": (proc.stderr or "")[:800],
|
||||
}
|
||||
if ok and proc.stdout:
|
||||
try:
|
||||
if proc.stdout.strip().startswith("{"):
|
||||
result["parsed"] = json.loads(proc.stdout)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"ok": False, "contact_id": contact_id, "error": str(e)}
|
||||
Reference in New Issue
Block a user