feat(workbench): add dynamic task approval lifecycle

This commit is contained in:
2026-09-24 16:36:12 +08:00
parent db092b0f5e
commit ad813a7c16
35 changed files with 2956 additions and 564 deletions
-270
View File
@@ -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())
+48 -2
View File
@@ -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):