fix(workbench): remove synthetic check projections

This commit is contained in:
2026-09-24 12:01:01 +08:00
parent 1a6ff64bbe
commit e374b5b3cb
25 changed files with 953 additions and 935 deletions
+9 -11
View File
@@ -10,7 +10,7 @@ from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
from app.domain.schemas.operational_data import TaskDataSnapshot from app.domain.schemas.operational_data import TaskDataSnapshot
from app.domain.schemas.workbench_runtime import ( from app.domain.schemas.workbench_runtime import (
WorkbenchDateTime, WorkbenchDateTime,
WorkbenchRuntimeWorkOrderSubtask, WorkOrderActivity,
) )
@@ -290,19 +290,17 @@ class ExternalWorkOrderSnapshot(StrictModel):
work_order_type: Literal["general", "water_quality_complaint", "burst_report"] = "general" work_order_type: Literal["general", "water_quality_complaint", "burst_report"] = "general"
location: NonEmptyStr | None = None location: NonEmptyStr | None = None
scenario_code: NonEmptyStr | None = None scenario_code: NonEmptyStr | None = None
subtasks: Annotated[ feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
list[WorkbenchRuntimeWorkOrderSubtask], Field(min_length=1, max_length=50) activity: list[WorkOrderActivity] = Field(default_factory=list)
] | None = None
origin: Literal["synthetic"] origin: Literal["synthetic"]
updated_at: WorkbenchDateTime updated_at: WorkbenchDateTime
@model_validator(mode="after") @model_validator(mode="before")
def unique_subtasks(self): @classmethod
if self.subtasks is not None and len( def accept_legacy_snapshot(cls, value):
{item.id for item in self.subtasks} if isinstance(value, dict):
) != len(self.subtasks): return {key: item for key, item in value.items() if key != "subtasks"}
raise ValueError("work order subtask ids must be unique") return value
return self
class OperationalTaskRun(StrictModel): class OperationalTaskRun(StrictModel):
+26 -43
View File
@@ -2,7 +2,7 @@
from typing import Annotated, Literal from typing import Annotated, Literal
from pydantic import AwareDatetime, BeforeValidator, JsonValue, model_validator from pydantic import AwareDatetime, BeforeValidator, Field, JsonValue, model_validator
from app.domain.schemas.shared import ( from app.domain.schemas.shared import (
NonEmptyStr, NonEmptyStr,
@@ -129,42 +129,13 @@ class WorkbenchAnalysisHistoryPage(StrictModel):
next_offset: NonNegativeInt | None next_offset: NonNegativeInt | None
class WorkbenchRuntimeWorkOrderStage(StrictModel): class WorkOrderActivity(StrictModel):
id: NonEmptyStr
sequence: PositiveInt sequence: PositiveInt
occurred_at: WorkbenchDateTime
actor: NonEmptyStr
kind: Literal["created", "updated", "feedback"]
title: NonEmptyStr title: NonEmptyStr
status: Literal["done", "current", "pending", "blocked", "skipped"] detail: NonEmptyStr
owner: NonEmptyStr
time_label: NonEmptyStr
description: NonEmptyStr
class WorkbenchRuntimeWorkOrderSubtask(StrictModel):
id: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
status: Literal["pending", "running", "blocked", "completed", "cancelled"]
scheduled_at: WorkbenchDateTime
location: NonEmptyStr
assignee: NonEmptyStr
duration_minutes: PositiveInt
reply_window_minutes: PositiveInt
stages: list[WorkbenchRuntimeWorkOrderStage]
reply_requirements: list[NonEmptyStr]
@model_validator(mode="after")
def validate_stages(self):
if not self.stages:
raise ValueError("subtask stages must not be empty")
if [stage.sequence for stage in self.stages] != list(
range(1, len(self.stages) + 1)
):
raise ValueError("subtask stages must use contiguous sequence numbers")
if len({stage.id for stage in self.stages}) != len(self.stages):
raise ValueError("subtask stage ids must be unique")
if sum(stage.status == "current" for stage in self.stages) > 1:
raise ValueError("subtask stages may contain at most one current stage")
return self
class WorkbenchRuntimeWorkOrder(StrictModel): class WorkbenchRuntimeWorkOrder(StrictModel):
@@ -181,16 +152,28 @@ class WorkbenchRuntimeWorkOrder(StrictModel):
duration_minutes: PositiveInt duration_minutes: PositiveInt
source: NonEmptyStr source: NonEmptyStr
dispatcher: NonEmptyStr dispatcher: NonEmptyStr
location: NonEmptyStr | None = None
assignee: NonEmptyStr | None = None
priority: Literal["normal", "urgent"] priority: Literal["normal", "urgent"]
subtasks: list[WorkbenchRuntimeWorkOrderSubtask] feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
activity: list[WorkOrderActivity] = Field(default_factory=list)
@model_validator(mode="after") @model_validator(mode="before")
def validate_subtasks(self): @classmethod
if not self.subtasks: def accept_legacy_document(cls, value):
raise ValueError("work order subtasks must not be empty") if not isinstance(value, dict) or "subtasks" not in value:
if len({subtask.id for subtask in self.subtasks}) != len(self.subtasks): return value
raise ValueError("work order subtask ids must be unique") document = {key: item for key, item in value.items() if key != "subtasks"}
return self subtasks = value.get("subtasks") or []
first = subtasks[0] if subtasks else {}
document.setdefault("location", first.get("location"))
document.setdefault("assignee", first.get("assignee"))
document.setdefault("feedback_requirements", list(dict.fromkeys(
requirement for subtask in subtasks
for requirement in subtask.get("reply_requirements", [])
)))
document.setdefault("activity", [])
return document
class WorkbenchRuntimeSnapshot(StrictModel): class WorkbenchRuntimeSnapshot(StrictModel):
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "3669e1b9ba31dad83f9c53028fd974b8bacac0f9d3069f644e49254baf6bb82f" "sha256": "c37f5626398e1762584a844bb908ca4f4fb68e203f4d37a489561669a434312b"
} }
} }
} }
+101 -196
View File
@@ -251,6 +251,15 @@
], ],
"title": "Assignee" "title": "Assignee"
}, },
"feedback_requirements": {
"items": {
"maxLength": 4000,
"minLength": 1,
"type": "string"
},
"title": "Feedback Requirements",
"type": "array"
},
"location": { "location": {
"anyOf": [ "anyOf": [
{ {
@@ -318,22 +327,6 @@
"title": "Status", "title": "Status",
"type": "string" "type": "string"
}, },
"subtasks": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
},
"maxItems": 50,
"minItems": 1,
"type": "array"
},
{
"type": "null"
}
],
"title": "Subtasks"
},
"summary": { "summary": {
"maxLength": 4000, "maxLength": 4000,
"minLength": 1, "minLength": 1,
@@ -1654,16 +1647,27 @@
"title": "Clear Scheduled At", "title": "Clear Scheduled At",
"type": "boolean" "type": "boolean"
}, },
"clear_subtasks": {
"default": false,
"title": "Clear Subtasks",
"type": "boolean"
},
"expected_revision": { "expected_revision": {
"minimum": 1.0, "minimum": 1.0,
"title": "Expected Revision", "title": "Expected Revision",
"type": "integer" "type": "integer"
}, },
"feedback_requirements": {
"anyOf": [
{
"items": {
"maxLength": 4000,
"minLength": 1,
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Feedback Requirements"
},
"location": { "location": {
"anyOf": [ "anyOf": [
{ {
@@ -1694,6 +1698,19 @@
], ],
"title": "Priority" "title": "Priority"
}, },
"progress_note": {
"anyOf": [
{
"maxLength": 4000,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Progress Note"
},
"request_id": { "request_id": {
"format": "uuid", "format": "uuid",
"title": "Request Id", "title": "Request Id",
@@ -1743,22 +1760,6 @@
], ],
"title": "Status" "title": "Status"
}, },
"subtasks": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
},
"maxItems": 50,
"minItems": 1,
"type": "array"
},
{
"type": "null"
}
],
"title": "Subtasks"
},
"summary": { "summary": {
"anyOf": [ "anyOf": [
{ {
@@ -1796,6 +1797,13 @@
"WorkOrder": { "WorkOrder": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"activity": {
"items": {
"$ref": "#/components/schemas/WorkOrderActivity"
},
"title": "Activity",
"type": "array"
},
"assignee": { "assignee": {
"anyOf": [ "anyOf": [
{ {
@@ -1816,6 +1824,13 @@
"title": "Created By", "title": "Created By",
"type": "string" "type": "string"
}, },
"feedback_requirements": {
"items": {
"type": "string"
},
"title": "Feedback Requirements",
"type": "array"
},
"location": { "location": {
"anyOf": [ "anyOf": [
{ {
@@ -1887,22 +1902,6 @@
"title": "Status", "title": "Status",
"type": "string" "type": "string"
}, },
"subtasks": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
},
"maxItems": 50,
"minItems": 1,
"type": "array"
},
{
"type": "null"
}
],
"title": "Subtasks"
},
"summary": { "summary": {
"title": "Summary", "title": "Summary",
"type": "string" "type": "string"
@@ -1955,6 +1954,55 @@
"title": "WorkOrder", "title": "WorkOrder",
"type": "object" "type": "object"
}, },
"WorkOrderActivity": {
"additionalProperties": false,
"properties": {
"actor": {
"minLength": 1,
"title": "Actor",
"type": "string"
},
"detail": {
"minLength": 1,
"title": "Detail",
"type": "string"
},
"kind": {
"enum": [
"created",
"updated",
"feedback"
],
"title": "Kind",
"type": "string"
},
"occurred_at": {
"format": "date-time",
"title": "Occurred At",
"type": "string"
},
"sequence": {
"minimum": 1.0,
"title": "Sequence",
"type": "integer"
},
"title": {
"minLength": 1,
"title": "Title",
"type": "string"
}
},
"required": [
"sequence",
"occurred_at",
"actor",
"kind",
"title",
"detail"
],
"title": "WorkOrderActivity",
"type": "object"
},
"WorkOrderPage": { "WorkOrderPage": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -1984,149 +2032,6 @@
], ],
"title": "WorkOrderPage", "title": "WorkOrderPage",
"type": "object" "type": "object"
},
"WorkbenchRuntimeWorkOrderStage": {
"additionalProperties": false,
"properties": {
"description": {
"minLength": 1,
"title": "Description",
"type": "string"
},
"id": {
"minLength": 1,
"title": "Id",
"type": "string"
},
"owner": {
"minLength": 1,
"title": "Owner",
"type": "string"
},
"sequence": {
"minimum": 1.0,
"title": "Sequence",
"type": "integer"
},
"status": {
"enum": [
"done",
"current",
"pending",
"blocked",
"skipped"
],
"title": "Status",
"type": "string"
},
"time_label": {
"minLength": 1,
"title": "Time Label",
"type": "string"
},
"title": {
"minLength": 1,
"title": "Title",
"type": "string"
}
},
"required": [
"id",
"sequence",
"title",
"status",
"owner",
"time_label",
"description"
],
"title": "WorkbenchRuntimeWorkOrderStage",
"type": "object"
},
"WorkbenchRuntimeWorkOrderSubtask": {
"additionalProperties": false,
"properties": {
"assignee": {
"minLength": 1,
"title": "Assignee",
"type": "string"
},
"duration_minutes": {
"minimum": 1.0,
"title": "Duration Minutes",
"type": "integer"
},
"id": {
"minLength": 1,
"title": "Id",
"type": "string"
},
"location": {
"minLength": 1,
"title": "Location",
"type": "string"
},
"reply_requirements": {
"items": {
"minLength": 1,
"type": "string"
},
"title": "Reply Requirements",
"type": "array"
},
"reply_window_minutes": {
"minimum": 1.0,
"title": "Reply Window Minutes",
"type": "integer"
},
"scheduled_at": {
"format": "date-time",
"title": "Scheduled At",
"type": "string"
},
"stages": {
"items": {
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderStage"
},
"title": "Stages",
"type": "array"
},
"status": {
"enum": [
"pending",
"running",
"blocked",
"completed",
"cancelled"
],
"title": "Status",
"type": "string"
},
"summary": {
"minLength": 1,
"title": "Summary",
"type": "string"
},
"title": {
"minLength": 1,
"title": "Title",
"type": "string"
}
},
"required": [
"id",
"title",
"summary",
"status",
"scheduled_at",
"location",
"assignee",
"duration_minutes",
"reply_window_minutes",
"stages",
"reply_requirements"
],
"title": "WorkbenchRuntimeWorkOrderSubtask",
"type": "object"
} }
} }
}, },
+107 -314
View File
@@ -2131,7 +2131,7 @@
"work_order_snapshot": { "work_order_snapshot": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/components/schemas/ExternalWorkOrderSnapshot-Input" "$ref": "#/components/schemas/ExternalWorkOrderSnapshot"
}, },
{ {
"type": "null" "type": "null"
@@ -2235,9 +2235,16 @@
"title": "ExternalFeedbackResult", "title": "ExternalFeedbackResult",
"type": "object" "type": "object"
}, },
"ExternalWorkOrderSnapshot-Input": { "ExternalWorkOrderSnapshot": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"activity": {
"items": {
"$ref": "#/components/schemas/WorkOrderActivity"
},
"title": "Activity",
"type": "array"
},
"assignee": { "assignee": {
"anyOf": [ "anyOf": [
{ {
@@ -2250,6 +2257,14 @@
], ],
"title": "Assignee" "title": "Assignee"
}, },
"feedback_requirements": {
"items": {
"minLength": 1,
"type": "string"
},
"title": "Feedback Requirements",
"type": "array"
},
"location": { "location": {
"anyOf": [ "anyOf": [
{ {
@@ -2318,165 +2333,6 @@
"title": "Status", "title": "Status",
"type": "string" "type": "string"
}, },
"subtasks": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
},
"maxItems": 50,
"minItems": 1,
"type": "array"
},
{
"type": "null"
}
],
"title": "Subtasks"
},
"summary": {
"minLength": 1,
"title": "Summary",
"type": "string"
},
"title": {
"minLength": 1,
"title": "Title",
"type": "string"
},
"updated_at": {
"format": "date-time",
"title": "Updated At",
"type": "string"
},
"work_order_id": {
"format": "uuid",
"title": "Work Order Id",
"type": "string"
},
"work_order_type": {
"default": "general",
"enum": [
"general",
"water_quality_complaint",
"burst_report"
],
"title": "Work Order Type",
"type": "string"
}
},
"required": [
"work_order_id",
"revision",
"title",
"summary",
"status",
"priority",
"origin",
"updated_at"
],
"title": "ExternalWorkOrderSnapshot",
"type": "object"
},
"ExternalWorkOrderSnapshot-Output": {
"additionalProperties": false,
"properties": {
"assignee": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Assignee"
},
"location": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Location"
},
"origin": {
"const": "synthetic",
"title": "Origin",
"type": "string"
},
"priority": {
"enum": [
"low",
"normal",
"high",
"urgent"
],
"title": "Priority",
"type": "string"
},
"revision": {
"minimum": 1.0,
"title": "Revision",
"type": "integer"
},
"scenario_code": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Scenario Code"
},
"scheduled_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Scheduled At"
},
"status": {
"enum": [
"draft",
"open",
"in_progress",
"awaiting_reply",
"completed",
"cancelled"
],
"title": "Status",
"type": "string"
},
"subtasks": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
},
"maxItems": 50,
"minItems": 1,
"type": "array"
},
{
"type": "null"
}
],
"title": "Subtasks"
},
"summary": { "summary": {
"minLength": 1, "minLength": 1,
"title": "Summary", "title": "Summary",
@@ -3705,7 +3561,7 @@
"work_order_snapshot": { "work_order_snapshot": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/components/schemas/ExternalWorkOrderSnapshot-Output" "$ref": "#/components/schemas/ExternalWorkOrderSnapshot"
}, },
{ {
"type": "null" "type": "null"
@@ -6577,6 +6433,55 @@
"title": "WebSearchRequest", "title": "WebSearchRequest",
"type": "object" "type": "object"
}, },
"WorkOrderActivity": {
"additionalProperties": false,
"properties": {
"actor": {
"minLength": 1,
"title": "Actor",
"type": "string"
},
"detail": {
"minLength": 1,
"title": "Detail",
"type": "string"
},
"kind": {
"enum": [
"created",
"updated",
"feedback"
],
"title": "Kind",
"type": "string"
},
"occurred_at": {
"format": "date-time",
"title": "Occurred At",
"type": "string"
},
"sequence": {
"minimum": 1.0,
"title": "Sequence",
"type": "integer"
},
"title": {
"minLength": 1,
"title": "Title",
"type": "string"
}
},
"required": [
"sequence",
"occurred_at",
"actor",
"kind",
"title",
"detail"
],
"title": "WorkOrderActivity",
"type": "object"
},
"WorkbenchAlertSource": { "WorkbenchAlertSource": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -7217,6 +7122,25 @@
"WorkbenchRuntimeWorkOrder": { "WorkbenchRuntimeWorkOrder": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"activity": {
"items": {
"$ref": "#/components/schemas/WorkOrderActivity"
},
"title": "Activity",
"type": "array"
},
"assignee": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Assignee"
},
"code": { "code": {
"minLength": 1, "minLength": 1,
"title": "Code", "title": "Code",
@@ -7237,11 +7161,31 @@
"title": "Duration Minutes", "title": "Duration Minutes",
"type": "integer" "type": "integer"
}, },
"feedback_requirements": {
"items": {
"minLength": 1,
"type": "string"
},
"title": "Feedback Requirements",
"type": "array"
},
"id": { "id": {
"minLength": 1, "minLength": 1,
"title": "Id", "title": "Id",
"type": "string" "type": "string"
}, },
"location": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Location"
},
"priority": { "priority": {
"enum": [ "enum": [
"normal", "normal",
@@ -7291,13 +7235,6 @@
"title": "Status", "title": "Status",
"type": "string" "type": "string"
}, },
"subtasks": {
"items": {
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
},
"title": "Subtasks",
"type": "array"
},
"summary": { "summary": {
"minLength": 1, "minLength": 1,
"title": "Summary", "title": "Summary",
@@ -7328,155 +7265,11 @@
"duration_minutes", "duration_minutes",
"source", "source",
"dispatcher", "dispatcher",
"priority", "priority"
"subtasks"
], ],
"title": "WorkbenchRuntimeWorkOrder", "title": "WorkbenchRuntimeWorkOrder",
"type": "object" "type": "object"
}, },
"WorkbenchRuntimeWorkOrderStage": {
"additionalProperties": false,
"properties": {
"description": {
"minLength": 1,
"title": "Description",
"type": "string"
},
"id": {
"minLength": 1,
"title": "Id",
"type": "string"
},
"owner": {
"minLength": 1,
"title": "Owner",
"type": "string"
},
"sequence": {
"minimum": 1.0,
"title": "Sequence",
"type": "integer"
},
"status": {
"enum": [
"done",
"current",
"pending",
"blocked",
"skipped"
],
"title": "Status",
"type": "string"
},
"time_label": {
"minLength": 1,
"title": "Time Label",
"type": "string"
},
"title": {
"minLength": 1,
"title": "Title",
"type": "string"
}
},
"required": [
"id",
"sequence",
"title",
"status",
"owner",
"time_label",
"description"
],
"title": "WorkbenchRuntimeWorkOrderStage",
"type": "object"
},
"WorkbenchRuntimeWorkOrderSubtask": {
"additionalProperties": false,
"properties": {
"assignee": {
"minLength": 1,
"title": "Assignee",
"type": "string"
},
"duration_minutes": {
"minimum": 1.0,
"title": "Duration Minutes",
"type": "integer"
},
"id": {
"minLength": 1,
"title": "Id",
"type": "string"
},
"location": {
"minLength": 1,
"title": "Location",
"type": "string"
},
"reply_requirements": {
"items": {
"minLength": 1,
"type": "string"
},
"title": "Reply Requirements",
"type": "array"
},
"reply_window_minutes": {
"minimum": 1.0,
"title": "Reply Window Minutes",
"type": "integer"
},
"scheduled_at": {
"format": "date-time",
"title": "Scheduled At",
"type": "string"
},
"stages": {
"items": {
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderStage"
},
"title": "Stages",
"type": "array"
},
"status": {
"enum": [
"pending",
"running",
"blocked",
"completed",
"cancelled"
],
"title": "Status",
"type": "string"
},
"summary": {
"minLength": 1,
"title": "Summary",
"type": "string"
},
"title": {
"minLength": 1,
"title": "Title",
"type": "string"
}
},
"required": [
"id",
"title",
"summary",
"status",
"scheduled_at",
"location",
"assignee",
"duration_minutes",
"reply_window_minutes",
"stages",
"reply_requirements"
],
"title": "WorkbenchRuntimeWorkOrderSubtask",
"type": "object"
},
"WorkbenchSyncSnapshot": { "WorkbenchSyncSnapshot": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
+2
View File
@@ -81,6 +81,8 @@ conda run -n server python scripts/sync_peripheral_work_orders.py
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器读取分页工单,将外围版本投影到 `external_work_order` 任务。合成来源在摘要和证据中明确标注;工单完成只表示该外部任务完成,不代表其他调查自动完成。 使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器读取分页工单,将外围版本投影到 `external_work_order` 任务。合成来源在摘要和证据中明确标注;工单完成只表示该外部任务完成,不代表其他调查自动完成。
通用合成检查工单不属于预约任务。早期种子脚本创建的通信、压力和零流量检查工单已经退役,种子脚本不再恢复它们;同步器按不可编辑的专用 seed actor 跳过旧种子工单。投诉、爆管事件以及调查动作实际创建的现场工单继续同步。`018_remove_synthetic_check_task_projections.sql` 只清理专用 seed actor 创建的旧检查投影,不按用户可编辑的标题或状态删除任务,也不影响 `workbench.work_orders` 中保留的三条演示工单。
水质投诉和爆管上报类型的外围工单同时创建关联调查,即使当时没有浏览器打开也会入队。两类事件从外围样板测试面板模拟上报;工况页负责展示调查。调查中创建的外围工单通过回执编号关联,后续反馈送回原调查,由 Agent 重新分析。 水质投诉和爆管上报类型的外围工单同时创建关联调查,即使当时没有浏览器打开也会入队。两类事件从外围样板测试面板模拟上报;工况页负责展示调查。调查中创建的外围工单通过回执编号关联,后续反馈送回原调查,由 Agent 重新分析。
## 旧调查迁移 ## 旧调查迁移
+4 -1
View File
@@ -6,6 +6,8 @@
- 定时任务:当前产品继续使用前端 mock。`scheduled_flow` 及后端计划接口为已有保留能力,不接当前前端、不由当前 worker 调度或执行。 - 定时任务:当前产品继续使用前端 mock。`scheduled_flow` 及后端计划接口为已有保留能力,不接当前前端、不由当前 worker 调度或执行。
- `external_work_order`:外围预约、维修等工单的只读任务投影。`source.kind + source.external_id` 在项目内唯一,进度以外部版本反馈为准。 - `external_work_order`:外围预约、维修等工单的只读任务投影。`source.kind + source.external_id` 在项目内唯一,进度以外部版本反馈为准。
预约任务采用单层结构:一张外围工单对应一条任务,工单快照直接提供状态、位置、负责人、预约时间、回执要求和按发生时间记录的 `activity`。活动仅由工单创建、修改或显式现场反馈产生;不能按当前状态补造派发、执行、复令三个阶段。旧快照中的 `subtasks` 仅在读取时兼容并丢弃,不回写外围工单或历史版本;后续外围修订同步为新结构。
- `event_investigation`:投诉、爆管上报或人工发起的调查。Agent 查询受控数据,发布执行图、证据引用和报告;现场反馈会创建新的执行 generation,让 Agent 重新分析。 - `event_investigation`:投诉、爆管上报或人工发起的调查。Agent 查询受控数据,发布执行图、证据引用和报告;现场反馈会创建新的执行 generation,让 Agent 重新分析。
创建请求使用 `request_id` 幂等。任务执行使用独立的 `run_id + execution_request_id`:Agent 必须领取当前 run,并通过 run 级心跳维持 60 秒租约。正常结束必须显式 complete;缺少同代最终计划或报告会以 `ARTIFACT_INCOMPLETE` 失败。`POST /operational-tasks/leases/recover` 会处理过期执行,连续三代超时后停止自动重试。 创建请求使用 `request_id` 幂等。任务执行使用独立的 `run_id + execution_request_id`:Agent 必须领取当前 run,并通过 run 级心跳维持 60 秒租约。正常结束必须显式 complete;缺少同代最终计划或报告会以 `ARTIFACT_INCOMPLETE` 失败。`POST /operational-tasks/leases/recover` 会处理过期执行,连续三代超时后停止自动重试。
@@ -60,6 +62,7 @@ psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/011_workbench_work_o
psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/014_operational_tasks.sql psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/014_operational_tasks.sql
psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/015_workbench_events_and_task_runs.sql psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/015_workbench_events_and_task_runs.sql
psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/017_workbench_schema_comments.sql psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/017_workbench_schema_comments.sql
psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/018_remove_synthetic_check_task_projections.sql
``` ```
`017_workbench_schema_comments.sql` 只增加数据库可见的表、列和 schema 注释,不改变数据、约束或索引。迁移不会创建或修改外围系统的业务表,也不授予 `PUBLIC` 权限。生产运行前需按部署身份显式授予新表权限。SQL 文件包含自己的事务边界,不能把外层 `ROLLBACK` 当作演练;具体要求见数据库变更门禁。 `017_workbench_schema_comments.sql` 只增加数据库可见的表、列和 schema 注释,不改变数据、约束或索引。`018_remove_synthetic_check_task_projections.sql` 读取 `peripheral_sample.work_orders` 的不可编辑创建者标识,删除错误进入预约任务的旧种子检查投影,不修改外围表,也不删除演示工单、事件工单或调查动作创建的工单。迁移不授予 `PUBLIC` 权限。生产运行前需按部署身份显式授予新表权限。SQL 文件包含自己的事务边界,不能把外层 `ROLLBACK` 当作演练;具体要求见数据库变更门禁。
+3 -1
View File
@@ -44,7 +44,9 @@ CREATE TABLE peripheral_sample.demo_datasets (
conda run -n server python scripts/seed_peripheral_sample.py --project-id <project-uuid> conda run -n server python scripts/seed_peripheral_sample.py --project-id <project-uuid>
``` ```
脚本使用固定 UUIDv5 请求号和专用 synthetic actor,重复执行会读取原幂等回执,不重复创建。它核验或补充 `water-demo-v1`,并创建 3 张测试工单和 12 条带 `DEMO-` 前缀的可编辑测试数据;不创建缺少合法 Action 来源的外围核查任务,不写正式工单、资产或监测表。 脚本使用固定 UUIDv5 请求号和专用 synthetic actor,重复执行会读取原幂等回执,不重复创建。它核验或补充 `water-demo-v1`,并创建 12 条带 `DEMO-` 前缀的可编辑测试数据;不再创建早期用于检查通信、压力和零流量展示的三张测试工单,也不创建缺少合法 Action 来源的外围核查任务,不写正式工单、资产或监测表。
通过工单接口新建的预约工单不再包含子任务和固定阶段。创建请求可直接填写 `feedback_requirements`;修改请求可填写 `progress_note`,它只追加一条有时间、操作者和正文的活动记录,不作为工单顶层字段保存。旧工单读取时去掉 `subtasks`,历史修订仍保留原样。
## 演示历史窗口配置 ## 演示历史窗口配置
+4
View File
@@ -8,6 +8,10 @@
`017_workbench_schema_comments.sql` 只为 `workbench` schema 及其 17 张表和关键列增加数据库可见注释。它先在 `tjwater_v2` 应用;其他项目数据库保持原状,直到 `tjwater_v2` 验证通过并获得明确扩展确认。 `017_workbench_schema_comments.sql` 只为 `workbench` schema 及其 17 张表和关键列增加数据库可见注释。它先在 `tjwater_v2` 应用;其他项目数据库保持原状,直到 `tjwater_v2` 验证通过并获得明确扩展确认。
`018_remove_synthetic_check_task_projections.sql` 清理曾被错误同步为预约任务的通用合成检查投影。它按完整 `peripheral_sample:<work_order_id>` 标识关联外围源,只匹配 `general` 类型、没有调查动作回执关联且由专用 seed actor 创建的旧检查任务;不使用用户可编辑的标题或工单状态作为删除依据。`workbench.work_orders` 中三条演示工单、投诉与爆管事件以及调查产生的现场采样工单均不在清理范围。同步器使用同一专用 seed actor 阻止旧种子检查再次生成。
预约工单结构调整无需 DDL。`seed_workbench_work_orders.py --project-id <已核对的项目 UUID>` 只重建该项目的三条演示预约投影,保留工单 ID 和标题,改为单层字段与显式活动记录。执行前必须核对脚本删除谓词实际命中的 ID;它不修改 `peripheral_sample.work_orders`、历史修订或调查任务。其他项目不得随 `tjwater_v2` 自动重建。
后续数据库变更默认只写入 `tjwater_v2`。其他项目保持现状,必须等 `tjwater_v2` 验证通过并由维护者明确确认后,再逐库扩展。禁止把“所有活跃项目”作为写操作的默认范围。 后续数据库变更默认只写入 `tjwater_v2`。其他项目保持现状,必须等 `tjwater_v2` 验证通过并由维护者明确确认后,再逐库扩展。禁止把“所有活跃项目”作为写操作的默认范围。
## 变更顺序 ## 变更顺序
+6 -5
View File
@@ -12,11 +12,12 @@ DDL 位于 `resources/sql/008_workbench_alert_events.sql`。任务通过 `POST /
`resources/sql/017_workbench_schema_comments.sql` 在 `008`、`011`、`014` 和 `015` 之后执行,为工作台 schema、17 张表和关键列增加数据库可见的用途说明。 `resources/sql/017_workbench_schema_comments.sql` 在 `008`、`011`、`014` 和 `015` 之后执行,为工作台 schema、17 张表和关键列增加数据库可见的用途说明。
- 一张主工单包含一个或多个 `subtasks`。 - 一张预约工单对应一条单层投影,直接保存位置、负责人、预约时间和回执要求。
- 每个子任务保存自己的位置、班组、时间窗和复令要求。 - `activity` 只记录已经发生的创建、修改和现场反馈,按递增 `sequence` 排列;展示层不得根据工单总状态补造派发、执行或复令阶段。
- 每个子任务包含一个或多个有序 `stages`,`sequence` 从 1 连续递增,阶段编号在子任务内唯一,最多只有一个 `current` 阶段。 - 旧文档中的 `subtasks` 在兼容读取时被丢弃,不再进入新接口契约;历史修订保留原始审计内容。
- 阶段名称不写死为固定枚举。演示数据使用派发、执行、复令,正式工单适配器可以按业务定义其他阶段。
预约任务演示数据通过 `scripts/seed_workbench_work_orders.py` 直接写入。工作台会显示三张原有 mock 工单,分别处于派发、执行和复令阶段;每张工单都保留自己的子任务与有序阶段。脚本可重复执行,并会先清理旧的外围合成工单投影。外围种子脚本仍会恢复被软删除的固定演示资源。 预约任务演示数据通过 `scripts/seed_workbench_work_orders.py` 直接写入。工作台继续显示三张原有演示工单,保留既有 ID、标题和状态,并改用回执要求与真实活动记录表达过程。脚本可重复执行,并会先清理旧的外围合成工单投影。`scripts/seed_peripheral_sample.py` 不再创建通信、压力和零流量检查工单。
截至 2026-09-24,本次数据清理和三张演示工单结构重建仅应用于物理数据库 `tjwater_v2`,项目为 `324ff7ee-c2d4-5184-a84b-48fb14a2933e`。核验结果为三张演示工单均使用单层结构,旧种子检查投影为 0;其他项目数据库未执行本次变更。扩大范围必须遵循 [`workbench-database-rollout.md`](workbench-database-rollout.md) 的逐库门禁。
工作台运行账号对工单表只需 `SELECT`,同步账号使用独立受控写权限;对告警事件表需要 `SELECT, INSERT`,对告警状态表需要 `SELECT, INSERT, UPDATE`。 工作台运行账号对工单表只需 `SELECT`,同步账号使用独立受控写权限;对告警事件表需要 `SELECT, INSERT`,对告警状态表需要 `SELECT, INSERT, UPDATE`。
@@ -0,0 +1,74 @@
-- Remove synthetic check fixtures that were incorrectly projected as appointments.
BEGIN;
CREATE TEMP TABLE synthetic_check_task_cleanup ON COMMIT DROP AS
SELECT
task.project_id,
task.task_id,
task.revision,
task.document->>'title' AS title
FROM workbench.operational_tasks AS task
WHERE task.task_type = 'external_work_order'
AND task.document#>>'{source,kind}' = 'external_system'
AND COALESCE(task.document#>>'{source,event_kind}', 'general') = 'general'
AND EXISTS (
SELECT 1
FROM peripheral_sample.work_orders AS source_order
WHERE source_order.project_id = task.project_id
AND task.document#>>'{source,external_id}' =
'peripheral_sample:' || source_order.work_order_id::text
AND source_order.document->>'created_by' =
'f1a20d4c-b7b7-5b67-b134-92e5ac541162'
)
AND NOT EXISTS (
SELECT 1
FROM workbench.operational_task_actions AS action
WHERE action.project_id = task.project_id
AND action.document->>'kind' = 'create_work_order'
AND action.document#>>'{receipt,adapter}' = 'peripheral_sample'
AND action.document#>>'{receipt,receipt_id}' =
split_part(task.document#>>'{source,external_id}', ':', 2)
);
DELETE FROM workbench.operational_task_requests AS request
USING synthetic_check_task_cleanup AS cleanup
WHERE request.project_id = cleanup.project_id
AND (
request.scope LIKE 'task:' || cleanup.task_id::text || ':%'
OR request.response->>'task_id' = cleanup.task_id::text
OR request.response#>>'{task,task_id}' = cleanup.task_id::text
OR request.response#>>'{notification,task_id}' = cleanup.task_id::text
);
DELETE FROM workbench.operational_tasks AS task
USING synthetic_check_task_cleanup AS cleanup
WHERE (task.project_id, task.task_id) = (cleanup.project_id, cleanup.task_id);
INSERT INTO workbench.domain_event_log (
project_id,
event_id,
topic,
aggregate_type,
aggregate_id,
aggregate_revision,
metadata
)
SELECT
project_id,
(
substr(md5(project_id::text || ':' || task_id::text || ':synthetic-check-cleanup'), 1, 8)
|| '-' || substr(md5(project_id::text || ':' || task_id::text || ':synthetic-check-cleanup'), 9, 4)
|| '-5' || substr(md5(project_id::text || ':' || task_id::text || ':synthetic-check-cleanup'), 14, 3)
|| '-a' || substr(md5(project_id::text || ':' || task_id::text || ':synthetic-check-cleanup'), 18, 3)
|| '-' || substr(md5(project_id::text || ':' || task_id::text || ':synthetic-check-cleanup'), 21, 12)
)::uuid,
'operational.task.changed',
'operational_task',
task_id::text,
revision + 1,
jsonb_build_object('change', 'deleted', 'reason', 'synthetic_check_projection_cleanup')
FROM synthetic_check_task_cleanup
ON CONFLICT (project_id, event_id) DO NOTHING;
COMMIT;
+12 -25
View File
@@ -5,7 +5,7 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, model_validator from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, model_validator
from app.domain.schemas.shared import ObjectRef, TimeRange, WireDateTime from app.domain.schemas.shared import ObjectRef, TimeRange, WireDateTime
from app.domain.schemas.workbench_runtime import WorkbenchRuntimeWorkOrderSubtask from app.domain.schemas.workbench_runtime import WorkOrderActivity
Text = Annotated[str, Field(min_length=1, max_length=4000)] Text = Annotated[str, Field(min_length=1, max_length=4000)]
PositiveInt = Annotated[StrictInt, Field(ge=1)] PositiveInt = Annotated[StrictInt, Field(ge=1)]
@@ -122,18 +122,6 @@ WorkOrderStatus = Literal[
] ]
Priority = Literal["low", "normal", "high", "urgent"] Priority = Literal["low", "normal", "high", "urgent"]
WorkOrderType = Literal["general", "water_quality_complaint", "burst_report"] WorkOrderType = Literal["general", "water_quality_complaint", "burst_report"]
WorkOrderSubtasks = Annotated[
list[WorkbenchRuntimeWorkOrderSubtask], Field(min_length=1, max_length=50)
]
def _validate_unique_subtasks(
subtasks: list[WorkbenchRuntimeWorkOrderSubtask] | None,
) -> None:
if subtasks is not None and len({item.id for item in subtasks}) != len(subtasks):
raise ValueError("work order subtask ids must be unique")
class CreateWorkOrder(Model): class CreateWorkOrder(Model):
request_id: UUID request_id: UUID
title: Annotated[str, Field(min_length=1, max_length=200)] title: Annotated[str, Field(min_length=1, max_length=200)]
@@ -145,13 +133,12 @@ class CreateWorkOrder(Model):
work_order_type: WorkOrderType = "general" work_order_type: WorkOrderType = "general"
location: Annotated[str, Field(min_length=1, max_length=200)] | None = None location: Annotated[str, Field(min_length=1, max_length=200)] | None = None
scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None
subtasks: WorkOrderSubtasks | None = None feedback_requirements: list[Text] = Field(default_factory=list)
@model_validator(mode="after") @model_validator(mode="after")
def aware_schedule(self): def aware_schedule(self):
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None: if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
raise ValueError("scheduled_at must include timezone") raise ValueError("scheduled_at must include timezone")
_validate_unique_subtasks(self.subtasks)
return self return self
@@ -170,8 +157,8 @@ class UpdateWorkOrder(Model):
clear_location: StrictBool = False clear_location: StrictBool = False
scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None
clear_scenario_code: StrictBool = False clear_scenario_code: StrictBool = False
subtasks: WorkOrderSubtasks | None = None feedback_requirements: list[Text] | None = None
clear_subtasks: StrictBool = False progress_note: Text | None = None
@model_validator(mode="after") @model_validator(mode="after")
def valid_update(self): def valid_update(self):
@@ -186,11 +173,8 @@ class UpdateWorkOrder(Model):
raise ValueError("clear_location conflicts with location") raise ValueError("clear_location conflicts with location")
if self.clear_scenario_code and self.scenario_code is not None: if self.clear_scenario_code and self.scenario_code is not None:
raise ValueError("clear_scenario_code conflicts with scenario_code") raise ValueError("clear_scenario_code conflicts with scenario_code")
if self.clear_subtasks and self.subtasks is not None:
raise ValueError("clear_subtasks conflicts with subtasks")
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None: if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
raise ValueError("scheduled_at must include timezone") raise ValueError("scheduled_at must include timezone")
_validate_unique_subtasks(self.subtasks)
return self return self
@@ -217,12 +201,15 @@ class WorkOrder(Model):
work_order_type: WorkOrderType = "general" work_order_type: WorkOrderType = "general"
location: str | None = None location: str | None = None
scenario_code: str | None = None scenario_code: str | None = None
subtasks: WorkOrderSubtasks | None = None feedback_requirements: list[str] = Field(default_factory=list)
activity: list[WorkOrderActivity] = Field(default_factory=list)
@model_validator(mode="after") @model_validator(mode="before")
def unique_subtasks(self): @classmethod
_validate_unique_subtasks(self.subtasks) def accept_legacy_document(cls, value):
return self if isinstance(value, dict):
return {key: item for key, item in value.items() if key != "subtasks"}
return value
class WorkOrderPage(Model): class WorkOrderPage(Model):
+7
View File
@@ -0,0 +1,7 @@
"""Stable identity used only by the explicit peripheral demo-data seed."""
from uuid import NAMESPACE_URL, uuid5
SEED_NAMESPACE = uuid5(NAMESPACE_URL, "tjwater/peripheral-sample/seed/v1")
SEED_ACTOR_ID = uuid5(SEED_NAMESPACE, "actor")
+29 -5
View File
@@ -24,6 +24,11 @@ from .schemas import (
TRANSITIONS = {"pending": ["processing"], "processing": ["pending_review"], TRANSITIONS = {"pending": ["processing"], "processing": ["pending_review"],
"pending_review": ["processing", "completed"], "completed": []} "pending_review": ["processing", "completed"], "completed": []}
WORK_ORDER_FIELD_LABELS = {
"title": "标题", "summary": "说明", "status": "状态", "priority": "优先级",
"assignee": "负责人", "scheduled_at": "预约时间", "location": "位置",
"scenario_code": "场景编号", "feedback_requirements": "回执要求",
}
class PostgresStore: class PostgresStore:
@@ -268,8 +273,13 @@ class PostgresStore:
@staticmethod @staticmethod
def _active_managed_document(document: dict[str, Any]) -> dict[str, Any]: def _active_managed_document(document: dict[str, Any]) -> dict[str, Any]:
"""Project a current active row without legacy soft-delete metadata.""" """Project a current active row without legacy soft-delete metadata."""
return {key: value for key, value in document.items() active = {key: value for key, value in document.items()
if key not in {"deleted_at", "deleted_by"}} if key not in {"deleted_at", "deleted_by"}}
if "work_order_id" in active:
active.pop("subtasks", None)
active.setdefault("feedback_requirements", [])
active.setdefault("activity", [])
return active
def _list_managed(self, who: Identity, table: str, id_column: str, def _list_managed(self, who: Identity, table: str, id_column: str,
limit: int, cursor: UUID | None) -> dict[str, Any]: limit: int, cursor: UUID | None) -> dict[str, Any]:
@@ -314,6 +324,8 @@ class PostgresStore:
"work_order_id": str(uuid4()), "project_id": who.project_id, "revision": 1, "work_order_id": str(uuid4()), "project_id": who.project_id, "revision": 1,
"origin": "synthetic", "created_at": now, "updated_at": now, "origin": "synthetic", "created_at": now, "updated_at": now,
"created_by": who.user_id, "updated_by": who.user_id, "created_by": who.user_id, "updated_by": who.user_id,
"activity": [{"sequence": 1, "occurred_at": now, "actor": who.user_id,
"kind": "created", "title": "工单创建", "detail": "预约工单已创建。"}],
} }
return self._save_work_order(conn, who, document) return self._save_work_order(conn, who, document)
return self._mutate(who, "create_work_order", command, write) return self._mutate(who, "create_work_order", command, write)
@@ -326,7 +338,7 @@ class PostgresStore:
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409) ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409)
changes = command.model_dump(mode="json", exclude_unset=True, changes = command.model_dump(mode="json", exclude_unset=True,
exclude={"request_id", "expected_revision", "clear_assignee", "clear_scheduled_at", exclude={"request_id", "expected_revision", "clear_assignee", "clear_scheduled_at",
"clear_location", "clear_scenario_code", "clear_subtasks"}) "clear_location", "clear_scenario_code", "progress_note"})
if command.clear_assignee: if command.clear_assignee:
changes["assignee"] = None changes["assignee"] = None
if command.clear_scheduled_at: if command.clear_scheduled_at:
@@ -335,10 +347,22 @@ class PostgresStore:
changes["location"] = None changes["location"] = None
if command.clear_scenario_code: if command.clear_scenario_code:
changes["scenario_code"] = None changes["scenario_code"] = None
if command.clear_subtasks: changed_fields = [key for key, value in changes.items() if document.get(key) != value]
changes["subtasks"] = None
document.update(changes) document.update(changes)
document.update(revision=document["revision"] + 1, updated_at=datetime.now(timezone.utc).isoformat(), now = datetime.now(timezone.utc).isoformat()
revision = document["revision"] + 1
if command.progress_note:
title, detail, kind = "现场反馈", command.progress_note, "feedback"
elif changed_fields:
title, detail, kind = "工单信息更新", "更新了" + "、".join(
WORK_ORDER_FIELD_LABELS.get(field, field) for field in changed_fields
) + "。", "updated"
else:
title, detail, kind = "工单信息确认", "工单信息已确认,未记录新的现场进展。", "updated"
document["activity"].append({"sequence": revision, "occurred_at": now,
"actor": who.user_id, "kind": kind,
"title": title, "detail": detail})
document.update(revision=revision, updated_at=now,
updated_by=who.user_id) updated_by=who.user_id)
return self._save_work_order(conn, who, document) return self._save_work_order(conn, who, document)
return self._mutate(who, f"update_work_order:{work_order_id}", command, write) return self._mutate(who, f"update_work_order:{work_order_id}", command, write)
+11 -86
View File
@@ -5,7 +5,7 @@ import argparse
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
from uuid import NAMESPACE_URL, UUID, uuid5 from uuid import UUID, uuid5
from psycopg.types.json import Jsonb 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.auth import Identity
from samples.peripheral.config import SampleSettings from samples.peripheral.config import SampleSettings
from samples.peripheral.demo_data import build_demo_content 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 from samples.peripheral.store import PostgresStore
SEED_NAMESPACE = uuid5(NAMESPACE_URL, "tjwater/peripheral-sample/seed/v1") ACTOR_ID = SEED_ACTOR_ID
ACTOR_ID = uuid5(SEED_NAMESPACE, "actor")
def request_id(kind: str) -> UUID: def request_id(kind: str) -> UUID:
return uuid5(SEED_NAMESPACE, kind) 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 = ( TEST_DATA = (
("DEMO-P001", "合成压力 1 · 00:00", "2026-09-17T00:00:00Z", 24.37, "m", "valid"), ("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"), ("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"] return row["result"]
def restore_seeded_resources( def restore_seeded_test_data(
store: PostgresStore, store: PostgresStore,
project_id: str, project_id: str,
*, record_ids: list[str],
table: str,
id_column: str,
resource_ids: list[str],
) -> None: ) -> None:
"""Restore deterministic seed rows that were soft-deleted after an earlier run.""" """Restore deterministic seed rows that were soft-deleted after an earlier run."""
allowed_resources = { if not record_ids:
("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:
return return
with store.connection() as connection: with store.connection() as connection:
connection.execute( connection.execute(
f"""UPDATE peripheral_sample.{table} """UPDATE peripheral_sample.test_data_records
SET document=document - 'deleted_at' - 'deleted_by', SET document=document - 'deleted_at' - 'deleted_by',
deleted_at=NULL, deleted_by=NULL deleted_at=NULL, deleted_by=NULL
WHERE project_id=%s AND {id_column}=ANY(%s::uuid[])""", WHERE project_id=%s AND record_id=ANY(%s::uuid[])""",
(project_id, resource_ids), (project_id, record_ids),
) )
@@ -160,34 +120,6 @@ def main() -> int:
who = Identity(project_id, str(ACTOR_ID), True) who = Identity(project_id, str(ACTOR_ID), True)
dataset_digest = ensure_demo_dataset(store, project_id) 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 = [] record_ids = []
for point_id, label, observed_at, value, unit, quality in TEST_DATA: for point_id, label, observed_at, value, unit, quality in TEST_DATA:
seed_request_id = request_id(f"test-data/{point_id}/{observed_at}") 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) result = previous or store.create_test_data(who, command)
record_ids.append(result["record_id"]) record_ids.append(result["record_id"])
restore_seeded_resources( restore_seeded_test_data(store, project_id, record_ids)
store,
project_id,
table="test_data_records",
id_column="record_id",
resource_ids=record_ids,
)
result = { result = {
"project_id": project_id, "project_id": project_id,
"actor_id": str(ACTOR_ID), "actor_id": str(ACTOR_ID),
"origin": "synthetic", "origin": "synthetic",
"demo_dataset_digest": dataset_digest, "demo_dataset_digest": dataset_digest,
"work_orders": {"count": len(order_ids), "ids": order_ids},
"test_data_records": {"count": len(record_ids), "ids": record_ids}, "test_data_records": {"count": len(record_ids), "ids": record_ids},
} }
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
+17 -115
View File
@@ -108,119 +108,16 @@ WORK_ORDER_TEMPLATES = (
) )
def _clock(value: datetime) -> str: def _build_activity(template: dict, scheduled_at: datetime, updated_at: datetime) -> list[dict]:
return value.astimezone(SHANGHAI).strftime("%H:%M") created_at = scheduled_at - timedelta(minutes=template["dispatch_lead_minutes"])
activity = [{"sequence": 1, "occurred_at": created_at,
"actor": template["dispatcher"], "kind": "created",
def _clock_range(start: datetime, duration_minutes: int) -> str: "title": "演示工单创建", "detail": template["summary"]}]
return f"{_clock(start)}-{_clock(start + timedelta(minutes=duration_minutes))}" if template["phase"] != "dispatch":
activity.append({"sequence": 2, "occurred_at": updated_at,
"actor": template["assignee"], "kind": "updated",
def _stage_statuses(phase: str, position: str) -> tuple[str, str, str]: "title": "演示状态更新", "detail": template["detail"]})
if position == "before": return activity
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_demo_work_orders() -> list[dict]: def build_demo_work_orders() -> list[dict]:
@@ -256,7 +153,10 @@ def build_demo_work_orders() -> list[dict]:
"source": template["source"], "source": template["source"],
"dispatcher": template["dispatcher"], "dispatcher": template["dispatcher"],
"priority": template["priority"], "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( documents.append(
WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json") WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json")
@@ -352,7 +252,9 @@ def main() -> int:
"code": document["code"], "code": document["code"],
"title": document["title"], "title": document["title"],
"status": document["status"], "status": document["status"],
"subtask_count": len(document["subtasks"]), "feedback_requirement_count": len(
document["feedback_requirements"]
),
} }
for document in documents for document in documents
], ],
+29 -5
View File
@@ -10,6 +10,8 @@ from uuid import NAMESPACE_URL, uuid5
import httpx import httpx
from samples.peripheral.seed_identity import SEED_ACTOR_ID
try: try:
from scripts.run_operational_worker import CredentialProvider, WorkerSettings from scripts.run_operational_worker import CredentialProvider, WorkerSettings
except ModuleNotFoundError: except ModuleNotFoundError:
@@ -19,18 +21,34 @@ except ModuleNotFoundError:
logger = logging.getLogger("peripheral-task-sync") logger = logging.getLogger("peripheral-task-sync")
STATUS = {"draft": "scheduled", "open": "assigned", "in_progress": "in_progress", STATUS = {"draft": "scheduled", "open": "assigned", "in_progress": "in_progress",
"awaiting_reply": "awaiting_feedback", "completed": "completed", "cancelled": "cancelled"} "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: 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 = ( required = (
"work_order_id", "revision", "title", "summary", "status", "priority", "work_order_id", "revision", "title", "summary", "status", "priority",
"origin", "updated_at", "origin", "updated_at",
) )
snapshot = {key: order[key] for key in required} snapshot = {key: order[key] for key in required}
snapshot.update({key: order.get(key) for key in ( 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") snapshot["work_order_type"] = order.get("work_order_type", "general")
return snapshot return snapshot
@@ -39,7 +57,10 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
credentials: CredentialProvider) -> int: credentials: CredentialProvider) -> int:
headers = await credentials.headers(client) headers = await credentials.headers(client)
tasks_url = f"{settings.server_url.rstrip('/')}/api/v1/operational-tasks" 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: while True:
response = await client.get(tasks_url, headers=headers, params={"limit": 100, "offset": offset}) response = await client.get(tasks_url, headers=headers, params={"limit": 100, "offset": offset})
response.raise_for_status() response.raise_for_status()
@@ -63,6 +84,7 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
receipt = action.get("receipt") receipt = action.get("receipt")
if action["kind"] == "create_work_order" and receipt and receipt["adapter"] == "peripheral_sample": if action["kind"] == "create_work_order" and receipt and receipt["adapter"] == "peripheral_sample":
linked.setdefault(receipt["receipt_id"], []).append(task["task_id"]) linked.setdefault(receipt["receipt_id"], []).append(task["task_id"])
linked_order_ids = set(linked)
cursor, synchronized = None, 0 cursor, synchronized = None, 0
while True: while True:
@@ -76,14 +98,16 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
for order in page["items"]: for order in page["items"]:
if order["project_id"] != settings.project_id or order["origin"] != "synthetic": if order["project_id"] != settings.project_id or order["origin"] != "synthetic":
raise ValueError("peripheral work order scope or origin mismatch") 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']}" external_id = f"peripheral_sample:{order['work_order_id']}"
# Complaint intake is an event even if no browser was open when the # 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. # external system created it. Use the same source key as the UI.
event_kind = order.get("work_order_type") event_kind = order.get("work_order_type")
if ( 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 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_request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:investigation"))
event = await client.post(tasks_url, headers=headers, json={ event = await client.post(tasks_url, headers=headers, json={
@@ -0,0 +1,326 @@
"""Opt-in boundary checks for the synthetic-check cleanup migration."""
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
import psycopg
import pytest
from psycopg.rows import dict_row
from app.core.config import get_pgconn_string
from samples.peripheral.seed_identity import SEED_ACTOR_ID
pytestmark = pytest.mark.skipif(
os.environ.get("OPERATIONAL_TASKS_STORAGE_TEST") != "1",
reason="explicit PostgreSQL test opt-in required",
)
@pytest.fixture
def anyio_backend():
return "asyncio"
def _migration_sql(*names: str) -> str:
root = Path(__file__).parents[2]
sql = "\n".join((root / f"resources/sql/{name}").read_text() for name in names)
return sql.replace("\nBEGIN;", "").replace("\nCOMMIT;", "")
async def _insert_task(
conn: psycopg.AsyncConnection,
*,
project_id: UUID,
external_id: str,
event_kind: str,
title: str,
task_type: str = "external_work_order",
) -> UUID:
task_id = uuid4()
request_id = uuid4()
execution_request_id = uuid4()
now = datetime.now(timezone.utc)
document = {
"project_id": str(project_id),
"task_id": str(task_id),
"task_type": task_type,
"workflow": "external-work-order" if task_type == "external_work_order" else event_kind,
"status": "completed",
"lifecycle_status": "completed",
"priority": "normal",
"revision": 1,
"source": {
"kind": "external_system",
"external_id": external_id,
"event_kind": event_kind,
},
"title": title,
}
await conn.execute(
"""INSERT INTO workbench.operational_tasks (
project_id,task_id,create_request_id,task_type,workflow,status,priority,
revision,execution_generation,execution_request_id,created_at,updated_at,
document,lifecycle_status
) VALUES (%s,%s,%s,%s,%s,'completed','normal',1,1,%s,%s,%s,%s::jsonb,'completed')""",
(
project_id,
task_id,
request_id,
task_type,
document["workflow"],
execution_request_id,
now,
now,
json.dumps(document),
),
)
return task_id
async def _insert_source_order(
conn: psycopg.AsyncConnection,
*,
project_id: UUID,
work_order_id: UUID,
title: str,
created_by: UUID = SEED_ACTOR_ID,
deleted: bool = True,
) -> None:
now = datetime.now(timezone.utc)
document = {
"project_id": str(project_id),
"work_order_id": str(work_order_id),
"revision": 1,
"title": title,
"created_by": str(created_by),
"origin": "synthetic",
}
await conn.execute(
"""INSERT INTO peripheral_sample.work_orders (
project_id,work_order_id,revision,document,deleted_at
) VALUES (%s,%s,1,%s::jsonb,%s)""",
(project_id, work_order_id, json.dumps(document), now if deleted else None),
)
@pytest.mark.anyio
async def test_cleanup_deletes_only_unlinked_exact_namespace_and_is_idempotent():
base_migration = _migration_sql(
"014_operational_tasks.sql",
"015_workbench_events_and_task_runs.sql",
)
cleanup_migration = _migration_sql("018_remove_synthetic_check_task_projections.sql")
project_id = uuid4()
conn = await psycopg.AsyncConnection.connect(
get_pgconn_string(), row_factory=dict_row, connect_timeout=3
)
try:
async with conn.transaction(force_rollback=True):
await conn.execute(base_migration)
await conn.execute("CREATE SCHEMA IF NOT EXISTS peripheral_sample")
await conn.execute(
"""CREATE TABLE IF NOT EXISTS peripheral_sample.work_orders (
project_id uuid NOT NULL,
work_order_id uuid NOT NULL,
revision bigint NOT NULL,
document jsonb NOT NULL,
deleted_at timestamptz,
PRIMARY KEY (project_id,work_order_id)
)"""
)
removed_order_id = uuid4()
await _insert_source_order(
conn,
project_id=project_id,
work_order_id=removed_order_id,
title="合成通信核查",
deleted=False,
)
removed_id = await _insert_task(
conn,
project_id=project_id,
external_id=f"peripheral_sample:{removed_order_id}",
event_kind="general",
title=" 合成通信核查",
)
deleted_artifact_order_id = uuid4()
await _insert_source_order(
conn,
project_id=project_id,
work_order_id=deleted_artifact_order_id,
title="合成联调:预约工单编辑已对齐",
created_by=uuid4(),
)
deleted_user_order_id = await _insert_task(
conn,
project_id=project_id,
external_id=f"peripheral_sample:{deleted_artifact_order_id}",
event_kind="general",
title="合成联调:预约工单编辑已对齐",
)
suffix_alias_id = await _insert_task(
conn,
project_id=project_id,
external_id=f"peripheral_sample:{removed_order_id}:suffix",
event_kind="general",
title="合成但不是规范来源标识",
)
similar_namespace_id = await _insert_task(
conn,
project_id=project_id,
external_id=f"peripheralXsample:{uuid4()}",
event_kind="general",
title="合成但属于其他命名空间",
)
event_order_id = uuid4()
await _insert_source_order(
conn,
project_id=project_id,
work_order_id=event_order_id,
title="合成水质投诉",
)
event_id = await _insert_task(
conn,
project_id=project_id,
external_id=f"peripheral_sample:{event_order_id}",
event_kind="water_quality_complaint",
title="合成水质投诉",
)
linked_order_id = uuid4()
await _insert_source_order(
conn,
project_id=project_id,
work_order_id=linked_order_id,
title="合成现场采样",
)
linked_id = await _insert_task(
conn,
project_id=project_id,
external_id=f"peripheral_sample:{linked_order_id}",
event_kind="general",
title="合成现场采样",
)
normal_order_id = uuid4()
await _insert_source_order(
conn,
project_id=project_id,
work_order_id=normal_order_id,
title="常规预约",
created_by=uuid4(),
)
normal_id = await _insert_task(
conn,
project_id=project_id,
external_id=f"peripheral_sample:{normal_order_id}",
event_kind="general",
title="常规预约",
)
action_id = uuid4()
now = datetime.now(timezone.utc)
action_document = {
"action_id": str(action_id),
"version": 1,
"kind": "create_work_order",
"approval_status": "approved",
"execution_status": "succeeded",
"receipt": {
"adapter": "peripheral_sample",
"receipt_id": str(linked_order_id),
},
}
await conn.execute(
"""INSERT INTO workbench.operational_task_actions (
project_id,task_id,action_id,version,approval_status,execution_status,
document,created_at,updated_at
) VALUES (%s,%s,%s,1,'approved','succeeded',%s::jsonb,%s,%s)""",
(project_id, linked_id, action_id, json.dumps(action_document), now, now),
)
await conn.execute(
"""INSERT INTO workbench.operational_task_requests (
project_id,scope,request_id,request_digest,response
) VALUES (%s,%s,%s,'test',%s::jsonb)""",
(
project_id,
f"task:{removed_id}:create",
uuid4(),
json.dumps({"task_id": str(removed_id)}),
),
)
await conn.execute(
"""INSERT INTO workbench.operational_task_requests (
project_id,scope,request_id,request_digest,response
) VALUES (%s,%s,%s,'test',%s::jsonb)""",
(
project_id,
f"notification:{uuid4()}:ack",
uuid4(),
json.dumps({"notification": {"task_id": str(removed_id)}}),
),
)
await conn.execute(cleanup_migration)
rows = await (
await conn.execute(
"""SELECT task_id FROM workbench.operational_tasks
WHERE project_id=%s AND task_id=ANY(%s)""",
(
project_id,
[
removed_id,
deleted_user_order_id,
suffix_alias_id,
similar_namespace_id,
event_id,
linked_id,
normal_id,
],
),
)
).fetchall()
assert {row["task_id"] for row in rows} == {
similar_namespace_id,
deleted_user_order_id,
suffix_alias_id,
event_id,
linked_id,
normal_id,
}
request_count = await (
await conn.execute(
"""SELECT count(*) AS count FROM workbench.operational_task_requests
WHERE project_id=%s AND (
response->>'task_id'=%s
OR response#>>'{notification,task_id}'=%s
)""",
(project_id, str(removed_id), str(removed_id)),
)
).fetchone()
assert request_count["count"] == 0
cleanup_event_count = await (
await conn.execute(
"""SELECT count(*) AS count FROM workbench.domain_event_log
WHERE project_id=%s
AND metadata->>'reason'='synthetic_check_projection_cleanup'""",
(project_id,),
)
).fetchone()
assert cleanup_event_count["count"] == 1
await conn.execute("DROP TABLE synthetic_check_task_cleanup")
await conn.execute(cleanup_migration)
repeated_event_count = await (
await conn.execute(
"""SELECT count(*) AS count FROM workbench.domain_event_log
WHERE project_id=%s
AND metadata->>'reason'='synthetic_check_projection_cleanup'""",
(project_id,),
)
).fetchone()
assert repeated_event_count["count"] == 1
finally:
await conn.close()
+5 -22
View File
@@ -647,27 +647,9 @@ def _work_order_snapshot(work_order_id, now):
"work_order_type": "water_quality_complaint", "work_order_type": "water_quality_complaint",
"location": "联调小区", "location": "联调小区",
"scenario_code": "WQ-SECONDARY-SUPPLY-001", "scenario_code": "WQ-SECONDARY-SUPPLY-001",
"subtasks": [{ "feedback_requirements": ["上传检测结果"],
"id": "sampling", "activity": [{"sequence": 1, "occurred_at": now.isoformat(), "actor": "水质班组",
"title": "现场采样", "kind": "feedback", "title": "现场反馈", "detail": "上传复测结果"}],
"summary": "采集并复测",
"status": "running",
"scheduled_at": (now + timedelta(hours=1)).isoformat(),
"location": "联调小区",
"assignee": "水质班组",
"duration_minutes": 60,
"reply_window_minutes": 120,
"stages": [{
"id": "reply",
"sequence": 1,
"title": "等待回令",
"status": "current",
"owner": "水质班组",
"time_label": "待反馈",
"description": "上传复测结果",
}],
"reply_requirements": ["上传检测结果"],
}],
"origin": "synthetic", "origin": "synthetic",
"updated_at": now.isoformat(), "updated_at": now.isoformat(),
} }
@@ -707,7 +689,8 @@ async def test_external_feedback_exposes_validated_work_order_snapshot_and_refre
assert result.task.scheduled_for == now + timedelta(hours=1) assert result.task.scheduled_for == now + timedelta(hours=1)
assert result.task.priority == "urgent" assert result.task.priority == "urgent"
assert result.task.work_order_snapshot is not None assert result.task.work_order_snapshot is not None
assert result.task.work_order_snapshot.subtasks[0].stages[0].status == "current" assert result.task.work_order_snapshot.activity[0].title == "现场反馈"
assert result.task.work_order_snapshot.feedback_requirements == ["上传检测结果"]
@pytest.mark.anyio @pytest.mark.anyio
@@ -25,7 +25,7 @@ class ManagedStore:
return command.model_dump(mode="json", exclude={"request_id"}) | { return command.model_dump(mode="json", exclude={"request_id"}) | {
"work_order_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1, "work_order_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1,
"origin": "synthetic", "created_at": now, "updated_at": now, "origin": "synthetic", "created_at": now, "updated_at": now,
"created_by": str(USER_ID), "updated_by": str(USER_ID), "created_by": str(USER_ID), "updated_by": str(USER_ID), "activity": [],
} }
def update_work_order(self, who, work_order_id, command): def update_work_order(self, who, work_order_id, command):
@@ -35,7 +35,7 @@ class ManagedStore:
"status": command.status or "open", "priority": command.priority or "normal", "status": command.status or "open", "priority": command.priority or "normal",
"assignee": None, "scheduled_at": None, "work_order_type": "general", "assignee": None, "scheduled_at": None, "work_order_type": "general",
"location": command.location, "scenario_code": command.scenario_code, "location": command.location, "scenario_code": command.scenario_code,
"subtasks": command.subtasks}})()) | {"revision": command.expected_revision + 1, "feedback_requirements": command.feedback_requirements or []}})()) | {"revision": command.expected_revision + 1,
"work_order_id": str(work_order_id)} "work_order_id": str(work_order_id)}
def delete_work_order(self, who, work_order_id, command): def delete_work_order(self, who, work_order_id, command):
@@ -86,32 +86,25 @@ def test_managed_work_order_and_data_routes_are_typed_crud():
"status": "open", "priority": "high", "assignee": None, "scheduled_at": None, "status": "open", "priority": "high", "assignee": None, "scheduled_at": None,
"work_order_type": "water_quality_complaint", "location": "演示小区", "work_order_type": "water_quality_complaint", "location": "演示小区",
"scenario_code": "WQ-SECONDARY-SUPPLY-001", "scenario_code": "WQ-SECONDARY-SUPPLY-001",
"subtasks": [{ "feedback_requirements": ["上传检测结果"],
"id": "sampling", "title": "现场采样", "summary": "采集二次供水样本",
"status": "running", "scheduled_at": "2026-09-18T01:00:00Z",
"location": "演示小区", "assignee": "水质班组", "duration_minutes": 60,
"reply_window_minutes": 120, "reply_requirements": ["上传检测结果"],
"stages": [{"id": "dispatch", "sequence": 1, "title": "派发",
"status": "current", "owner": "调度员", "time_label": "待执行",
"description": "确认采样人员"}],
}],
}) })
assert created.status_code == 201 and created.json()["origin"] == "synthetic" assert created.status_code == 201 and created.json()["origin"] == "synthetic"
assert created.json()["work_order_type"] == "water_quality_complaint" assert created.json()["work_order_type"] == "water_quality_complaint"
assert created.json()["location"] == "演示小区" assert created.json()["location"] == "演示小区"
assert created.json()["scenario_code"] == "WQ-SECONDARY-SUPPLY-001" assert created.json()["scenario_code"] == "WQ-SECONDARY-SUPPLY-001"
assert created.json()["subtasks"][0]["stages"][0]["status"] == "current" assert created.json()["feedback_requirements"] == ["上传检测结果"]
assert "subtasks" not in created.json()
work_order_id = created.json()["work_order_id"] work_order_id = created.json()["work_order_id"]
changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={ changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={
"request_id": str(uuid4()), "expected_revision": 1, "status": "awaiting_reply", "request_id": str(uuid4()), "expected_revision": 1, "status": "awaiting_reply",
"location": "复测点", "clear_scenario_code": True, "clear_subtasks": True, "location": "复测点", "clear_scenario_code": True, "progress_note": "等待现场检测结果",
"clear_assignee": False, "clear_scheduled_at": False, "clear_assignee": False, "clear_scheduled_at": False,
}) })
assert changed.status_code == 200 and changed.json()["revision"] == 2 assert changed.status_code == 200 and changed.json()["revision"] == 2
assert changed.json()["status"] == "awaiting_reply" assert changed.json()["status"] == "awaiting_reply"
update_command = next(call[3] for call in fake.calls if call[0] == "update_work_order") update_command = next(call[3] for call in fake.calls if call[0] == "update_work_order")
assert update_command.location == "复测点" assert update_command.location == "复测点"
assert update_command.clear_scenario_code and update_command.clear_subtasks assert update_command.clear_scenario_code and update_command.progress_note == "等待现场检测结果"
deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={ deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={
"request_id": str(uuid4()), "expected_revision": 2, "request_id": str(uuid4()), "expected_revision": 2,
}) })
+102 -11
View File
@@ -5,7 +5,12 @@ import httpx
import pytest import pytest
from scripts.run_operational_worker import CredentialProvider, WorkerSettings from scripts.run_operational_worker import CredentialProvider, WorkerSettings
from scripts.sync_peripheral_work_orders import sync_once, work_order_snapshot from scripts.sync_peripheral_work_orders import (
should_project_work_order,
sync_once,
work_order_snapshot,
)
from samples.peripheral.seed_identity import SEED_ACTOR_ID
from app.domain.schemas.operational_task import ExternalFeedbackCommand from app.domain.schemas.operational_task import ExternalFeedbackCommand
@@ -14,6 +19,29 @@ def anyio_backend():
return "asyncio" return "asyncio"
def test_unlinked_synthetic_checks_are_not_operational_tasks():
order_id = str(uuid4())
order = {
"work_order_id": order_id,
"origin": "synthetic",
"created_by": str(SEED_ACTOR_ID),
"title": "合成压力偏差复核",
"work_order_type": "general",
}
assert not should_project_work_order(order, set())
assert should_project_work_order(order, {order_id})
assert should_project_work_order(
{**order, "work_order_type": "water_quality_complaint"}, set()
)
assert should_project_work_order(
{**order, "title": "现场采样:二次供水出水及末端用户水样"}, set()
) is False
assert should_project_work_order(
{**order, "created_by": str(uuid4()), "title": "合成管材接驳预约"}, set()
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_sync_creates_one_task_and_only_applies_new_external_revisions(): async def test_sync_creates_one_task_and_only_applies_new_external_revisions():
project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4())) project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4()))
@@ -55,12 +83,52 @@ async def test_sync_creates_one_task_and_only_applies_new_external_revisions():
assert snapshot["assignee"] == "测试班组" assert snapshot["assignee"] == "测试班组"
assert snapshot["location"] == "测试位置" assert snapshot["location"] == "测试位置"
assert snapshot["scheduled_at"] == "2026-09-24T08:00:00Z" assert snapshot["scheduled_at"] == "2026-09-24T08:00:00Z"
assert snapshot["subtasks"] is None assert snapshot["feedback_requirements"] == []
assert snapshot["activity"] == []
# Exercise the real API schema, including the prefixed external identity. # Exercise the real API schema, including the prefixed external identity.
accepted = ExternalFeedbackCommand.model_validate(writes[-1][1]) accepted = ExternalFeedbackCommand.model_validate(writes[-1][1])
assert str(accepted.work_order_snapshot.work_order_id) == order_id assert str(accepted.work_order_snapshot.work_order_id) == order_id
@pytest.mark.anyio
async def test_sync_skips_unlinked_synthetic_check_before_writing():
project_id, order_id = str(uuid4()), str(uuid4())
order = {
"project_id": project_id,
"work_order_id": order_id,
"origin": "synthetic",
"created_by": str(SEED_ACTOR_ID),
"revision": 1,
"title": "合成通信核查",
"summary": "仅用于外围样板联调",
"priority": "normal",
"status": "open",
"created_at": "2026-09-23T08:00:00Z",
"updated_at": "2026-09-23T08:00:00Z",
"scheduled_at": None,
"work_order_type": "general",
}
def handler(request):
assert request.method == "GET"
if request.url.host == "sample.test":
return httpx.Response(200, json={"items": [order], "next_cursor": None})
return httpx.Response(200, json={"items": [], "next_offset": None})
settings = WorkerSettings(
"http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY"
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
synchronized = await sync_once(
client,
settings,
"http://sample.test",
CredentialProvider(settings),
)
assert synchronized == 0
@pytest.mark.anyio @pytest.mark.anyio
async def test_sample_sync_rejects_cross_project_data_before_writing(): async def test_sample_sync_rejects_cross_project_data_before_writing():
def handler(request): def handler(request):
@@ -74,17 +142,29 @@ async def test_sample_sync_rejects_cross_project_data_before_writing():
@pytest.mark.anyio @pytest.mark.anyio
@pytest.mark.parametrize("investigation_status", ["waiting_human", "queued"]) @pytest.mark.parametrize(
async def test_created_work_order_receipt_routes_feedback_back_to_investigation(investigation_status): ("investigation_status", "expected_feedback_count", "routes_to_investigation"),
[
("waiting_human", 2, True),
("queued", 2, True),
("completed", 1, False),
],
)
async def test_created_work_order_receipt_preserves_projection_and_routes_active_feedback(
investigation_status,
expected_feedback_count,
routes_to_investigation,
):
project_id, order_id, investigation_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4())) project_id, order_id, investigation_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4()))
received = [] received = []
def handler(request): def handler(request):
if request.url.host == "sample.test": if request.url.host == "sample.test":
return httpx.Response(200, json={"items": [{ return httpx.Response(200, json={"items": [{
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
"created_by": str(uuid4()),
"revision": 2, "summary": "采样已完成", "status": "completed", "updated_at": "2026-09-23T09:00:00Z", "revision": 2, "summary": "采样已完成", "status": "completed", "updated_at": "2026-09-23T09:00:00Z",
"title": "合成采样工单", "priority": "normal", "title": "合成采样工单", "priority": "normal",
"work_order_type": "water_quality_complaint", "created_at": "2026-09-23T08:00:00Z", "work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
"scheduled_at": None, "scheduled_at": None,
}], "next_cursor": None}) }], "next_cursor": None})
if request.method == "POST": if request.method == "POST":
@@ -106,8 +186,10 @@ async def test_created_work_order_receipt_routes_feedback_back_to_investigation(
], "next_offset": None}) ], "next_offset": None})
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) == 2 assert await sync_once(
assert any(investigation_id in path for path in received) client, settings, "http://sample.test", CredentialProvider(settings)
) == expected_feedback_count
assert any(investigation_id in path for path in received) is routes_to_investigation
assert any(projection_id in path for path in received) assert any(projection_id in path for path in received)
@@ -142,11 +224,20 @@ async def test_external_events_start_investigations_without_browser(event_kind):
assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z" assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z"
def test_snapshot_preserves_nested_process_without_inventing_completed_stages(): def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
order = { order = {
"work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步", "work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步",
"status": "in_progress", "priority": "normal", "origin": "synthetic", "status": "in_progress", "priority": "normal", "origin": "synthetic",
"updated_at": "2026-09-23T09:00:00Z", "work_order_type": "general", "updated_at": "2026-09-23T09:00:00Z", "work_order_type": "general",
"feedback_requirements": ["回填现场检查结果"],
"activity": [{
"sequence": 1,
"occurred_at": "2026-09-23T08:00:00Z",
"actor": "测试调度",
"kind": "created",
"title": "工单创建",
"detail": "已创建现场检查工单。",
}],
"subtasks": [{ "subtasks": [{
"id": "visit", "title": "现场检查", "summary": "合成测试,未现场执行", "id": "visit", "title": "现场检查", "summary": "合成测试,未现场执行",
"status": "pending", "scheduled_at": "2026-09-24T08:00:00Z", "status": "pending", "scheduled_at": "2026-09-24T08:00:00Z",
@@ -157,6 +248,6 @@ def test_snapshot_preserves_nested_process_without_inventing_completed_stages():
}], }],
} }
snapshot = work_order_snapshot(order) snapshot = work_order_snapshot(order)
assert snapshot["subtasks"] == order["subtasks"] assert snapshot["feedback_requirements"] == ["回填现场检查结果"]
assert snapshot["subtasks"][0]["stages"][0]["status"] == "pending" assert snapshot["activity"] == order["activity"]
assert work_order_snapshot({key: value for key, value in order.items() if key != "subtasks"})["subtasks"] is None assert "subtasks" not in snapshot
@@ -0,0 +1,49 @@
from uuid import uuid4
from samples.peripheral.auth import Identity
from samples.peripheral.config import SampleSettings
from samples.peripheral.schemas import CreateWorkOrder, UpdateWorkOrder, WorkOrder
from samples.peripheral.store import PostgresStore
def test_create_and_update_record_flat_order_activity(monkeypatch):
store = PostgresStore(SampleSettings())
actor = Identity(str(uuid4()), str(uuid4()), True)
saved = {}
monkeypatch.setattr(store, "_mutate", lambda who, key, command, operation: operation(None, "normal"))
def save(_conn, _who, document):
saved.clear()
saved.update(WorkOrder.model_validate(document).model_dump(mode="json"))
return dict(saved)
monkeypatch.setattr(store, "_save_work_order", save)
monkeypatch.setattr(store, "_managed_document", lambda *_args: dict(saved))
created = store.create_work_order(actor, CreateWorkOrder(
request_id=uuid4(), title="阀门预约复核", summary="现场复核开度",
location="北区阀门", assignee="北区班组",
feedback_requirements=["回传开度照片"],
))
assert created["feedback_requirements"] == ["回传开度照片"]
assert created["activity"][0]["kind"] == "created"
assert "subtasks" not in created
updated = store.update_work_order(actor, created["work_order_id"], UpdateWorkOrder(
request_id=uuid4(), expected_revision=1, status="awaiting_reply",
progress_note="已复核开度,等待回执确认",
))
assert updated["status"] == "awaiting_reply"
assert [entry["sequence"] for entry in updated["activity"]] == [1, 2]
assert updated["activity"][1]["kind"] == "feedback"
assert updated["activity"][1]["detail"] == "已复核开度,等待回执确认"
assert "progress_note" not in updated
def test_legacy_order_reads_flat_without_claiming_unrecorded_progress():
legacy = {"work_order_id": str(uuid4()), "subtasks": [{"stages": [{"status": "done"}]}]}
projected = PostgresStore._active_managed_document(legacy)
assert "subtasks" not in projected
assert projected["feedback_requirements"] == []
assert projected["activity"] == []
+8 -25
View File
@@ -1,12 +1,10 @@
from contextlib import contextmanager from contextlib import contextmanager
from uuid import uuid4 from uuid import uuid4
import pytest
from scripts.seed_peripheral_sample import ( from scripts.seed_peripheral_sample import (
ACTOR_ID, ACTOR_ID,
previous_seed_result, previous_seed_result,
restore_seeded_resources, restore_seeded_test_data,
) )
@@ -42,8 +40,8 @@ class FakeStore:
def test_previous_seed_result_reuses_legacy_receipt(): def test_previous_seed_result_reuses_legacy_receipt():
resource_id = str(uuid4()) resource_id = str(uuid4())
store = FakeStore({ store = FakeStore({
"command_key": "create_work_order", "command_key": "create_test_data",
"result": {"work_order_id": resource_id}, "result": {"record_id": resource_id},
}) })
seed_request_id = uuid4() seed_request_id = uuid4()
@@ -51,10 +49,10 @@ def test_previous_seed_result_reuses_legacy_receipt():
store, store,
"project-1", "project-1",
seed_request_id=seed_request_id, seed_request_id=seed_request_id,
command_key="create_work_order", command_key="create_test_data",
) )
assert result == {"work_order_id": resource_id} assert result == {"record_id": resource_id}
assert store.readonly is True assert store.readonly is True
assert store.connection_value.calls[0][1] == ( assert store.connection_value.calls[0][1] == (
"project-1", "project-1",
@@ -63,29 +61,14 @@ def test_previous_seed_result_reuses_legacy_receipt():
) )
def test_restore_seeded_resources_only_restores_allowed_tables(): def test_restore_seeded_test_data_clears_soft_delete_fields():
store = FakeStore() store = FakeStore()
resource_ids = [str(uuid4()), str(uuid4())] resource_ids = [str(uuid4()), str(uuid4())]
restore_seeded_resources( restore_seeded_test_data(store, "project-1", resource_ids)
store,
"project-1",
table="work_orders",
id_column="work_order_id",
resource_ids=resource_ids,
)
statement, parameters = store.connection_value.calls[0] statement, parameters = store.connection_value.calls[0]
assert "UPDATE peripheral_sample.work_orders" in statement assert "UPDATE peripheral_sample.test_data_records" in statement
assert "document=document - 'deleted_at' - 'deleted_by'" in statement assert "document=document - 'deleted_at' - 'deleted_by'" in statement
assert "deleted_at=NULL, deleted_by=NULL" in statement assert "deleted_at=NULL, deleted_by=NULL" in statement
assert parameters == ("project-1", resource_ids) assert parameters == ("project-1", resource_ids)
with pytest.raises(ValueError, match="unsupported seeded resource"):
restore_seeded_resources(
store,
"project-1",
table="requests",
id_column="request_id",
resource_ids=resource_ids,
)
+6 -13
View File
@@ -28,25 +28,18 @@ def test_build_demo_work_orders_restores_original_mock_tasks():
"running", "running",
"awaiting_reply", "awaiting_reply",
] ]
assert all(len(document["subtasks"]) == 3 for document in documents) assert all(len(document["feedback_requirements"]) == 3 for document in documents)
assert all("subtasks" not in document for document in documents)
assert all("合成" not in str(document) for document in documents) assert all("合成" not in str(document) for document in documents)
def test_demo_work_orders_keep_one_current_stage_per_active_task(): def test_demo_work_orders_show_recorded_activity_without_synthetic_stages():
documents = build_demo_work_orders() documents = build_demo_work_orders()
for document in documents: for document in documents:
current_stages = [ assert document["activity"]
stage assert all(entry["kind"] in {"created", "updated"} for entry in document["activity"])
for subtask in document["subtasks"] assert "stages" not in document
for stage in subtask["stages"]
if stage["status"] == "current"
]
assert len(current_stages) == 1
assert documents[0]["subtasks"][0]["stages"][0]["status"] == "current"
assert documents[1]["subtasks"][1]["stages"][1]["status"] == "current"
assert documents[2]["subtasks"][2]["stages"][2]["status"] == "current"
def test_replace_removes_legacy_projection_before_inserting_demo_tasks(): def test_replace_removes_legacy_projection_before_inserting_demo_tasks():
+5 -39
View File
@@ -201,47 +201,11 @@ async def test_snapshot_projects_persisted_work_orders_and_alerts():
"source": "生产工单系统", "source": "生产工单系统",
"dispatcher": "调度中心", "dispatcher": "调度中心",
"priority": "urgent", "priority": "urgent",
"subtasks": [{
"id": "inspect-valve",
"title": "阀门开度复核",
"summary": "现场复核阀门开度。",
"status": "running",
"scheduled_at": NOW.replace(hour=3),
"location": "北区阀门 V-1", "location": "北区阀门 V-1",
"assignee": "北区班组", "assignee": "北区班组",
"duration_minutes": 45, "feedback_requirements": ["回传阀门开度", "复核压力"],
"reply_window_minutes": 20, "activity": [{"sequence": 1, "occurred_at": NOW, "actor": "调度中心",
"stages": [ "kind": "created", "title": "工单创建", "detail": "等待现场复核。"}],
{
"id": "dispatch",
"sequence": 1,
"title": "派发",
"status": "done",
"owner": "调度中心",
"time_label": "10:20",
"description": "已派发。",
},
{
"id": "execution",
"sequence": 2,
"title": "执行中",
"status": "current",
"owner": "北区班组",
"time_label": "10:30—11:15",
"description": "现场执行中。",
},
{
"id": "reply",
"sequence": 3,
"title": "待复令",
"status": "pending",
"owner": "北区班组 → 调度中心",
"time_label": "11:15—11:35",
"description": "等待执行结果。",
},
],
"reply_requirements": ["回传阀门开度", "复核压力"],
}],
} }
persisted_alert = { persisted_alert = {
"alert_id": "alarm-1", "alert_id": "alarm-1",
@@ -282,3 +246,5 @@ async def test_snapshot_projects_persisted_work_orders_and_alerts():
"severity": "warning", "severity": "warning",
}] }]
assert snapshot["work_orders"][0]["code"] == "WO-20260917-001" assert snapshot["work_orders"][0]["code"] == "WO-20260917-001"
assert snapshot["work_orders"][0]["activity"][0]["title"] == "工单创建"
assert "subtasks" not in snapshot["work_orders"][0]