fix(workbench): remove synthetic check projections
This commit is contained in:
@@ -5,7 +5,7 @@ import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
@@ -16,49 +16,18 @@ from app.services.content_digest import digest
|
||||
from samples.peripheral.auth import Identity
|
||||
from samples.peripheral.config import SampleSettings
|
||||
from samples.peripheral.demo_data import build_demo_content
|
||||
from samples.peripheral.schemas import CreateTestDataRecord, CreateWorkOrder
|
||||
from samples.peripheral.schemas import CreateTestDataRecord
|
||||
from samples.peripheral.seed_identity import SEED_ACTOR_ID, SEED_NAMESPACE
|
||||
from samples.peripheral.store import PostgresStore
|
||||
|
||||
|
||||
SEED_NAMESPACE = uuid5(NAMESPACE_URL, "tjwater/peripheral-sample/seed/v1")
|
||||
ACTOR_ID = uuid5(SEED_NAMESPACE, "actor")
|
||||
ACTOR_ID = SEED_ACTOR_ID
|
||||
|
||||
|
||||
def request_id(kind: str) -> UUID:
|
||||
return uuid5(SEED_NAMESPACE, kind)
|
||||
|
||||
|
||||
WORK_ORDERS = (
|
||||
{
|
||||
"key": "work-order/demo-communication",
|
||||
"title": "合成通信核查",
|
||||
"summary": "检查 DEMO-P001 的预置通信中断情节,仅用于外围样板联调。",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"assignee": "外围样板班组",
|
||||
"scheduled_at": "2026-09-17T03:00:00Z",
|
||||
},
|
||||
{
|
||||
"key": "work-order/demo-pressure",
|
||||
"title": "合成压力偏差复核",
|
||||
"summary": "复核 DEMO-P001、DEMO-P002 相邻测试点的固定合成读数,不代表现场异常。",
|
||||
"status": "in_progress",
|
||||
"priority": "urgent",
|
||||
"assignee": "演示核查班组",
|
||||
"scheduled_at": "2026-09-17T04:00:00Z",
|
||||
},
|
||||
{
|
||||
"key": "work-order/flow-zero",
|
||||
"title": "合成零流量样本确认",
|
||||
"summary": "确认 DEMO-F001 中零值与缺测值可被界面明确区分。",
|
||||
"status": "completed",
|
||||
"priority": "normal",
|
||||
"assignee": "外围样板班组",
|
||||
"scheduled_at": "2026-09-17T06:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
TEST_DATA = (
|
||||
("DEMO-P001", "合成压力 1 · 00:00", "2026-09-17T00:00:00Z", 24.37, "m", "valid"),
|
||||
("DEMO-P001", "合成压力 1 · 00:15", "2026-09-17T00:15:00Z", 24.18, "m", "valid"),
|
||||
@@ -97,31 +66,22 @@ def previous_seed_result(
|
||||
return row["result"]
|
||||
|
||||
|
||||
def restore_seeded_resources(
|
||||
def restore_seeded_test_data(
|
||||
store: PostgresStore,
|
||||
project_id: str,
|
||||
*,
|
||||
table: str,
|
||||
id_column: str,
|
||||
resource_ids: list[str],
|
||||
record_ids: list[str],
|
||||
) -> None:
|
||||
"""Restore deterministic seed rows that were soft-deleted after an earlier run."""
|
||||
allowed_resources = {
|
||||
("work_orders", "work_order_id"),
|
||||
("test_data_records", "record_id"),
|
||||
}
|
||||
if (table, id_column) not in allowed_resources:
|
||||
raise ValueError("unsupported seeded resource")
|
||||
if not resource_ids:
|
||||
if not record_ids:
|
||||
return
|
||||
|
||||
with store.connection() as connection:
|
||||
connection.execute(
|
||||
f"""UPDATE peripheral_sample.{table}
|
||||
"""UPDATE peripheral_sample.test_data_records
|
||||
SET document=document - 'deleted_at' - 'deleted_by',
|
||||
deleted_at=NULL, deleted_by=NULL
|
||||
WHERE project_id=%s AND {id_column}=ANY(%s::uuid[])""",
|
||||
(project_id, resource_ids),
|
||||
WHERE project_id=%s AND record_id=ANY(%s::uuid[])""",
|
||||
(project_id, record_ids),
|
||||
)
|
||||
|
||||
|
||||
@@ -160,34 +120,6 @@ def main() -> int:
|
||||
who = Identity(project_id, str(ACTOR_ID), True)
|
||||
|
||||
dataset_digest = ensure_demo_dataset(store, project_id)
|
||||
order_ids = []
|
||||
for item in WORK_ORDERS:
|
||||
seed_request_id = request_id(item["key"])
|
||||
command = CreateWorkOrder(
|
||||
request_id=seed_request_id,
|
||||
title=item["title"],
|
||||
summary=item["summary"],
|
||||
status=item["status"],
|
||||
priority=item["priority"],
|
||||
assignee=item["assignee"],
|
||||
scheduled_at=item["scheduled_at"],
|
||||
)
|
||||
previous = previous_seed_result(
|
||||
store,
|
||||
project_id,
|
||||
seed_request_id=seed_request_id,
|
||||
command_key="create_work_order",
|
||||
)
|
||||
result = previous or store.create_work_order(who, command)
|
||||
order_ids.append(result["work_order_id"])
|
||||
restore_seeded_resources(
|
||||
store,
|
||||
project_id,
|
||||
table="work_orders",
|
||||
id_column="work_order_id",
|
||||
resource_ids=order_ids,
|
||||
)
|
||||
|
||||
record_ids = []
|
||||
for point_id, label, observed_at, value, unit, quality in TEST_DATA:
|
||||
seed_request_id = request_id(f"test-data/{point_id}/{observed_at}")
|
||||
@@ -209,20 +141,13 @@ def main() -> int:
|
||||
)
|
||||
result = previous or store.create_test_data(who, command)
|
||||
record_ids.append(result["record_id"])
|
||||
restore_seeded_resources(
|
||||
store,
|
||||
project_id,
|
||||
table="test_data_records",
|
||||
id_column="record_id",
|
||||
resource_ids=record_ids,
|
||||
)
|
||||
restore_seeded_test_data(store, project_id, record_ids)
|
||||
|
||||
result = {
|
||||
"project_id": project_id,
|
||||
"actor_id": str(ACTOR_ID),
|
||||
"origin": "synthetic",
|
||||
"demo_dataset_digest": dataset_digest,
|
||||
"work_orders": {"count": len(order_ids), "ids": order_ids},
|
||||
"test_data_records": {"count": len(record_ids), "ids": record_ids},
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
|
||||
@@ -108,119 +108,16 @@ WORK_ORDER_TEMPLATES = (
|
||||
)
|
||||
|
||||
|
||||
def _clock(value: datetime) -> str:
|
||||
return value.astimezone(SHANGHAI).strftime("%H:%M")
|
||||
|
||||
|
||||
def _clock_range(start: datetime, duration_minutes: int) -> str:
|
||||
return f"{_clock(start)}-{_clock(start + timedelta(minutes=duration_minutes))}"
|
||||
|
||||
|
||||
def _stage_statuses(phase: str, position: str) -> tuple[str, str, str]:
|
||||
if position == "before":
|
||||
return "done", "done", "done"
|
||||
if position == "after":
|
||||
return "pending", "pending", "pending"
|
||||
if phase == "dispatch":
|
||||
return "current", "pending", "pending"
|
||||
if phase == "execution":
|
||||
return "done", "current", "pending"
|
||||
return "done", "done", "current"
|
||||
|
||||
|
||||
def _subtask_status(phase: str, position: str) -> str:
|
||||
if position == "before":
|
||||
return "completed"
|
||||
if position == "after" or phase == "dispatch":
|
||||
return "pending"
|
||||
return "running"
|
||||
|
||||
|
||||
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": 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_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]:
|
||||
@@ -256,7 +153,10 @@ def build_demo_work_orders() -> list[dict]:
|
||||
"source": template["source"],
|
||||
"dispatcher": template["dispatcher"],
|
||||
"priority": template["priority"],
|
||||
"subtasks": _build_subtasks(template, scheduled_at),
|
||||
"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")
|
||||
@@ -352,7 +252,9 @@ def main() -> int:
|
||||
"code": document["code"],
|
||||
"title": document["title"],
|
||||
"status": document["status"],
|
||||
"subtask_count": len(document["subtasks"]),
|
||||
"feedback_requirement_count": len(
|
||||
document["feedback_requirements"]
|
||||
),
|
||||
}
|
||||
for document in documents
|
||||
],
|
||||
|
||||
@@ -10,6 +10,8 @@ from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
import httpx
|
||||
|
||||
from samples.peripheral.seed_identity import SEED_ACTOR_ID
|
||||
|
||||
try:
|
||||
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
|
||||
except ModuleNotFoundError:
|
||||
@@ -19,18 +21,34 @@ except ModuleNotFoundError:
|
||||
logger = logging.getLogger("peripheral-task-sync")
|
||||
STATUS = {"draft": "scheduled", "open": "assigned", "in_progress": "in_progress",
|
||||
"awaiting_reply": "awaiting_feedback", "completed": "completed", "cancelled": "cancelled"}
|
||||
EVENT_WORK_ORDER_TYPES = frozenset({"water_quality_complaint", "burst_report"})
|
||||
|
||||
|
||||
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"]
|
||||
work_order_type = order.get("work_order_type", "general")
|
||||
if work_order_type in EVENT_WORK_ORDER_TYPES or work_order_id in linked_order_ids:
|
||||
return True
|
||||
return not (
|
||||
work_order_type == "general"
|
||||
and order.get("origin") == "synthetic"
|
||||
and order.get("created_by") == str(SEED_ACTOR_ID)
|
||||
)
|
||||
|
||||
|
||||
def work_order_snapshot(order: dict) -> dict:
|
||||
"""Preserve external process facts without deriving stages from total status."""
|
||||
"""Preserve the flat order and its recorded activity without inventing stages."""
|
||||
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",
|
||||
"assignee", "scheduled_at", "location", "scenario_code",
|
||||
)})
|
||||
snapshot["feedback_requirements"] = order.get("feedback_requirements") or []
|
||||
snapshot["activity"] = order.get("activity") or []
|
||||
snapshot["work_order_type"] = order.get("work_order_type", "general")
|
||||
return snapshot
|
||||
|
||||
@@ -39,7 +57,10 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
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
|
||||
existing: dict[str, dict] = {}
|
||||
event_tasks: dict[str | None, dict] = {}
|
||||
investigations: list[dict] = []
|
||||
offset = 0
|
||||
while True:
|
||||
response = await client.get(tasks_url, headers=headers, params={"limit": 100, "offset": offset})
|
||||
response.raise_for_status()
|
||||
@@ -63,6 +84,7 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
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"])
|
||||
linked_order_ids = set(linked)
|
||||
|
||||
cursor, synchronized = None, 0
|
||||
while True:
|
||||
@@ -76,14 +98,16 @@ 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")
|
||||
if not should_project_work_order(order, linked_order_ids):
|
||||
continue
|
||||
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"}
|
||||
event_kind in EVENT_WORK_ORDER_TYPES
|
||||
and order["work_order_id"] not in event_tasks
|
||||
and order["work_order_id"] not in linked
|
||||
and order["work_order_id"] not in linked_order_ids
|
||||
):
|
||||
event_request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:investigation"))
|
||||
event = await client.post(tasks_url, headers=headers, json={
|
||||
|
||||
Reference in New Issue
Block a user