feat(workbench): add dynamic task approval lifecycle
This commit is contained in:
@@ -1,270 +0,0 @@
|
||||
"""Idempotently seed the workbench's original demo reservation tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchRuntimeWorkOrder
|
||||
from app.native.wndb.core.connection import project_connection
|
||||
|
||||
|
||||
SCHEMA_SQL = ROOT / "resources/sql/011_workbench_work_orders.sql"
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
DATASET_ID = "workbench-demo-orders@1"
|
||||
DEMO_DATE = datetime(2026, 9, 17, tzinfo=SHANGHAI)
|
||||
SEEDED_WORK_ORDER_PREFIX = "mock-work-order-"
|
||||
LEGACY_WORK_ORDER_SOURCES = (
|
||||
"合成数据预约工单",
|
||||
"外围样板预约工单",
|
||||
)
|
||||
|
||||
WORK_ORDER_TEMPLATES = (
|
||||
{
|
||||
"id": "incident-valve-dispatch",
|
||||
"code": "DEMO-WO-20260917-001",
|
||||
"title": "演示工单:华山路阀门边界确认",
|
||||
"summary": "异常事件分析后生成阀门边界确认任务,派发前需调度员复核影响范围。",
|
||||
"detail": "该条目为工作台演示数据,用于展示人工确认界面,不代表真实提交或岗位通知。",
|
||||
"recommendation": "确认影响用户、上下游压力和应急隔离边界后,再通知执行岗位到场。",
|
||||
"source": "异常事件分析",
|
||||
"location": "华山路沿线阀门组 VG-11",
|
||||
"dispatcher": "调度中心值班长",
|
||||
"assignee": "调度执行岗",
|
||||
"priority": "urgent",
|
||||
"risk_level": "attention",
|
||||
"phase": "dispatch",
|
||||
"start_hour": 10,
|
||||
"start_minute": 30,
|
||||
"duration_minutes": 40,
|
||||
"dispatch_lead_minutes": 30,
|
||||
"reply_window_minutes": 20,
|
||||
"reply_requirements": [
|
||||
"确认阀门当前开度",
|
||||
"记录影响分区和用户范围",
|
||||
"回传派发确认截图",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "valve-operation-execution",
|
||||
"code": "DEMO-WO-20260917-002",
|
||||
"title": "演示工单:北辰分区阀门开度调整",
|
||||
"summary": "根据管网智能调度方案,现场调整北辰分区边界阀门开度并复核压力。",
|
||||
"detail": "该工单模拟系统派发后的执行阶段,班组正在按方案对阀门要素进行操作。",
|
||||
"recommendation": "执行过程中保持调度语音联络,调整后立即复核末端压力和流量变化。",
|
||||
"source": "调度方案确认",
|
||||
"location": "北辰分区边界阀门 BV-07",
|
||||
"dispatcher": "管网调度岗",
|
||||
"assignee": "北辰抢修二组",
|
||||
"priority": "urgent",
|
||||
"risk_level": "attention",
|
||||
"phase": "execution",
|
||||
"start_hour": 11,
|
||||
"start_minute": 0,
|
||||
"duration_minutes": 55,
|
||||
"dispatch_lead_minutes": 25,
|
||||
"reply_window_minutes": 20,
|
||||
"reply_requirements": [
|
||||
"上传阀门操作前后照片",
|
||||
"记录最终开度",
|
||||
"复核北辰末端压力不低于 0.22MPa",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "pump-boundary-reply",
|
||||
"code": "DEMO-WO-20260917-003",
|
||||
"title": "演示工单:泵站边界复核复令",
|
||||
"summary": "泵站出水边界复核已完成,等待班组复令和调度确认闭环。",
|
||||
"detail": "该工单模拟执行完成后的复令阶段,用于检查处置结果、照片和指标回传。",
|
||||
"recommendation": "核对泵站出水压力、频率曲线和分区边界模拟结果后完成复令。",
|
||||
"source": "模型复核建议",
|
||||
"location": "北辰二级泵站 P-02",
|
||||
"dispatcher": "模型复核岗",
|
||||
"assignee": "泵站运行班组",
|
||||
"priority": "normal",
|
||||
"risk_level": "normal",
|
||||
"phase": "reply",
|
||||
"start_hour": 13,
|
||||
"start_minute": 0,
|
||||
"duration_minutes": 45,
|
||||
"dispatch_lead_minutes": 20,
|
||||
"reply_window_minutes": 35,
|
||||
"reply_requirements": [
|
||||
"上传泵站运行截图",
|
||||
"填写出水压力复核结果",
|
||||
"确认模型边界已同步更新",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _build_activity(template: dict, scheduled_at: datetime, updated_at: datetime) -> list[dict]:
|
||||
created_at = scheduled_at - timedelta(minutes=template["dispatch_lead_minutes"])
|
||||
activity = [{"sequence": 1, "occurred_at": created_at,
|
||||
"actor": template["dispatcher"], "kind": "created",
|
||||
"title": "演示工单创建", "detail": template["summary"]}]
|
||||
if template["phase"] != "dispatch":
|
||||
activity.append({"sequence": 2, "occurred_at": updated_at,
|
||||
"actor": template["assignee"], "kind": "updated",
|
||||
"title": "演示状态更新", "detail": template["detail"]})
|
||||
return activity
|
||||
|
||||
|
||||
def build_demo_work_orders() -> list[dict]:
|
||||
"""Build the three reservation tasks previously provided by the frontend mock."""
|
||||
documents = []
|
||||
for template in WORK_ORDER_TEMPLATES:
|
||||
scheduled_at = DEMO_DATE.replace(
|
||||
hour=template["start_hour"],
|
||||
minute=template["start_minute"],
|
||||
)
|
||||
status = {
|
||||
"dispatch": "pending",
|
||||
"execution": "running",
|
||||
"reply": "awaiting_reply",
|
||||
}[template["phase"]]
|
||||
updated_at = {
|
||||
"dispatch": scheduled_at - timedelta(minutes=5),
|
||||
"execution": scheduled_at + timedelta(minutes=20),
|
||||
"reply": scheduled_at + timedelta(minutes=template["duration_minutes"]),
|
||||
}[template["phase"]]
|
||||
projected = {
|
||||
"id": f"{SEEDED_WORK_ORDER_PREFIX}{template['id']}",
|
||||
"code": template["code"],
|
||||
"scheduled_at": scheduled_at,
|
||||
"title": template["title"],
|
||||
"summary": template["summary"],
|
||||
"status": status,
|
||||
"risk_level": template["risk_level"],
|
||||
"updated_at": updated_at,
|
||||
"detail": template["detail"],
|
||||
"recommendation": template["recommendation"],
|
||||
"duration_minutes": template["duration_minutes"],
|
||||
"source": template["source"],
|
||||
"dispatcher": template["dispatcher"],
|
||||
"priority": template["priority"],
|
||||
"location": template["location"],
|
||||
"assignee": template["assignee"],
|
||||
"feedback_requirements": template["reply_requirements"],
|
||||
"activity": _build_activity(template, scheduled_at, updated_at),
|
||||
}
|
||||
documents.append(
|
||||
WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json")
|
||||
)
|
||||
return documents
|
||||
|
||||
|
||||
def replace_demo_work_orders(conn, project_id: UUID, documents: list[dict]) -> None:
|
||||
"""Remove the obsolete synthetic projection and replace this seed dataset."""
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM workbench.work_orders
|
||||
WHERE project_id = %s
|
||||
AND (
|
||||
work_order_id LIKE 'demo-work-order-%%'
|
||||
OR work_order_id LIKE %s
|
||||
OR document->>'source' = ANY(%s)
|
||||
)
|
||||
""",
|
||||
(
|
||||
project_id,
|
||||
f"{SEEDED_WORK_ORDER_PREFIX}%",
|
||||
list(LEGACY_WORK_ORDER_SOURCES),
|
||||
),
|
||||
)
|
||||
for document in documents:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO workbench.work_orders
|
||||
(project_id, work_order_id, scheduled_at, updated_at, document)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (project_id, work_order_id) DO UPDATE
|
||||
SET scheduled_at = EXCLUDED.scheduled_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
document = EXCLUDED.document
|
||||
""",
|
||||
(
|
||||
project_id,
|
||||
document["id"],
|
||||
document["scheduled_at"],
|
||||
document["updated_at"],
|
||||
Jsonb(document),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def publish_demo_work_order_batch_event(conn, project_id: UUID, count: int) -> None:
|
||||
"""Invalidate the demo projection once, never once per seeded row."""
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO workbench.domain_event_log
|
||||
(project_id,event_id,topic,aggregate_type,aggregate_id,
|
||||
aggregate_revision,occurred_at,metadata)
|
||||
SELECT %s,%s,'workbench.work_order.changed','demo_work_order_dataset',%s,
|
||||
COALESCE(max(aggregate_revision),0)+1,now(),%s
|
||||
FROM workbench.domain_event_log
|
||||
WHERE project_id=%s AND aggregate_type='demo_work_order_dataset'
|
||||
AND aggregate_id=%s
|
||||
RETURNING event_seq""",
|
||||
(
|
||||
project_id, uuid4(), DATASET_ID,
|
||||
Jsonb({"namespace": "demo-work-order", "count": count}),
|
||||
project_id, DATASET_ID,
|
||||
),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.execute(
|
||||
"SELECT pg_notify('workbench_events', %s)",
|
||||
(f'{project_id}:{row["event_seq"]}',),
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--database-name", default="tjwater_v2")
|
||||
parser.add_argument("--project-id", required=True, type=UUID)
|
||||
args = parser.parse_args()
|
||||
documents = build_demo_work_orders()
|
||||
|
||||
with project_connection(args.database_name) as conn, conn.transaction():
|
||||
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
|
||||
replace_demo_work_orders(conn, args.project_id, documents)
|
||||
publish_demo_work_order_batch_event(conn, args.project_id, len(documents))
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"database": args.database_name,
|
||||
"project_id": str(args.project_id),
|
||||
"dataset_id": DATASET_ID,
|
||||
"work_orders": [
|
||||
{
|
||||
"id": document["id"],
|
||||
"code": document["code"],
|
||||
"title": document["title"],
|
||||
"status": document["status"],
|
||||
"feedback_requirement_count": len(
|
||||
document["feedback_requirements"]
|
||||
),
|
||||
}
|
||||
for document in documents
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -6,10 +6,14 @@ import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from samples.peripheral.seed_identity import SEED_ACTOR_ID
|
||||
|
||||
try:
|
||||
@@ -24,6 +28,16 @@ STATUS = {"draft": "scheduled", "open": "assigned", "in_progress": "in_progress"
|
||||
EVENT_WORK_ORDER_TYPES = frozenset({"water_quality_complaint", "burst_report"})
|
||||
|
||||
|
||||
def peripheral_order_id(external_id: str | None) -> str | None:
|
||||
"""Return only canonical IDs owned by this synchronizer's namespace."""
|
||||
if not external_id or not external_id.startswith("peripheral_sample:"):
|
||||
return None
|
||||
try:
|
||||
return str(UUID(external_id.removeprefix("peripheral_sample:")))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
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"]
|
||||
@@ -87,6 +101,7 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
linked_order_ids = set(linked)
|
||||
|
||||
cursor, synchronized = None, 0
|
||||
seen_order_ids: set[str] = set()
|
||||
while True:
|
||||
params = {"limit": 100}
|
||||
if cursor:
|
||||
@@ -98,6 +113,7 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
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")
|
||||
seen_order_ids.add(order["work_order_id"])
|
||||
if not should_project_work_order(order, linked_order_ids):
|
||||
continue
|
||||
external_id = f"peripheral_sample:{order['work_order_id']}"
|
||||
@@ -162,7 +178,37 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
synchronized += 1
|
||||
cursor = page.get("next_cursor")
|
||||
if not cursor:
|
||||
return synchronized
|
||||
break
|
||||
|
||||
for external_id, task in existing.items():
|
||||
order_id = peripheral_order_id(external_id)
|
||||
if order_id is None:
|
||||
continue
|
||||
if order_id in seen_order_ids:
|
||||
continue
|
||||
# The list may change during pagination. Only a scoped 404 proves that
|
||||
# the source order is now absent; transient errors must stop this tick.
|
||||
response = await client.get(
|
||||
f"{peripheral_url.rstrip('/')}/api/sample/v1/work-orders/{order_id}",
|
||||
headers=headers,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
continue
|
||||
if response.status_code != 404:
|
||||
response.raise_for_status()
|
||||
if response.json().get("code") != "WORK_ORDER_NOT_FOUND":
|
||||
raise ValueError("peripheral work order lookup did not confirm deletion")
|
||||
result = await client.post(
|
||||
f"{tasks_url}/{task['task_id']}/withdraw-external-source",
|
||||
headers=headers,
|
||||
json={
|
||||
"request_id": str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:withdraw")),
|
||||
"source_event_id": external_id,
|
||||
},
|
||||
)
|
||||
result.raise_for_status()
|
||||
synchronized += 1
|
||||
return synchronized
|
||||
|
||||
|
||||
async def run(once: bool):
|
||||
|
||||
Reference in New Issue
Block a user