feat(workbench): persist operational workflows and events

This commit is contained in:
2026-09-24 04:19:02 +08:00
parent baff7dfe9e
commit 9335bdc1ea
66 changed files with 16580 additions and 238 deletions
+309 -195
View File
@@ -1,4 +1,4 @@
"""Project peripheral demo orders into the backend workbench order store."""
"""Idempotently seed the workbench's original demo reservation tasks."""
from __future__ import annotations
@@ -7,7 +7,7 @@ import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
from uuid import UUID
from uuid import UUID, uuid4
from zoneinfo import ZoneInfo
from psycopg.types.json import Jsonb
@@ -21,158 +21,311 @@ from app.native.wndb.core.connection import project_connection
SCHEMA_SQL = ROOT / "resources/sql/011_workbench_work_orders.sql"
SHANGHAI = ZoneInfo("Asia/Shanghai")
PROJECTION_SOURCE = "合成数据预约工单"
DEMO_WORK_ORDER_ID = "demo-work-order-peripheral-inspection"
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 = (
"合成数据预约工单",
"外围样板预约工单",
)
ORDER_DETAILS = {
"合成通信核查": {
"location": "DEMO-P001",
"duration_minutes": 30,
"reply_requirements": ["记录通信恢复时间", "确认缺口数据是否补传"],
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": [
"确认阀门当前开度",
"记录影响分区和用户范围",
"回传派发确认截图",
],
},
"合成压力偏差复核": {
"location": "DEMO-P001 / DEMO-P002",
{
"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,
"reply_requirements": ["回填相邻测点对照结果", "确认合成样本复核结论"],
"dispatch_lead_minutes": 20,
"reply_window_minutes": 35,
"reply_requirements": [
"上传泵站运行截图",
"填写出水压力复核结果",
"确认模型边界已同步更新",
],
},
"合成零流量样本确认": {
"location": "DEMO-F001",
"duration_minutes": 20,
"reply_requirements": ["确认零值显示", "确认缺测值显示"],
},
}
)
def _parse_timestamp(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _time_label(value: datetime) -> str:
def _clock(value: datetime) -> str:
return value.astimezone(SHANGHAI).strftime("%H:%M")
def _time_range(start: datetime, end: datetime) -> str:
return f"{_time_label(start)}-{_time_label(end)}"
def _clock_range(start: datetime, duration_minutes: int) -> str:
return f"{_clock(start)}-{_clock(start + timedelta(minutes=duration_minutes))}"
def _subtask_status(status: str) -> str:
mapped = {
"open": "pending",
"in_progress": "running",
"completed": "completed",
}.get(status)
if mapped is None:
raise ValueError(f"unsupported peripheral work-order status: {status}")
return mapped
def _stage_statuses(status: str) -> tuple[str, str, str]:
if status == "completed":
def _stage_statuses(phase: str, position: str) -> tuple[str, str, str]:
if position == "before":
return "done", "done", "done"
if status == "running":
if position == "after":
return "pending", "pending", "pending"
if phase == "dispatch":
return "current", "pending", "pending"
if phase == "execution":
return "done", "current", "pending"
return "current", "pending", "pending"
return "done", "done", "current"
def project_peripheral_subtask(document: dict) -> dict:
status = _subtask_status(document.get("status"))
scheduled_at = _parse_timestamp(document["scheduled_at"])
details = ORDER_DETAILS.get(document["title"], {})
duration_minutes = int(details.get("duration_minutes", 30))
execution_end = scheduled_at + timedelta(minutes=duration_minutes)
reply_end = execution_end + timedelta(minutes=15)
dispatch_status, execution_status, reply_status = _stage_statuses(status)
assignee = document.get("assignee") or "待分派班组"
def _subtask_status(phase: str, position: str) -> str:
if position == "before":
return "completed"
if position == "after" or phase == "dispatch":
return "pending"
return "running"
return {
"id": document["work_order_id"],
"title": document["title"],
"summary": document["summary"],
"status": status,
"scheduled_at": document["scheduled_at"],
"location": details.get("location", "演示点位"),
"assignee": assignee,
"duration_minutes": duration_minutes,
"reply_window_minutes": 15,
"stages": [
def _build_subtasks(template: dict, scheduled_at: datetime) -> list[dict]:
requirements = template["reply_requirements"]
phase = template["phase"]
active_index = {
"dispatch": 0,
"execution": min(1, len(requirements) - 1),
"reply": len(requirements) - 1,
}[phase]
subtask_duration = max(
5,
(template["duration_minutes"] + len(requirements) - 1) // len(requirements),
)
subtasks = []
for index, requirement in enumerate(requirements):
position = (
"before"
if index < active_index
else "after"
if index > active_index
else "active"
)
subtask_start = scheduled_at + timedelta(minutes=index * subtask_duration)
execution_end = subtask_start + timedelta(minutes=subtask_duration)
reply_end = execution_end + timedelta(minutes=template["reply_window_minutes"])
dispatch_status, execution_status, reply_status = _stage_statuses(
phase, position
)
subtasks.append(
{
"id": "dispatch",
"sequence": 1,
"title": "派发",
"status": dispatch_status,
"owner": "演示调度",
"time_label": _time_label(scheduled_at - timedelta(minutes=15)),
"description": "预约工单子任务已进入演示调度流程。",
},
{
"id": "execution",
"sequence": 2,
"title": "执行",
"status": execution_status,
"owner": assignee,
"time_label": _time_range(scheduled_at, execution_end),
"description": "按外围合成场景执行本项核查。",
},
{
"id": "reply",
"sequence": 3,
"title": "复令",
"status": reply_status,
"owner": assignee,
"time_label": _time_range(execution_end, reply_end),
"description": "回传本项核查结果并完成子任务闭环。",
},
],
"reply_requirements": details.get(
"reply_requirements", ["回传演示核查结果"]
"id": f"{template['id']}-subtask-{index + 1}",
"title": requirement,
"summary": f"完成“{requirement}”并保留可复核的阶段记录。",
"status": _subtask_status(phase, position),
"scheduled_at": subtask_start,
"location": template["location"],
"assignee": template["assignee"],
"duration_minutes": subtask_duration,
"reply_window_minutes": template["reply_window_minutes"],
"stages": [
{
"id": "dispatch",
"sequence": 1,
"title": "派发",
"status": dispatch_status,
"owner": template["dispatcher"],
"time_label": _clock(
subtask_start
- timedelta(minutes=template["dispatch_lead_minutes"])
),
"description": (
f"确认{template['location']}任务范围、优先级和到场窗口,"
f"派发给{template['assignee']}。"
),
},
{
"id": "execution",
"sequence": 2,
"title": "执行中",
"status": execution_status,
"owner": template["assignee"],
"time_label": _clock_range(subtask_start, subtask_duration),
"description": template["summary"],
},
{
"id": "reply",
"sequence": 3,
"title": "待复令",
"status": reply_status,
"owner": f"{template['assignee']} → 调度中心",
"time_label": _clock_range(
execution_end,
template["reply_window_minutes"],
),
"description": (
"提交处置结果、现场照片和影响复核,"
f"最迟 {_clock(reply_end)} 前完成复令。"
),
},
],
"reply_requirements": [f"回传“{requirement}”的执行结果"],
}
)
return subtasks
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"],
"subtasks": _build_subtasks(template, scheduled_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 project_peripheral_work_order(source_documents: list[dict]) -> dict:
if not source_documents:
raise ValueError("source documents must not be empty")
subtasks = [project_peripheral_subtask(document) for document in source_documents]
scheduled_values = [_parse_timestamp(item["scheduled_at"]) for item in source_documents]
updated_values = [_parse_timestamp(item["updated_at"]) for item in source_documents]
end_values = [
scheduled + timedelta(minutes=subtask["duration_minutes"])
for scheduled, subtask in zip(scheduled_values, subtasks, strict=True)
]
statuses = {subtask["status"] for subtask in subtasks}
if statuses == {"completed"}:
status = "completed"
elif statuses == {"pending"}:
status = "pending"
else:
status = "running"
priority = "urgent" if any(
document.get("priority") in {"high", "urgent"}
for document in source_documents
) else "normal"
projected = {
"id": DEMO_WORK_ORDER_ID,
"code": "DEMO-WO-001",
"scheduled_at": min(scheduled_values),
"title": "外围合成数据核查",
"summary": "依次核查合成通信、压力偏差和零流量样本。",
"status": status,
"risk_level": "attention" if priority == "urgent" else "normal",
"updated_at": max(updated_values),
"detail": "外围样板中的三项 synthetic 核查已组合为一张预约工单,仅用于演示。",
"recommendation": "按子任务逐项核查并分别完成阶段回传。",
"duration_minutes": max(
1,
int((max(end_values) - min(scheduled_values)).total_seconds() // 60),
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,
),
"source": PROJECTION_SOURCE,
"dispatcher": "演示调度",
"priority": priority,
"subtasks": subtasks,
}
return WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json")
)
row = cursor.fetchone()
conn.execute(
"SELECT pg_notify('workbench_events', %s)",
(f'{project_id}:{row["event_seq"]}',),
)
def main() -> int:
@@ -180,73 +333,34 @@ def main() -> int:
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"))
source_rows = conn.execute(
"""
SELECT document
FROM peripheral_sample.work_orders
WHERE project_id = %s
AND deleted_at IS NULL
AND document->>'origin' = 'synthetic'
AND document->>'scheduled_at' IS NOT NULL
AND document->>'status' IN ('open', 'in_progress', 'completed')
ORDER BY (document->>'scheduled_at')::timestamptz, work_order_id
""",
(args.project_id,),
).fetchall()
source_documents = [row["document"] for row in source_rows]
if not source_documents:
raise RuntimeError("no eligible peripheral demo work orders found")
documents = [project_peripheral_work_order(source_documents)]
replace_demo_work_orders(conn, args.project_id, documents)
publish_demo_work_order_batch_event(conn, args.project_id, len(documents))
conn.execute(
"""
DELETE FROM workbench.work_orders
WHERE project_id = %s
AND (
work_order_id LIKE 'demo-work-order-%%'
OR document->>'source' = %s
OR document->>'source' = '外围样板预约工单'
)
""",
(args.project_id, PROJECTION_SOURCE),
)
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
""",
(
args.project_id,
document["id"],
document["scheduled_at"],
document["updated_at"],
Jsonb(document),
),
)
print(json.dumps({
"database": args.database_name,
"project_id": str(args.project_id),
"source": PROJECTION_SOURCE,
"work_orders": [
print(
json.dumps(
{
"id": document["id"],
"code": document["code"],
"title": document["title"],
"subtask_count": len(document["subtasks"]),
}
for document in documents
],
}, ensure_ascii=False, indent=2))
"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"],
"subtask_count": len(document["subtasks"]),
}
for document in documents
],
},
ensure_ascii=False,
indent=2,
)
)
return 0