fix(workbench): remove synthetic check projections
This commit is contained in:
@@ -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.workbench_runtime import (
|
||||
WorkbenchDateTime,
|
||||
WorkbenchRuntimeWorkOrderSubtask,
|
||||
WorkOrderActivity,
|
||||
)
|
||||
|
||||
|
||||
@@ -290,19 +290,17 @@ class ExternalWorkOrderSnapshot(StrictModel):
|
||||
work_order_type: Literal["general", "water_quality_complaint", "burst_report"] = "general"
|
||||
location: NonEmptyStr | None = None
|
||||
scenario_code: NonEmptyStr | None = None
|
||||
subtasks: Annotated[
|
||||
list[WorkbenchRuntimeWorkOrderSubtask], Field(min_length=1, max_length=50)
|
||||
] | None = None
|
||||
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
|
||||
activity: list[WorkOrderActivity] = Field(default_factory=list)
|
||||
origin: Literal["synthetic"]
|
||||
updated_at: WorkbenchDateTime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_subtasks(self):
|
||||
if self.subtasks is not None and len(
|
||||
{item.id for item in self.subtasks}
|
||||
) != len(self.subtasks):
|
||||
raise ValueError("work order subtask ids must be unique")
|
||||
return self
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def accept_legacy_snapshot(cls, value):
|
||||
if isinstance(value, dict):
|
||||
return {key: item for key, item in value.items() if key != "subtasks"}
|
||||
return value
|
||||
|
||||
|
||||
class OperationalTaskRun(StrictModel):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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 (
|
||||
NonEmptyStr,
|
||||
@@ -129,42 +129,13 @@ class WorkbenchAnalysisHistoryPage(StrictModel):
|
||||
next_offset: NonNegativeInt | None
|
||||
|
||||
|
||||
class WorkbenchRuntimeWorkOrderStage(StrictModel):
|
||||
id: NonEmptyStr
|
||||
class WorkOrderActivity(StrictModel):
|
||||
sequence: PositiveInt
|
||||
occurred_at: WorkbenchDateTime
|
||||
actor: NonEmptyStr
|
||||
kind: Literal["created", "updated", "feedback"]
|
||||
title: NonEmptyStr
|
||||
status: Literal["done", "current", "pending", "blocked", "skipped"]
|
||||
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
|
||||
detail: NonEmptyStr
|
||||
|
||||
|
||||
class WorkbenchRuntimeWorkOrder(StrictModel):
|
||||
@@ -181,16 +152,28 @@ class WorkbenchRuntimeWorkOrder(StrictModel):
|
||||
duration_minutes: PositiveInt
|
||||
source: NonEmptyStr
|
||||
dispatcher: NonEmptyStr
|
||||
location: NonEmptyStr | None = None
|
||||
assignee: NonEmptyStr | None = None
|
||||
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")
|
||||
def validate_subtasks(self):
|
||||
if not self.subtasks:
|
||||
raise ValueError("work order subtasks must not be empty")
|
||||
if len({subtask.id for subtask in self.subtasks}) != len(self.subtasks):
|
||||
raise ValueError("work order subtask ids must be unique")
|
||||
return self
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def accept_legacy_document(cls, value):
|
||||
if not isinstance(value, dict) or "subtasks" not in value:
|
||||
return value
|
||||
document = {key: item for key, item in value.items() if key != "subtasks"}
|
||||
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):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"contracts": {
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "3669e1b9ba31dad83f9c53028fd974b8bacac0f9d3069f644e49254baf6bb82f"
|
||||
"sha256": "c37f5626398e1762584a844bb908ca4f4fb68e203f4d37a489561669a434312b"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +251,15 @@
|
||||
],
|
||||
"title": "Assignee"
|
||||
},
|
||||
"feedback_requirements": {
|
||||
"items": {
|
||||
"maxLength": 4000,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Feedback Requirements",
|
||||
"type": "array"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -318,22 +327,6 @@
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"subtasks": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
|
||||
},
|
||||
"maxItems": 50,
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Subtasks"
|
||||
},
|
||||
"summary": {
|
||||
"maxLength": 4000,
|
||||
"minLength": 1,
|
||||
@@ -1654,16 +1647,27 @@
|
||||
"title": "Clear Scheduled At",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clear_subtasks": {
|
||||
"default": false,
|
||||
"title": "Clear Subtasks",
|
||||
"type": "boolean"
|
||||
},
|
||||
"expected_revision": {
|
||||
"minimum": 1.0,
|
||||
"title": "Expected Revision",
|
||||
"type": "integer"
|
||||
},
|
||||
"feedback_requirements": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"maxLength": 4000,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Feedback Requirements"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1694,6 +1698,19 @@
|
||||
],
|
||||
"title": "Priority"
|
||||
},
|
||||
"progress_note": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 4000,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Progress Note"
|
||||
},
|
||||
"request_id": {
|
||||
"format": "uuid",
|
||||
"title": "Request Id",
|
||||
@@ -1743,22 +1760,6 @@
|
||||
],
|
||||
"title": "Status"
|
||||
},
|
||||
"subtasks": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
|
||||
},
|
||||
"maxItems": 50,
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Subtasks"
|
||||
},
|
||||
"summary": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1796,6 +1797,13 @@
|
||||
"WorkOrder": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"activity": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WorkOrderActivity"
|
||||
},
|
||||
"title": "Activity",
|
||||
"type": "array"
|
||||
},
|
||||
"assignee": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1816,6 +1824,13 @@
|
||||
"title": "Created By",
|
||||
"type": "string"
|
||||
},
|
||||
"feedback_requirements": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Feedback Requirements",
|
||||
"type": "array"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1887,22 +1902,6 @@
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"subtasks": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
|
||||
},
|
||||
"maxItems": 50,
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Subtasks"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Summary",
|
||||
"type": "string"
|
||||
@@ -1955,6 +1954,55 @@
|
||||
"title": "WorkOrder",
|
||||
"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": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -1984,149 +2032,6 @@
|
||||
],
|
||||
"title": "WorkOrderPage",
|
||||
"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
@@ -2131,7 +2131,7 @@
|
||||
"work_order_snapshot": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ExternalWorkOrderSnapshot-Input"
|
||||
"$ref": "#/components/schemas/ExternalWorkOrderSnapshot"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -2235,9 +2235,16 @@
|
||||
"title": "ExternalFeedbackResult",
|
||||
"type": "object"
|
||||
},
|
||||
"ExternalWorkOrderSnapshot-Input": {
|
||||
"ExternalWorkOrderSnapshot": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"activity": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WorkOrderActivity"
|
||||
},
|
||||
"title": "Activity",
|
||||
"type": "array"
|
||||
},
|
||||
"assignee": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2250,6 +2257,14 @@
|
||||
],
|
||||
"title": "Assignee"
|
||||
},
|
||||
"feedback_requirements": {
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Feedback Requirements",
|
||||
"type": "array"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2318,165 +2333,6 @@
|
||||
"title": "Status",
|
||||
"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": {
|
||||
"minLength": 1,
|
||||
"title": "Summary",
|
||||
@@ -3705,7 +3561,7 @@
|
||||
"work_order_snapshot": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ExternalWorkOrderSnapshot-Output"
|
||||
"$ref": "#/components/schemas/ExternalWorkOrderSnapshot"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -6577,6 +6433,55 @@
|
||||
"title": "WebSearchRequest",
|
||||
"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": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -7217,6 +7122,25 @@
|
||||
"WorkbenchRuntimeWorkOrder": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"activity": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WorkOrderActivity"
|
||||
},
|
||||
"title": "Activity",
|
||||
"type": "array"
|
||||
},
|
||||
"assignee": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Assignee"
|
||||
},
|
||||
"code": {
|
||||
"minLength": 1,
|
||||
"title": "Code",
|
||||
@@ -7237,11 +7161,31 @@
|
||||
"title": "Duration Minutes",
|
||||
"type": "integer"
|
||||
},
|
||||
"feedback_requirements": {
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Feedback Requirements",
|
||||
"type": "array"
|
||||
},
|
||||
"id": {
|
||||
"minLength": 1,
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Location"
|
||||
},
|
||||
"priority": {
|
||||
"enum": [
|
||||
"normal",
|
||||
@@ -7291,13 +7235,6 @@
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"subtasks": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
|
||||
},
|
||||
"title": "Subtasks",
|
||||
"type": "array"
|
||||
},
|
||||
"summary": {
|
||||
"minLength": 1,
|
||||
"title": "Summary",
|
||||
@@ -7328,155 +7265,11 @@
|
||||
"duration_minutes",
|
||||
"source",
|
||||
"dispatcher",
|
||||
"priority",
|
||||
"subtasks"
|
||||
"priority"
|
||||
],
|
||||
"title": "WorkbenchRuntimeWorkOrder",
|
||||
"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": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -81,6 +81,8 @@ conda run -n server python scripts/sync_peripheral_work_orders.py
|
||||
|
||||
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器读取分页工单,将外围版本投影到 `external_work_order` 任务。合成来源在摘要和证据中明确标注;工单完成只表示该外部任务完成,不代表其他调查自动完成。
|
||||
|
||||
通用合成检查工单不属于预约任务。早期种子脚本创建的通信、压力和零流量检查工单已经退役,种子脚本不再恢复它们;同步器按不可编辑的专用 seed actor 跳过旧种子工单。投诉、爆管事件以及调查动作实际创建的现场工单继续同步。`018_remove_synthetic_check_task_projections.sql` 只清理专用 seed actor 创建的旧检查投影,不按用户可编辑的标题或状态删除任务,也不影响 `workbench.work_orders` 中保留的三条演示工单。
|
||||
|
||||
水质投诉和爆管上报类型的外围工单同时创建关联调查,即使当时没有浏览器打开也会入队。两类事件从外围样板测试面板模拟上报;工况页负责展示调查。调查中创建的外围工单通过回执编号关联,后续反馈送回原调查,由 Agent 重新分析。
|
||||
|
||||
## 旧调查迁移
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
- 定时任务:当前产品继续使用前端 mock。`scheduled_flow` 及后端计划接口为已有保留能力,不接当前前端、不由当前 worker 调度或执行。
|
||||
- `external_work_order`:外围预约、维修等工单的只读任务投影。`source.kind + source.external_id` 在项目内唯一,进度以外部版本反馈为准。
|
||||
|
||||
预约任务采用单层结构:一张外围工单对应一条任务,工单快照直接提供状态、位置、负责人、预约时间、回执要求和按发生时间记录的 `activity`。活动仅由工单创建、修改或显式现场反馈产生;不能按当前状态补造派发、执行、复令三个阶段。旧快照中的 `subtasks` 仅在读取时兼容并丢弃,不回写外围工单或历史版本;后续外围修订同步为新结构。
|
||||
- `event_investigation`:投诉、爆管上报或人工发起的调查。Agent 查询受控数据,发布执行图、证据引用和报告;现场反馈会创建新的执行 generation,让 Agent 重新分析。
|
||||
|
||||
创建请求使用 `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/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/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` 当作演练;具体要求见数据库变更门禁。
|
||||
|
||||
@@ -44,7 +44,9 @@ CREATE TABLE peripheral_sample.demo_datasets (
|
||||
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`,历史修订仍保留原样。
|
||||
|
||||
## 演示历史窗口配置
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
`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` 验证通过并由维护者明确确认后,再逐库扩展。禁止把“所有活跃项目”作为写操作的默认范围。
|
||||
|
||||
## 变更顺序
|
||||
|
||||
@@ -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 张表和关键列增加数据库可见的用途说明。
|
||||
|
||||
- 一张主工单包含一个或多个 `subtasks`。
|
||||
- 每个子任务保存自己的位置、班组、时间窗和复令要求。
|
||||
- 每个子任务包含一个或多个有序 `stages`,`sequence` 从 1 连续递增,阶段编号在子任务内唯一,最多只有一个 `current` 阶段。
|
||||
- 阶段名称不写死为固定枚举。演示数据使用派发、执行、复令,正式工单适配器可以按业务定义其他阶段。
|
||||
- 一张预约工单对应一条单层投影,直接保存位置、负责人、预约时间和回执要求。
|
||||
- `activity` 只记录已经发生的创建、修改和现场反馈,按递增 `sequence` 排列;展示层不得根据工单总状态补造派发、执行或复令阶段。
|
||||
- 旧文档中的 `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`。
|
||||
|
||||
@@ -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;
|
||||
@@ -5,7 +5,7 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, model_validator
|
||||
|
||||
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)]
|
||||
PositiveInt = Annotated[StrictInt, Field(ge=1)]
|
||||
@@ -122,18 +122,6 @@ WorkOrderStatus = Literal[
|
||||
]
|
||||
Priority = Literal["low", "normal", "high", "urgent"]
|
||||
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):
|
||||
request_id: UUID
|
||||
title: Annotated[str, Field(min_length=1, max_length=200)]
|
||||
@@ -145,13 +133,12 @@ class CreateWorkOrder(Model):
|
||||
work_order_type: WorkOrderType = "general"
|
||||
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
|
||||
subtasks: WorkOrderSubtasks | None = None
|
||||
feedback_requirements: list[Text] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def aware_schedule(self):
|
||||
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
|
||||
raise ValueError("scheduled_at must include timezone")
|
||||
_validate_unique_subtasks(self.subtasks)
|
||||
return self
|
||||
|
||||
|
||||
@@ -170,8 +157,8 @@ class UpdateWorkOrder(Model):
|
||||
clear_location: StrictBool = False
|
||||
scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None
|
||||
clear_scenario_code: StrictBool = False
|
||||
subtasks: WorkOrderSubtasks | None = None
|
||||
clear_subtasks: StrictBool = False
|
||||
feedback_requirements: list[Text] | None = None
|
||||
progress_note: Text | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_update(self):
|
||||
@@ -186,11 +173,8 @@ class UpdateWorkOrder(Model):
|
||||
raise ValueError("clear_location conflicts with location")
|
||||
if self.clear_scenario_code and self.scenario_code is not None:
|
||||
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:
|
||||
raise ValueError("scheduled_at must include timezone")
|
||||
_validate_unique_subtasks(self.subtasks)
|
||||
return self
|
||||
|
||||
|
||||
@@ -217,12 +201,15 @@ class WorkOrder(Model):
|
||||
work_order_type: WorkOrderType = "general"
|
||||
location: 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")
|
||||
def unique_subtasks(self):
|
||||
_validate_unique_subtasks(self.subtasks)
|
||||
return self
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def accept_legacy_document(cls, value):
|
||||
if isinstance(value, dict):
|
||||
return {key: item for key, item in value.items() if key != "subtasks"}
|
||||
return value
|
||||
|
||||
|
||||
class WorkOrderPage(Model):
|
||||
|
||||
@@ -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")
|
||||
@@ -24,6 +24,11 @@ from .schemas import (
|
||||
|
||||
TRANSITIONS = {"pending": ["processing"], "processing": ["pending_review"],
|
||||
"pending_review": ["processing", "completed"], "completed": []}
|
||||
WORK_ORDER_FIELD_LABELS = {
|
||||
"title": "标题", "summary": "说明", "status": "状态", "priority": "优先级",
|
||||
"assignee": "负责人", "scheduled_at": "预约时间", "location": "位置",
|
||||
"scenario_code": "场景编号", "feedback_requirements": "回执要求",
|
||||
}
|
||||
|
||||
|
||||
class PostgresStore:
|
||||
@@ -268,8 +273,13 @@ class PostgresStore:
|
||||
@staticmethod
|
||||
def _active_managed_document(document: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Project a current active row without legacy soft-delete metadata."""
|
||||
return {key: value for key, value in document.items()
|
||||
if key not in {"deleted_at", "deleted_by"}}
|
||||
active = {key: value for key, value in document.items()
|
||||
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,
|
||||
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,
|
||||
"origin": "synthetic", "created_at": now, "updated_at": now,
|
||||
"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._mutate(who, "create_work_order", command, write)
|
||||
@@ -326,7 +338,7 @@ class PostgresStore:
|
||||
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409)
|
||||
changes = command.model_dump(mode="json", exclude_unset=True,
|
||||
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:
|
||||
changes["assignee"] = None
|
||||
if command.clear_scheduled_at:
|
||||
@@ -335,10 +347,22 @@ class PostgresStore:
|
||||
changes["location"] = None
|
||||
if command.clear_scenario_code:
|
||||
changes["scenario_code"] = None
|
||||
if command.clear_subtasks:
|
||||
changes["subtasks"] = None
|
||||
changed_fields = [key for key, value in changes.items() if document.get(key) != value]
|
||||
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)
|
||||
return self._save_work_order(conn, who, document)
|
||||
return self._mutate(who, f"update_work_order:{work_order_id}", command, write)
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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()
|
||||
@@ -647,27 +647,9 @@ def _work_order_snapshot(work_order_id, now):
|
||||
"work_order_type": "water_quality_complaint",
|
||||
"location": "联调小区",
|
||||
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
|
||||
"subtasks": [{
|
||||
"id": "sampling",
|
||||
"title": "现场采样",
|
||||
"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": ["上传检测结果"],
|
||||
}],
|
||||
"feedback_requirements": ["上传检测结果"],
|
||||
"activity": [{"sequence": 1, "occurred_at": now.isoformat(), "actor": "水质班组",
|
||||
"kind": "feedback", "title": "现场反馈", "detail": "上传复测结果"}],
|
||||
"origin": "synthetic",
|
||||
"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.priority == "urgent"
|
||||
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
|
||||
|
||||
@@ -25,7 +25,7 @@ class ManagedStore:
|
||||
return command.model_dump(mode="json", exclude={"request_id"}) | {
|
||||
"work_order_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1,
|
||||
"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):
|
||||
@@ -35,7 +35,7 @@ class ManagedStore:
|
||||
"status": command.status or "open", "priority": command.priority or "normal",
|
||||
"assignee": None, "scheduled_at": None, "work_order_type": "general",
|
||||
"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)}
|
||||
|
||||
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,
|
||||
"work_order_type": "water_quality_complaint", "location": "演示小区",
|
||||
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
|
||||
"subtasks": [{
|
||||
"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": "确认采样人员"}],
|
||||
}],
|
||||
"feedback_requirements": ["上传检测结果"],
|
||||
})
|
||||
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
|
||||
assert created.json()["work_order_type"] == "water_quality_complaint"
|
||||
assert created.json()["location"] == "演示小区"
|
||||
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"]
|
||||
changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={
|
||||
"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,
|
||||
})
|
||||
assert changed.status_code == 200 and changed.json()["revision"] == 2
|
||||
assert changed.json()["status"] == "awaiting_reply"
|
||||
update_command = next(call[3] for call in fake.calls if call[0] == "update_work_order")
|
||||
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={
|
||||
"request_id": str(uuid4()), "expected_revision": 2,
|
||||
})
|
||||
|
||||
@@ -5,7 +5,12 @@ import httpx
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -14,6 +19,29 @@ def anyio_backend():
|
||||
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
|
||||
async def test_sync_creates_one_task_and_only_applies_new_external_revisions():
|
||||
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["location"] == "测试位置"
|
||||
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.
|
||||
accepted = ExternalFeedbackCommand.model_validate(writes[-1][1])
|
||||
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
|
||||
async def test_sample_sync_rejects_cross_project_data_before_writing():
|
||||
def handler(request):
|
||||
@@ -74,17 +142,29 @@ async def test_sample_sync_rejects_cross_project_data_before_writing():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("investigation_status", ["waiting_human", "queued"])
|
||||
async def test_created_work_order_receipt_routes_feedback_back_to_investigation(investigation_status):
|
||||
@pytest.mark.parametrize(
|
||||
("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()))
|
||||
received = []
|
||||
def handler(request):
|
||||
if request.url.host == "sample.test":
|
||||
return httpx.Response(200, json={"items": [{
|
||||
"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",
|
||||
"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,
|
||||
}], "next_cursor": None})
|
||||
if request.method == "POST":
|
||||
@@ -106,8 +186,10 @@ async def test_created_work_order_receipt_routes_feedback_back_to_investigation(
|
||||
], "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:
|
||||
assert await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) == 2
|
||||
assert any(investigation_id in path for path in received)
|
||||
assert await sync_once(
|
||||
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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
def test_snapshot_preserves_nested_process_without_inventing_completed_stages():
|
||||
def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
|
||||
order = {
|
||||
"work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步",
|
||||
"status": "in_progress", "priority": "normal", "origin": "synthetic",
|
||||
"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": [{
|
||||
"id": "visit", "title": "现场检查", "summary": "合成测试,未现场执行",
|
||||
"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)
|
||||
assert snapshot["subtasks"] == order["subtasks"]
|
||||
assert snapshot["subtasks"][0]["stages"][0]["status"] == "pending"
|
||||
assert work_order_snapshot({key: value for key, value in order.items() if key != "subtasks"})["subtasks"] is None
|
||||
assert snapshot["feedback_requirements"] == ["回填现场检查结果"]
|
||||
assert snapshot["activity"] == order["activity"]
|
||||
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"] == []
|
||||
@@ -1,12 +1,10 @@
|
||||
from contextlib import contextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.seed_peripheral_sample import (
|
||||
ACTOR_ID,
|
||||
previous_seed_result,
|
||||
restore_seeded_resources,
|
||||
restore_seeded_test_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -42,8 +40,8 @@ class FakeStore:
|
||||
def test_previous_seed_result_reuses_legacy_receipt():
|
||||
resource_id = str(uuid4())
|
||||
store = FakeStore({
|
||||
"command_key": "create_work_order",
|
||||
"result": {"work_order_id": resource_id},
|
||||
"command_key": "create_test_data",
|
||||
"result": {"record_id": resource_id},
|
||||
})
|
||||
seed_request_id = uuid4()
|
||||
|
||||
@@ -51,10 +49,10 @@ def test_previous_seed_result_reuses_legacy_receipt():
|
||||
store,
|
||||
"project-1",
|
||||
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.connection_value.calls[0][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()
|
||||
resource_ids = [str(uuid4()), str(uuid4())]
|
||||
|
||||
restore_seeded_resources(
|
||||
store,
|
||||
"project-1",
|
||||
table="work_orders",
|
||||
id_column="work_order_id",
|
||||
resource_ids=resource_ids,
|
||||
)
|
||||
restore_seeded_test_data(store, "project-1", resource_ids)
|
||||
|
||||
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 "deleted_at=NULL, deleted_by=NULL" in statement
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -28,25 +28,18 @@ def test_build_demo_work_orders_restores_original_mock_tasks():
|
||||
"running",
|
||||
"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)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
for document in documents:
|
||||
current_stages = [
|
||||
stage
|
||||
for subtask in document["subtasks"]
|
||||
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"
|
||||
assert document["activity"]
|
||||
assert all(entry["kind"] in {"created", "updated"} for entry in document["activity"])
|
||||
assert "stages" not in document
|
||||
|
||||
|
||||
def test_replace_removes_legacy_projection_before_inserting_demo_tasks():
|
||||
|
||||
@@ -201,47 +201,11 @@ async def test_snapshot_projects_persisted_work_orders_and_alerts():
|
||||
"source": "生产工单系统",
|
||||
"dispatcher": "调度中心",
|
||||
"priority": "urgent",
|
||||
"subtasks": [{
|
||||
"id": "inspect-valve",
|
||||
"title": "阀门开度复核",
|
||||
"summary": "现场复核阀门开度。",
|
||||
"status": "running",
|
||||
"scheduled_at": NOW.replace(hour=3),
|
||||
"location": "北区阀门 V-1",
|
||||
"assignee": "北区班组",
|
||||
"duration_minutes": 45,
|
||||
"reply_window_minutes": 20,
|
||||
"stages": [
|
||||
{
|
||||
"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": ["回传阀门开度", "复核压力"],
|
||||
}],
|
||||
"location": "北区阀门 V-1",
|
||||
"assignee": "北区班组",
|
||||
"feedback_requirements": ["回传阀门开度", "复核压力"],
|
||||
"activity": [{"sequence": 1, "occurred_at": NOW, "actor": "调度中心",
|
||||
"kind": "created", "title": "工单创建", "detail": "等待现场复核。"}],
|
||||
}
|
||||
persisted_alert = {
|
||||
"alert_id": "alarm-1",
|
||||
@@ -282,3 +246,5 @@ async def test_snapshot_projects_persisted_work_orders_and_alerts():
|
||||
"severity": "warning",
|
||||
}]
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user