171 lines
8.7 KiB
Python
171 lines
8.7 KiB
Python
#!/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
|
||
|
||
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"}
|
||
|
||
|
||
def work_order_snapshot(order: dict) -> dict:
|
||
"""Preserve external process facts without deriving stages from total status."""
|
||
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", "subtasks",
|
||
)})
|
||
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, event_tasks, investigations, 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"])
|
||
|
||
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")
|
||
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 {"water_quality_complaint", "burst_report"}
|
||
and order["work_order_id"] not in event_tasks
|
||
and order["work_order_id"] not in linked
|
||
):
|
||
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
|