Files
next-tjwater-server/scripts/sync_peripheral_work_orders.py
T

195 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Synchronize the explicitly synthetic peripheral work orders into task projections."""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
from uuid import NAMESPACE_URL, uuid5
import httpx
from samples.peripheral.seed_identity import SEED_ACTOR_ID
try:
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
except ModuleNotFoundError:
from run_operational_worker import CredentialProvider, WorkerSettings
logger = logging.getLogger("peripheral-task-sync")
STATUS = {"draft": "scheduled", "open": "assigned", "in_progress": "in_progress",
"awaiting_reply": "awaiting_feedback", "completed": "completed", "cancelled": "cancelled"}
EVENT_WORK_ORDER_TYPES = frozenset({"water_quality_complaint", "burst_report"})
def should_project_work_order(order: dict, linked_order_ids: set[str]) -> bool:
"""Exclude retired seed checks while preserving user and workflow orders."""
work_order_id = order["work_order_id"]
work_order_type = order.get("work_order_type", "general")
if work_order_type in EVENT_WORK_ORDER_TYPES or work_order_id in linked_order_ids:
return True
return not (
work_order_type == "general"
and order.get("origin") == "synthetic"
and order.get("created_by") == str(SEED_ACTOR_ID)
)
def work_order_snapshot(order: dict) -> dict:
"""Preserve the flat order and its recorded activity without inventing stages."""
required = (
"work_order_id", "revision", "title", "summary", "status", "priority",
"origin", "updated_at",
)
snapshot = {key: order[key] for key in required}
snapshot.update({key: order.get(key) for key in (
"assignee", "scheduled_at", "location", "scenario_code",
)})
snapshot["feedback_requirements"] = order.get("feedback_requirements") or []
snapshot["activity"] = order.get("activity") or []
snapshot["work_order_type"] = order.get("work_order_type", "general")
return snapshot
async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, peripheral_url: str,
credentials: CredentialProvider) -> int:
headers = await credentials.headers(client)
tasks_url = f"{settings.server_url.rstrip('/')}/api/v1/operational-tasks"
existing: dict[str, dict] = {}
event_tasks: dict[str | None, dict] = {}
investigations: list[dict] = []
offset = 0
while True:
response = await client.get(tasks_url, headers=headers, params={"limit": 100, "offset": offset})
response.raise_for_status()
page = response.json()
for task in page["items"]:
if task["task_type"] == "external_work_order":
existing[task["source"].get("external_id")] = task
elif task["task_type"] == "event_investigation":
event_tasks[task.get("source", {}).get("external_id")] = task
if task["status"] in {"queued", "waiting_human", "running"}:
investigations.append(task)
if page.get("next_offset") is None:
break
offset = page["next_offset"]
linked: dict[str, list[str]] = {}
for task in investigations:
response = await client.get(f"{tasks_url}/{task['task_id']}", headers=headers)
response.raise_for_status()
for action in response.json().get("actions", []):
receipt = action.get("receipt")
if action["kind"] == "create_work_order" and receipt and receipt["adapter"] == "peripheral_sample":
linked.setdefault(receipt["receipt_id"], []).append(task["task_id"])
linked_order_ids = set(linked)
cursor, synchronized = None, 0
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
response = await client.get(f"{peripheral_url.rstrip('/')}/api/sample/v1/work-orders",
headers=headers, params=params)
response.raise_for_status()
page = response.json()
for order in page["items"]:
if order["project_id"] != settings.project_id or order["origin"] != "synthetic":
raise ValueError("peripheral work order scope or origin mismatch")
if not should_project_work_order(order, linked_order_ids):
continue
external_id = f"peripheral_sample:{order['work_order_id']}"
# Complaint intake is an event even if no browser was open when the
# external system created it. Use the same source key as the UI.
event_kind = order.get("work_order_type")
if (
event_kind in EVENT_WORK_ORDER_TYPES
and order["work_order_id"] not in event_tasks
and order["work_order_id"] not in linked_order_ids
):
event_request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:investigation"))
event = await client.post(tasks_url, headers=headers, json={
"request_id": event_request_id, "task_type": "event_investigation", "workflow": event_kind,
"title": "水质投诉调查" if event_kind == "water_quality_complaint" else "爆管事件调查", "summary": f"合成外围事件:{order['summary']}",
"priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal",
"source": {"kind": "external_system", "external_id": order["work_order_id"],
"external_revision": order["revision"], "event_kind": event_kind},
"trigger": {"kind": "event", "occurred_at": order.get("scheduled_at") or order["created_at"],
"detail": f"外围样板事件上报 · {order.get('location') or '位置待确认'}"},
})
event.raise_for_status()
event_tasks[order["work_order_id"]] = event.json()
task = existing.get(external_id)
if task is None:
request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:create"))
result = await client.post(tasks_url, headers=headers, json={
"request_id": request_id, "task_type": "external_work_order", "workflow": "external-work-order",
"title": order["title"], "summary": f"合成外围工单:{order['summary']}",
"priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal",
"source": {"kind": "external_system", "external_id": external_id,
"event_kind": order.get("work_order_type", "general")},
"trigger": {"kind": "external_sync", "occurred_at": order["created_at"],
"detail": "外围样板工单同步,进度以外部系统反馈为准"},
"scheduled_for": order["scheduled_at"],
})
result.raise_for_status()
task = result.json()
existing[external_id] = task
event_id = f"{external_id}:{order['revision']}"
feedback = {
"request_id": str(uuid5(NAMESPACE_URL, event_id)), "source_event_id": external_id,
"source_revision": order["revision"], "status": STATUS[order["status"]],
"occurred_at": order["updated_at"],
"detail": f"合成外围工单 {order['work_order_id']}:{order['status']}",
"evidence": {"evidence_id": event_id, "kind": "synthetic", "summary": order["summary"],
"source_label": "外围样板合成工单", "source_ref": external_id,
"observed_at": order["updated_at"]},
}
targets = set(linked.get(order["work_order_id"], []))
event_task = event_tasks.get(order["work_order_id"])
if event_task and order["revision"] > (event_task["source"].get("external_revision") or 0):
targets.add(event_task["task_id"])
if (task["source"].get("external_revision") or 0) < order["revision"]:
targets.add(task["task_id"])
for target in targets:
payload = dict(feedback)
if target == task["task_id"]:
payload["work_order_snapshot"] = work_order_snapshot(order)
result = await client.post(f"{tasks_url}/{target}/external-feedback", headers=headers, json=payload)
result.raise_for_status()
synchronized += 1
cursor = page.get("next_cursor")
if not cursor:
return synchronized
async def run(once: bool):
settings = WorkerSettings.from_environment()
peripheral_url = os.environ["OPERATIONAL_ACTION_PERIPHERAL_URL"]
credentials = CredentialProvider(settings)
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
while True:
try:
count = await sync_once(client, settings, peripheral_url, credentials)
logger.info("Peripheral progress synchronization completed: %d revisions", count)
except (httpx.HTTPError, ValueError, KeyError):
logger.error("Peripheral synchronization failed; next tick will reconcile persisted revisions")
if once:
raise SystemExit(1) from None
if once:
return
await asyncio.sleep(settings.interval_seconds)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--once", action="store_true")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
try:
asyncio.run(run(args.once))
except KeyboardInterrupt:
pass