feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user