feat(workbench): add runtime GIS and sample integrations

This commit is contained in:
2026-09-22 10:44:31 +08:00
parent a3b4e75317
commit 7ab9b36a5e
62 changed files with 6685 additions and 51 deletions
+13
View File
@@ -45,6 +45,9 @@ conda activate server
## 常用命令
外围接入样板是独立进程,复用 PostgreSQL,不自动建表;启动、授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。
外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD,并将演示预约工单投影到 `workbench.work_orders`。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。
```bash
conda run -n server python -m pytest tests -q
conda run -n server python scripts/run_server.py
@@ -87,6 +90,16 @@ docker compose -f infra/docker/docker-compose.yml config
使用模板复制或临时方案库的模拟功能时,`biz_data` 账号必须具备数据库创建和删除权限;只有显式删除项目时才会终止该项目的现有数据库会话,普通复制不会主动中断复制源会话。
奉贤项目的原始 GIS 与水质监测点使用独立脚本维护。导入脚本读取两份交付目录并写入项目 BizDB;水质脚本调用现有灵敏度分析服务生成 50 个监测点;发布脚本创建 10 个项目图层并刷新 GeoServer 缓存:
```bash
conda run -n server python scripts/import_fengxian_gis.py --database tjwater_v2
conda run -n server python scripts/generate_fengxian_water_quality_points.py --project tjwater_v2
conda run -n server python scripts/publish_fengxian_map_layers.py --database tjwater_v2 --workspace tjwater_next
```
发布命令需要配置 `GEOSERVER_URL`、`GEOSERVER_USERNAME` 和 `GEOSERVER_PASSWORD`。其中 7 个基础管网图层仍由项目供应流程创建,奉贤发布脚本追加 9 个原始 GIS 图层和 1 个水质监测点图层。
## 测试与发布
提交前根据改动范围运行最小有效测试:
+107 -35
View File
@@ -1,47 +1,116 @@
import logging
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg import AsyncConnection
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.auth.project_dependencies import get_project_pg_connection
from app.domain.schemas.gis_attributes import (
ElementGisProperties,
GisElementType,
SourceGisFeatureProperties,
)
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
router = APIRouter()
logger = logging.getLogger(__name__)
async def get_database_connection(
@router.get(
"/source-gis/layers/{layer_key}/features/{source_fid}/properties",
response_model=SourceGisFeatureProperties,
summary="获取源 GIS 要素台账属性",
)
async def get_source_gis_feature_properties(
layer_key: str,
source_fid: int,
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""获取数据库连接"""
yield conn
normalized_layer_key = layer_key.strip()
if (
not normalized_layer_key
or len(normalized_layer_key) > 80
or source_fid < 0
):
raise HTTPException(status_code=422, detail="无效的源 GIS 要素编号")
try:
group = await GisAttributeRepository.get_source_feature_group(
conn,
normalized_layer_key,
source_fid,
)
if group is None:
raise HTTPException(status_code=404, detail="源 GIS 要素不存在")
return SourceGisFeatureProperties(
layer_key=normalized_layer_key,
source_fid=source_fid,
group=group,
)
except HTTPException:
raise
except Exception as exc:
logger.warning("Source GIS feature properties are unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询源 GIS 要素属性失败") from exc
@router.get(
"/elements/{element_type}/{element_id}/gis-properties",
response_model=ElementGisProperties,
summary="获取元素 GIS 台账属性",
)
async def get_element_gis_properties(
element_type: GisElementType,
element_id: str,
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""Return source GIS records linked to one current-project map element."""
normalized_id = element_id.strip()
if not normalized_id or len(normalized_id) > 160:
raise HTTPException(status_code=422, detail="无效的元素编号")
try:
groups = await GisAttributeRepository.list_element_groups(
conn,
element_type,
normalized_id,
)
return ElementGisProperties(
element_type=element_type,
element_id=normalized_id,
groups=groups,
)
except Exception as exc:
logger.warning("Element GIS properties are unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询 GIS 台账属性失败") from exc
@router.get("/analysis/runs", summary="获取分析运行列表")
async def get_analysis_runs(
conn: AsyncConnection = Depends(get_database_connection),
limit: int = Query(default=100, ge=1, le=100),
offset: int = Query(default=0, ge=0),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""
获取所有方案信息
返回项目中所有方案的详细信息
"""
"""分页返回当前项目的持久分析运行。"""
try:
runs = await AnalysisRepository.list_runs(conn)
return {"success": True, "data": runs, "count": len(runs)}
except Exception as e:
raise HTTPException(status_code=500, detail=f"查询分析运行时发生错误: {str(e)}")
runs = await AnalysisRepository.list_runs(conn, limit=limit, offset=offset)
return {
"success": True,
"data": runs,
"count": len(runs),
"limit": limit,
"offset": offset,
}
except Exception as exc:
logger.warning("Analysis run page is unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
@router.get("/analysis/runs/{run_id}", summary="获取分析运行")
async def get_analysis_run(
run_id: UUID,
conn: AsyncConnection = Depends(get_database_connection),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""
获取所有爆管定位结果
返回项目中所有的爆管定位分析结果
"""
"""返回当前项目的一条持久分析运行。"""
try:
run = await AnalysisRepository.get_run(conn, run_id)
if run is None:
@@ -49,28 +118,31 @@ async def get_analysis_run(
return run
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"查询分析运行时发生错误: {str(e)}"
)
except Exception as exc:
logger.warning("Analysis run is unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
@router.get("/analysis/runs/{run_id}/results", summary="获取分析结果")
async def get_analysis_results(
run_id: UUID,
result_type: str | None = Query(default=None, description="结果类型"),
conn: AsyncConnection = Depends(get_database_connection),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""
根据爆管事件ID查询爆管定位结果
参数:
burst_incident: 爆管事件的唯一标识符
"""
"""分页返回一条分析运行的持久结果。"""
try:
return await AnalysisRepository.list_results(conn, run_id, result_type)
except Exception as e:
return await AnalysisRepository.list_results(
conn,
run_id,
result_type,
limit=limit,
offset=offset,
)
except Exception as exc:
logger.warning("Analysis result page is unavailable", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"查询分析结果时发生错误: {str(e)}",
)
detail="查询分析结果失败",
) from exc
+1
View File
@@ -146,6 +146,7 @@ async def optimize_sensor_placement_scheme(
sensor_count=payload.sensor_count,
min_diameter=payload.min_diameter,
created_by=current_user.username,
sensor_type=payload.sensor_type,
)
run = get_sensor_placement_run(network, created["run_id"])
return {**run, "can_edit": True}
+244
View File
@@ -0,0 +1,244 @@
"""Read-only workbench runtime aggregation."""
import logging
from contextlib import asynccontextmanager
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from psycopg import AsyncConnection
from pydantic import ValidationError
from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
get_project_pg_connection,
)
from app.domain.schemas.workbench_runtime import (
WorkbenchAnalysisHistoryPage,
WorkbenchPersistedAlert,
WorkbenchRuntimeLatestAnalysis,
WorkbenchRuntimeSnapshot,
WorkbenchRuntimeWorkOrder,
)
from app.domain.schemas.task_alert import TaskAlertCommand, TaskAlertEvent, TaskAlertState
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.workbench_operations import (
WorkbenchOperationsRepository,
)
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
from app.services.task_alerts import TaskAlertService
from app.domain.domain_errors import DomainError
from app.services.workbench_runtime import (
build_workbench_runtime_snapshot,
project_analysis_history_item,
project_latest_analysis,
project_sensor_location_map_payload,
sensor_location_ids_for_map_projection,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@asynccontextmanager
async def _projection_transaction(conn: AsyncConnection):
"""Isolate an optional projection so one SQL failure cannot poison others."""
transaction = getattr(conn, "transaction", None)
if transaction is None: # Lightweight endpoint test doubles.
yield
return
async with transaction():
yield
@router.get(
"/workbench/analysis-runs",
response_model=WorkbenchAnalysisHistoryPage,
)
async def workbench_analysis_runs(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""Return a bounded, sanitized analysis history page for the workbench."""
rows = await AnalysisRepository.list_run_summaries(
conn,
limit=limit + 1,
offset=offset,
)
has_more = len(rows) > limit
items = rows[:limit]
return {
"items": [project_analysis_history_item(run) for run in items],
"limit": limit,
"offset": offset,
"next_offset": offset + limit if has_more else None,
}
@router.get(
"/workbench/analysis-runs/{run_id}",
response_model=WorkbenchRuntimeLatestAnalysis,
)
async def workbench_analysis_run(
run_id: UUID,
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""Return one authorized project's analysis run as a typed workbench artifact."""
run = await AnalysisRepository.get_run(conn, run_id)
if run is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Analysis run not found",
)
results = await AnalysisRepository.list_recent_results(conn, run_id, limit=100)
try:
referenced_map_payload = await _sensor_location_map_payload(conn, results)
except Exception:
referenced_map_payload = None
logger.warning(
"Workbench analysis spatial references are unavailable",
exc_info=True,
)
return project_latest_analysis(
run,
results,
referenced_map_payload=referenced_map_payload,
)
@router.post("/workbench/task-alert-events", response_model=TaskAlertState)
async def push_task_alert_event(
command: TaskAlertCommand,
ctx: ProjectContext = Depends(get_project_context),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
event = TaskAlertEvent(
**command.model_dump(),
producer_id=f"principal:{ctx.user_id}",
)
return await TaskAlertService(
TaskAlertRepository(conn, ctx.project_id), ctx
).push(event)
except DomainError as exc:
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
@router.get("/workbench/runtime", response_model=WorkbenchRuntimeSnapshot)
async def workbench_runtime(
ctx: ProjectContext = Depends(get_project_context),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
analysis_runs: list[dict] | None = None
latest_results: list[dict] | None = None
latest_analysis_map_payload: dict | None = None
analysis_availability = "not_configured"
work_order_storage_configured = False
work_orders: list[dict] | None = None
alert_storage_configured = False
alerts: list[dict] | None = None
schedule_availability = "not_configured"
work_order_availability = "not_configured"
alert_availability = "not_configured"
try:
async with _projection_transaction(conn):
analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100)
if analysis_runs:
latest_results = await AnalysisRepository.list_recent_results(
conn, analysis_runs[0]["run_id"], limit=100
)
analysis_availability = "ready" if analysis_runs else "not_configured"
except Exception:
logger.warning("Workbench analysis projection is unavailable", exc_info=True)
analysis_runs = None
latest_results = None
analysis_availability = "degraded"
if analysis_runs and latest_results:
try:
async with _projection_transaction(conn):
latest_analysis_map_payload = await _sensor_location_map_payload(
conn, latest_results
)
except Exception:
logger.warning(
"Workbench analysis spatial references are unavailable",
exc_info=True,
)
try:
async with _projection_transaction(conn):
operations_availability = await WorkbenchOperationsRepository.availability(conn)
work_order_storage_configured = operations_availability.work_orders
alert_storage_configured = operations_availability.alerts
except Exception:
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
schedule_availability = "degraded"
work_order_availability = "degraded"
alert_availability = "degraded"
if work_order_storage_configured:
try:
async with _projection_transaction(conn):
work_order_documents = await WorkbenchOperationsRepository.list_work_orders(
conn, str(ctx.project_id)
)
work_orders, invalid_work_orders = _validate_documents(
work_order_documents, WorkbenchRuntimeWorkOrder, "work order"
)
work_order_availability = (
"degraded" if invalid_work_orders else "ready"
)
except Exception:
work_orders = None
work_order_availability = "degraded"
logger.warning("Workbench work-order projection is unavailable", exc_info=True)
if alert_storage_configured:
try:
async with _projection_transaction(conn):
alert_documents = await WorkbenchOperationsRepository.list_alerts(
conn, str(ctx.project_id)
)
alerts, invalid_alerts = _validate_documents(
alert_documents, WorkbenchPersistedAlert, "alert"
)
alert_availability = "degraded" if invalid_alerts else "ready"
except Exception:
alerts = None
alert_availability = "degraded"
logger.warning("Workbench alert projection is unavailable", exc_info=True)
return await build_workbench_runtime_snapshot(
analysis_runs=analysis_runs,
latest_analysis_results=latest_results,
latest_analysis_map_payload=latest_analysis_map_payload,
work_order_storage_configured=work_order_storage_configured,
work_orders=work_orders,
alert_storage_configured=alert_storage_configured,
persisted_alerts=alerts,
scenario_availability=analysis_availability,
artifact_availability=analysis_availability,
schedule_availability=schedule_availability,
work_order_availability=work_order_availability,
alert_availability=alert_availability,
projection_degraded=False,
)
async def _sensor_location_map_payload(
conn: AsyncConnection, results: list[dict]
) -> dict | None:
node_ids = sensor_location_ids_for_map_projection(results)
if not node_ids:
return None
map_points = await AnalysisRepository.list_junction_map_points(conn, node_ids)
return project_sensor_location_map_payload(node_ids, map_points)
def _validate_documents(documents, model, label: str) -> tuple[list[dict], int]:
valid: list[dict] = []
rejected = 0
for document in documents:
try:
valid.append(model.model_validate(document).model_dump())
except ValidationError:
rejected += 1
logger.warning("Workbench %s document was rejected", label)
return valid, rejected
+6
View File
@@ -17,6 +17,7 @@ from app.api.v1.endpoints import (
sensor_placement,
simulation,
web_search,
workbench_runtime,
)
from app.api.v1.endpoints.components import (
controls,
@@ -85,6 +86,11 @@ burst_run_access = Depends(require_permission(BURST_RUN))
optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN))
# Core services
api_router.include_router(
workbench_runtime.router,
tags=["Workbench Runtime"],
dependencies=[simulation_access],
)
api_router.include_router(access.router, tags=["Access Control"])
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
api_router.include_router(
-1
View File
@@ -9,7 +9,6 @@ class Settings(BaseSettings):
ENVIRONMENT: str = "production"
API_V1_STR: str = "/api/v1"
NETWORK_NAME: str = "default_network"
# 敏感配置加密密钥 (Fernet)
DATABASE_ENCRYPTION_KEY: str = ""
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
class DomainError(Exception):
"""A domain rejection with a stable API error payload."""
def __init__(self, code: str, message: str, *, refs: list | None = None,
recovery: str = "none", status: int = 422):
super().__init__(message)
self.code, self.message, self.status = code, message, status
self.refs, self.recovery = refs or [], recovery
def payload(self) -> dict:
return {"code": self.code, "message": self.message,
"retryable": self.recovery == "retry_same_request",
"related_refs": self.refs, "recovery_action": self.recovery,
"retry_after_seconds": None}
def require(condition: bool, code: str, message: str, **kwargs) -> None:
if not condition:
raise DomainError(code, message, **kwargs)
+47
View File
@@ -0,0 +1,47 @@
"""Schemas for project-scoped source GIS attributes."""
from datetime import date
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
GisElementType = Literal[
"pipes",
"junctions",
"valves",
"reservoirs",
"pumps",
"tanks",
"scada",
]
class GisAttributeGroup(BaseModel):
model_config = ConfigDict(extra="forbid")
dataset_key: str
dataset_title: str
source_date: date
layer_key: str
layer_name: str
source_fid: int
match_method: Literal["node_pair", "geometry", "source"]
match_distance_m: float | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
class ElementGisProperties(BaseModel):
model_config = ConfigDict(extra="forbid")
element_type: GisElementType
element_id: str
groups: list[GisAttributeGroup] = Field(default_factory=list)
class SourceGisFeatureProperties(BaseModel):
model_config = ConfigDict(extra="forbid")
layer_key: str
source_fid: int
group: GisAttributeGroup
+3 -1
View File
@@ -25,7 +25,7 @@ class SensorPlacementOptimizeRequest(BaseModel):
pattern=r"^[^/\\\x00]+$",
)
run_name: str = Field(..., min_length=1, max_length=64)
sensor_type: Literal["pressure"]
sensor_type: Literal["pressure", "water_quality"]
method: Literal["sensitivity", "kmeans"]
sensor_count: int = Field(..., gt=0, le=200)
min_diameter: int = Field(default=0, ge=0)
@@ -89,6 +89,8 @@ class SensorPlacementSchemeResponse(BaseModel):
created_by: str
created_at: datetime
status: str
sensor_type: Literal["pressure", "water_quality"] = "pressure"
method: Literal["sensitivity", "kmeans"] | None = None
sensor_locations: list[str]
sensor_points: list[SensorPointResponse]
can_edit: bool = False
+58
View File
@@ -0,0 +1,58 @@
"""Small strict value types shared by independent backend features."""
from datetime import datetime
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints, model_validator
NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
StrictBool = Annotated[bool, Field(strict=True)]
PositiveInt = Annotated[int, Field(strict=True, ge=1)]
NonNegativeInt = Annotated[int, Field(strict=True, ge=0)]
def _datetime_wire_value(value: object) -> datetime | str:
if not isinstance(value, (datetime, str)):
raise ValueError("datetime input must be an RFC3339 string or datetime instance")
return value
WireDateTime = Annotated[datetime, BeforeValidator(_datetime_wire_value)]
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
ObjectType = Literal[
"analysis_task",
"goal",
"state_snapshot",
"action_contract",
"validation",
"run",
"outcome",
"evidence",
"artifact",
"operation",
]
class ObjectRef(StrictModel):
object_type: ObjectType
object_id: UUID
revision: PositiveInt
class TimeRange(StrictModel):
start: WireDateTime
end: WireDateTime
@model_validator(mode="after")
def validate_range(self) -> "TimeRange":
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("timestamps must include a timezone")
if self.start >= self.end:
raise ValueError("start must be before end")
return self
+38
View File
@@ -0,0 +1,38 @@
"""Idempotent alert events emitted by scheduled and other backend tasks."""
from __future__ import annotations
from typing import Literal
from uuid import UUID
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchDateTime
class TaskAlertCommand(StrictModel):
event_id: UUID
alert_id: NonEmptyStr
event_type: Literal["raised", "acknowledged", "resolved"]
occurred_at: WorkbenchDateTime
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
severity: Literal["warning", "critical"]
class TaskAlertEvent(TaskAlertCommand):
producer_id: NonEmptyStr
class TaskAlertState(StrictModel):
alert_id: NonEmptyStr
revision: PositiveInt
producer_id: NonEmptyStr
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
severity: Literal["warning", "critical"]
status: Literal["active", "acknowledged", "resolved"]
updated_at: WorkbenchDateTime
+209
View File
@@ -0,0 +1,209 @@
"""Typed read-only projections consumed by the supply workbench."""
from typing import Annotated, Literal
from pydantic import AwareDatetime, BeforeValidator, JsonValue, model_validator
from app.domain.schemas.shared import (
NonEmptyStr,
NonNegativeInt,
PositiveInt,
StrictBool,
StrictModel,
)
def _workbench_datetime_wire_value(value: object):
from datetime import datetime
if not isinstance(value, (datetime, str)):
raise ValueError("datetime input must be an RFC3339 string or datetime instance")
return value
WorkbenchDateTime = Annotated[
AwareDatetime, BeforeValidator(_workbench_datetime_wire_value)
]
ProjectionAvailability = Literal["ready", "not_configured", "degraded"]
class WorkbenchRuntimeCondition(StrictModel):
id: NonEmptyStr
task_id: Literal["backend-schedule"]
session_id: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
status: Literal["pending", "running", "completed", "warning", "error"]
risk_level: Literal["normal", "attention", "critical"]
scheduled_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
detail: NonEmptyStr
recommendation: NonEmptyStr | None
evidence: list[NonEmptyStr]
model_name: Literal["TJWater scheduler"]
class WorkbenchAlertSource(StrictModel):
task_type: Literal["scheduled_task", "analysis_task", "other_task"]
task_id: NonEmptyStr
run_id: NonEmptyStr
class WorkbenchRuntimeAlert(StrictModel):
id: NonEmptyStr
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
severity: Literal["warning", "critical"]
class WorkbenchPersistedAlert(StrictModel):
alert_id: NonEmptyStr
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
severity: Literal["warning", "critical"]
status: Literal["active", "acknowledged", "resolved"]
producer_id: NonEmptyStr | None = None
updated_at: WorkbenchDateTime | None = None
class WorkbenchRuntimeScenario(StrictModel):
id: NonEmptyStr
run_id: NonEmptyStr
name: NonEmptyStr
description: NonEmptyStr
status: Literal["draft", "active", "review"]
run_type: NonEmptyStr
run_type_label: NonEmptyStr
run_status: NonEmptyStr
created_at: WorkbenchDateTime
class WorkbenchRuntimeAnalysisFact(StrictModel):
label: NonEmptyStr
value: NonEmptyStr
class WorkbenchRuntimeAnalysisResult(StrictModel):
result_type: NonEmptyStr
created_at: WorkbenchDateTime
facts: list[WorkbenchRuntimeAnalysisFact]
class WorkbenchRuntimeLatestAnalysis(StrictModel):
run_id: NonEmptyStr
name: NonEmptyStr
run_type: NonEmptyStr
run_type_label: NonEmptyStr
status: NonEmptyStr
status_label: NonEmptyStr
created_by: NonEmptyStr
created_at: WorkbenchDateTime
started_at: WorkbenchDateTime
results: list[WorkbenchRuntimeAnalysisResult]
map_payload: dict[str, JsonValue] | None
class WorkbenchAnalysisHistoryItem(StrictModel):
run_id: NonEmptyStr
name: NonEmptyStr
run_type: NonEmptyStr
run_type_label: NonEmptyStr
status: NonEmptyStr
status_label: NonEmptyStr
created_by: NonEmptyStr
created_at: WorkbenchDateTime
started_at: WorkbenchDateTime
has_results: StrictBool
class WorkbenchAnalysisHistoryPage(StrictModel):
items: list[WorkbenchAnalysisHistoryItem]
limit: PositiveInt
offset: NonNegativeInt
next_offset: NonNegativeInt | None
class WorkbenchRuntimeWorkOrderStage(StrictModel):
id: NonEmptyStr
sequence: PositiveInt
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
class WorkbenchRuntimeWorkOrder(StrictModel):
id: NonEmptyStr
code: NonEmptyStr
scheduled_at: WorkbenchDateTime
title: NonEmptyStr
summary: NonEmptyStr
status: Literal["pending", "running", "awaiting_reply", "completed"]
risk_level: Literal["normal", "attention", "critical"]
updated_at: WorkbenchDateTime
detail: NonEmptyStr
recommendation: NonEmptyStr | None
duration_minutes: PositiveInt
source: NonEmptyStr
dispatcher: NonEmptyStr
priority: Literal["normal", "urgent"]
subtasks: list[WorkbenchRuntimeWorkOrderSubtask]
@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
class WorkbenchRuntimeSnapshot(StrictModel):
availability: ProjectionAvailability
source: Literal["none", "scheduled_tasks"]
scenario_availability: ProjectionAvailability
artifact_availability: ProjectionAvailability
schedule_availability: ProjectionAvailability
work_order_availability: ProjectionAvailability
alert_availability: ProjectionAvailability
generated_at: WorkbenchDateTime
conditions: list[WorkbenchRuntimeCondition]
alerts: list[WorkbenchRuntimeAlert]
scenarios: list[WorkbenchRuntimeScenario]
latest_analysis: WorkbenchRuntimeLatestAnalysis | None
work_orders: list[WorkbenchRuntimeWorkOrder]
+108 -4
View File
@@ -101,7 +101,11 @@ class AnalysisRepository:
return cur.fetchone()
@staticmethod
async def list_runs(conn: AsyncConnection) -> list[dict]:
async def list_runs(
conn: AsyncConnection, *, limit: int = 100, offset: int = 0
) -> list[dict]:
if not 1 <= limit <= 100 or offset < 0:
raise ValueError("analysis run page is out of range")
async with conn.cursor() as cur:
await cur.execute(
"""
@@ -109,7 +113,52 @@ class AnalysisRepository:
started_at, status, parameters
FROM analysis.runs
ORDER BY created_at DESC, run_id
LIMIT %s OFFSET %s
""",
(limit, offset),
)
return await cur.fetchall()
@staticmethod
async def list_recent_runs(
conn: AsyncConnection, *, limit: int = 100
) -> list[dict]:
if not 1 <= limit <= 100:
raise ValueError("recent analysis run limit must be between 1 and 100")
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT run_id, name, run_type, created_by, created_at,
started_at, status, parameters
FROM analysis.runs
ORDER BY created_at DESC, run_id
LIMIT %s
""",
(limit,),
)
return await cur.fetchall()
@staticmethod
async def list_run_summaries(
conn: AsyncConnection, *, limit: int = 21, offset: int = 0
) -> list[dict]:
if not 1 <= limit <= 101 or offset < 0:
raise ValueError("analysis run summary page is out of range")
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT r.run_id, r.name, r.run_type, r.created_by,
r.created_at, r.started_at, r.status,
EXISTS (
SELECT 1
FROM analysis.results AS result
WHERE result.run_id = r.run_id
) AS has_results
FROM analysis.runs AS r
ORDER BY r.created_at DESC, r.run_id
LIMIT %s OFFSET %s
""",
(limit, offset),
)
return await cur.fetchall()
@@ -129,20 +178,75 @@ class AnalysisRepository:
@staticmethod
async def list_results(
conn: AsyncConnection, run_id: UUID, result_type: str | None = None
conn: AsyncConnection,
run_id: UUID,
result_type: str | None = None,
*,
limit: int = 100,
offset: int = 0,
) -> list[dict]:
if not 1 <= limit <= 500 or offset < 0:
raise ValueError("analysis result page is out of range")
query = """
SELECT result_id, run_id, result_type, node_id, link_id,
payload, created_at
FROM analysis.results
WHERE run_id = %s
"""
params: tuple[UUID] | tuple[UUID, str] = (run_id,)
params: tuple = (run_id,)
if result_type is not None:
query += " AND result_type = %s"
params = (run_id, result_type)
query += " ORDER BY created_at, result_id"
query += " ORDER BY created_at, result_id LIMIT %s OFFSET %s"
params += (limit, offset)
async with conn.cursor() as cur:
await cur.execute(query, params)
return await cur.fetchall()
@staticmethod
async def list_recent_results(
conn: AsyncConnection, run_id: UUID, *, limit: int = 100
) -> list[dict]:
if not 1 <= limit <= 100:
raise ValueError("recent analysis result limit must be between 1 and 100")
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT result_id, run_id, result_type, node_id, link_id,
payload, created_at
FROM (
SELECT result_id, run_id, result_type, node_id, link_id,
payload, created_at
FROM analysis.results
WHERE run_id = %s
ORDER BY created_at DESC, result_id DESC
LIMIT %s
) AS recent_results
ORDER BY created_at, result_id
""",
(run_id, limit),
)
return await cur.fetchall()
@staticmethod
async def list_junction_map_points(
conn: AsyncConnection, node_ids: list[str]
) -> list[dict]:
if not node_ids:
return []
if len(node_ids) > 500:
raise ValueError("analysis map projection supports at most 500 junctions")
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT g.id AS node_id,
ST_X(ST_Transform(g.geom, 4326)) AS longitude,
ST_Y(ST_Transform(g.geom, 4326)) AS latitude
FROM gis.junctions AS g
WHERE g.id = ANY(%s)
ORDER BY g.id
""",
(node_ids,),
)
return await cur.fetchall()
+80
View File
@@ -0,0 +1,80 @@
"""Read-only access to imported source GIS attributes."""
from typing import Any
from psycopg import AsyncConnection
class GisAttributeRepository:
@staticmethod
async def list_element_groups(
conn: AsyncConnection,
element_type: str,
element_id: str,
) -> list[dict[str, Any]]:
async with conn.cursor() as cur:
await cur.execute(
"SELECT to_regclass('source_gis.element_links') AS relation"
)
availability = await cur.fetchone()
if not availability or availability["relation"] is None:
return []
await cur.execute(
"""
SELECT
dataset.dataset_key,
dataset.title AS dataset_title,
dataset.source_date,
layer.layer_key,
layer.display_name AS layer_name,
feature.source_fid,
link.match_method,
link.distance_m AS match_distance_m,
feature.attributes
FROM source_gis.element_links AS link
JOIN source_gis.features AS feature
ON feature.layer_key = link.layer_key
AND feature.source_fid = link.source_fid
JOIN source_gis.layers AS layer
ON layer.layer_key = feature.layer_key
JOIN source_gis.datasets AS dataset
ON dataset.dataset_key = layer.dataset_key
WHERE link.element_type = %s
AND link.element_id = %s
ORDER BY dataset.source_date DESC, layer.display_name, feature.source_fid
""",
(element_type, element_id),
)
return [dict(row) for row in await cur.fetchall()]
@staticmethod
async def get_source_feature_group(
conn: AsyncConnection,
layer_key: str,
source_fid: int,
) -> dict[str, Any] | None:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT
dataset.dataset_key,
dataset.title AS dataset_title,
dataset.source_date,
layer.layer_key,
layer.display_name AS layer_name,
feature.source_fid,
'source' AS match_method,
NULL::double precision AS match_distance_m,
feature.attributes
FROM source_gis.features AS feature
JOIN source_gis.layers AS layer
ON layer.layer_key = feature.layer_key
JOIN source_gis.datasets AS dataset
ON dataset.dataset_key = layer.dataset_key
WHERE feature.layer_key = %s
AND feature.source_fid = %s
""",
(layer_key, source_fid),
)
row = await cur.fetchone()
return dict(row) if row else None
@@ -24,6 +24,8 @@ def _placement_row(row: dict[str, Any]) -> dict[str, Any]:
"created_by": row["created_by"],
"created_at": row["created_at"],
"status": row["status"],
"sensor_type": payload.get("sensor_type", "pressure"),
"method": payload.get("method"),
"sensor_locations": locations,
}
@@ -57,11 +59,15 @@ def create_sensor_placement(
min_diameter: int,
created_by: str,
sensor_locations: list[str],
sensor_type: str = "pressure",
method: str | None = None,
) -> dict[str, Any]:
run_id = uuid4()
payload = {
"sensor_number": len(sensor_locations),
"minimum_diameter": min_diameter,
"sensor_type": sensor_type,
"method": method,
"sensor_locations": sensor_locations,
}
with project_connection(name) as conn, conn.transaction():
+88
View File
@@ -0,0 +1,88 @@
"""PostgreSQL persistence for task-produced alert events and current state."""
from __future__ import annotations
from contextlib import asynccontextmanager
from psycopg import AsyncConnection
from psycopg.errors import UndefinedTable
from psycopg.types.json import Jsonb
from app.domain.domain_errors import DomainError
class TaskAlertRepository:
def __init__(self, connection: AsyncConnection, project_id):
self.connection = connection
self.project_id = str(project_id)
@asynccontextmanager
async def transaction(self):
try:
async with self.connection.transaction():
yield self
except UndefinedTable as exc:
raise DomainError(
"DEPENDENCY_UNAVAILABLE",
"Project alert storage is not provisioned",
status=503,
) from exc
async def lock_alert(self, alert_id: str) -> None:
await self.connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
(f"tjwater-alert:{self.project_id}:{alert_id}",),
)
async def get_event(self, producer_id: str, event_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.alert_events
WHERE project_id=%s AND producer_id=%s AND event_id=%s""",
(self.project_id, producer_id, event_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def get_alert(self, alert_id: str) -> tuple[int, dict] | None:
cursor = await self.connection.execute(
"""SELECT revision,document FROM workbench.alerts
WHERE project_id=%s AND alert_id=%s""",
(self.project_id, alert_id),
)
row = await cursor.fetchone()
return (row["revision"], row["document"]) if row else None
async def save_event(self, event: dict, resulting_state: dict) -> None:
document = event | {"resulting_state": resulting_state}
await self.connection.execute(
"""INSERT INTO workbench.alert_events
(project_id,producer_id,event_id,alert_id,event_type,occurred_at,document)
VALUES (%s,%s,%s,%s,%s,%s,%s)""",
(
self.project_id,
event["producer_id"],
event["event_id"],
event["alert_id"],
event["event_type"],
event["occurred_at"],
Jsonb(document),
),
)
async def save_alert(self, state: dict) -> None:
await self.connection.execute(
"""INSERT INTO workbench.alerts
(project_id,alert_id,revision,status,occurred_at,updated_at,document)
VALUES (%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(project_id,alert_id) DO UPDATE SET
revision=excluded.revision,status=excluded.status,
updated_at=excluded.updated_at,document=excluded.document""",
(
self.project_id,
state["alert_id"],
state["revision"],
state["status"],
state["occurred_at"],
state["updated_at"],
Jsonb({key: value for key, value in state.items() if key != "revision"}),
),
)
@@ -0,0 +1,70 @@
"""Optional project-local schedule, work-order and alert projections."""
from dataclasses import dataclass
from typing import Any
from psycopg import AsyncConnection
@dataclass(frozen=True)
class WorkbenchOperationsAvailability:
work_orders: bool
alerts: bool
schedules: bool = False
class WorkbenchOperationsRepository:
"""Read provisioned workbench records without creating or mutating storage."""
@staticmethod
async def availability(
conn: AsyncConnection,
) -> WorkbenchOperationsAvailability:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT
to_regclass('workbench.work_orders') IS NOT NULL AS work_orders,
to_regclass('workbench.alerts') IS NOT NULL AS alerts
"""
)
row = await cur.fetchone()
return WorkbenchOperationsAvailability(
work_orders=bool(row and row["work_orders"]),
alerts=bool(row and row["alerts"]),
)
@staticmethod
async def list_work_orders(
conn: AsyncConnection, project_id: str, *, limit: int = 100
) -> list[dict[str, Any]]:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT document
FROM workbench.work_orders
WHERE project_id = %s
ORDER BY updated_at DESC, work_order_id
LIMIT %s
""",
(project_id, limit),
)
return [row["document"] for row in await cur.fetchall()]
@staticmethod
async def list_alerts(
conn: AsyncConnection, project_id: str, *, limit: int = 100
) -> list[dict[str, Any]]:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT document
FROM workbench.alerts
WHERE project_id = %s
AND status = 'active'
ORDER BY occurred_at DESC, alert_id
LIMIT %s
""",
(project_id, limit),
)
return [row["document"] for row in await cur.fetchall()]
+83
View File
@@ -94,6 +94,89 @@ class GeoServerAdminClient:
)
return True
def feature_type_exists(
self,
*,
workspace: str,
datastore: str,
layer: str,
) -> bool:
response = self._client.get(
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}.json"
)
if response.status_code == 404:
return False
if response.is_error:
raise GeoServerProvisioningError(
f"GeoServer feature type check failed with HTTP {response.status_code}"
)
return True
def publish_feature_type(
self,
*,
workspace: str,
datastore: str,
layer: str,
title: str,
map_bbox: tuple[float, float, float, float],
) -> None:
minx, miny, maxx, maxy = map_bbox
payload = {
"featureType": {
"name": layer,
"nativeName": layer,
"title": title,
"srs": "EPSG:3857",
"projectionPolicy": "FORCE_DECLARED",
"enabled": True,
"advertised": True,
"nativeBoundingBox": {
"minx": minx,
"miny": miny,
"maxx": maxx,
"maxy": maxy,
"crs": "EPSG:3857",
},
"latLonBoundingBox": {
"minx": _longitude(minx),
"miny": _latitude(miny),
"maxx": _longitude(maxx),
"maxy": _latitude(maxy),
"crs": "EPSG:4326",
},
}
}
if self.feature_type_exists(
workspace=workspace,
datastore=datastore,
layer=layer,
):
self._request(
"PUT",
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}",
json=payload,
)
else:
self._request(
"POST",
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes",
json=payload,
)
self._set_client_cache(workspace, layer)
def truncate_layer_cache(self, workspace: str, layer: str) -> None:
self._request(
"POST",
"/gwc/rest/masstruncate",
content=(
"<truncateLayer><layerName>"
f"{workspace}:{layer}"
"</layerName></truncateLayer>"
),
headers={"Content-Type": "text/xml"},
)
def create_project_workspace(
self,
*,
+3 -2
View File
@@ -28,8 +28,9 @@ async def lifespan(app: FastAPI):
logger.info("TJWater CloudService is starting...")
logger.info("**********************************************************")
try:
yield
# 清理资源
finally:
await project_connection_manager.close_all()
close_all_timescale_pools()
close_all_project_pools()
@@ -55,7 +56,7 @@ app = FastAPI(
# Include Routers
app.include_router(api_router, prefix="/api/v1")
install_problem_details_handlers(app)
# Legcy Routers without version prefix
# Legacy routers without version prefix
# app.include_router(api_router)
# 配置中间件
+18
View File
@@ -0,0 +1,18 @@
"""RFC 8785 canonical JSON hashing shared by persisted records."""
from hashlib import sha256
import rfc8785
from app.domain.domain_errors import DomainError
def canonical(value: object) -> bytes:
try:
return rfc8785.dumps(value)
except rfc8785.CanonicalizationError as exc:
raise DomainError("SCHEMA_INVALID", "Content is not canonicalizable JSON") from exc
def digest(value: object) -> str:
return "sha256:" + sha256(canonical(value)).hexdigest()
+10
View File
@@ -80,6 +80,8 @@ def _create_validated_placement(
min_diameter: int,
created_by: str,
sensor_locations: list[str],
sensor_type: str = "pressure",
method: str | None = None,
) -> dict[str, Any]:
validate_sensor_placement_nodes(project_code, sensor_locations)
return sensor_placement_repository.create_sensor_placement(
@@ -88,6 +90,8 @@ def _create_validated_placement(
min_diameter=min_diameter,
created_by=created_by,
sensor_locations=sensor_locations,
sensor_type=sensor_type,
method=method,
)
@@ -97,6 +101,7 @@ def optimize_sensor_placement_by_sensitivity(
sensor_count: int,
min_diameter: int,
created_by: str,
sensor_type: str = "pressure",
) -> dict[str, Any]:
"""Run sensitivity placement and persist the validated result."""
@@ -112,6 +117,8 @@ def optimize_sensor_placement_by_sensitivity(
min_diameter=min_diameter,
created_by=created_by,
sensor_locations=sensor_locations,
sensor_type=sensor_type,
method="sensitivity",
)
@@ -121,6 +128,7 @@ def optimize_sensor_placement_by_kmeans(
sensor_count: int,
min_diameter: int,
created_by: str,
sensor_type: str = "pressure",
) -> dict[str, Any]:
"""Export the model, run K-means placement, and persist the result."""
@@ -136,6 +144,8 @@ def optimize_sensor_placement_by_kmeans(
min_diameter=min_diameter,
created_by=created_by,
sensor_locations=sensor_locations,
sensor_type=sensor_type,
method="kmeans",
)
+124
View File
@@ -0,0 +1,124 @@
"""Monotonic, idempotent state machine for task-produced alerts."""
from __future__ import annotations
from datetime import datetime, timezone
from app.domain.schemas.task_alert import TaskAlertEvent, TaskAlertState
from app.domain.domain_errors import require
from app.services.content_digest import digest
class TaskAlertService:
def __init__(
self,
repository,
context,
*,
clock=None,
internal_producer_id: str | None = None,
):
self.repo = repository
self.context = context
self.clock = clock or (lambda: datetime.now(timezone.utc))
self.internal_producer_id = internal_producer_id
async def push(self, command: TaskAlertEvent) -> TaskAlertState:
if self.internal_producer_id is None:
require(
self.context is not None and self.context.project_role == "member",
"PERMISSION_DENIED",
"Alert events require project write permission",
status=403,
)
require(
command.producer_id == f"principal:{self.context.user_id}",
"PERMISSION_DENIED",
"Alert producer must match the authenticated project principal",
status=403,
)
else:
require(
command.producer_id == self.internal_producer_id,
"PERMISSION_DENIED",
"Internal alert producer identity does not match",
status=403,
)
event = command.model_dump(mode="json")
async with self.repo.transaction():
await self.repo.lock_alert(command.alert_id)
prior_event = await self.repo.get_event(
command.producer_id, str(command.event_id)
)
if prior_event is not None:
prior_command = {
key: value for key, value in prior_event.items()
if key != "resulting_state"
}
require(
digest(prior_command) == digest(event),
"IDEMPOTENCY_CONFLICT",
"event_id was used with a different alert event",
status=409,
)
return TaskAlertState.model_validate(prior_event["resulting_state"])
current = await self.repo.get_alert(command.alert_id)
state = self._transition(command, current)
state_document = state.model_dump(mode="json")
await self.repo.save_event(event, state_document)
await self.repo.save_alert(state_document)
return state
def _transition(
self, command: TaskAlertEvent, current: tuple[int, dict] | None
) -> TaskAlertState:
now = self.clock()
require(now.tzinfo is not None, "SCHEMA_INVALID", "Alert clock must be timezone-aware")
if current is None:
require(command.event_type == "raised", "INVALID_TRANSITION",
"An alert must be raised before it can change state", status=409)
revision = 1
status = "active"
else:
revision, document = current
require(document.get("producer_id") == command.producer_id,
"PERMISSION_DENIED", "Only the originating producer can update an alert", status=403)
require(document.get("source") == command.source.model_dump(mode="json"),
"SCOPE_MISMATCH", "Alert source cannot change", status=409)
require(
all(
document.get(field) == event_value
for field, event_value in {
"condition_id": command.condition_id,
"title": command.title,
"description": command.description,
"occurred_at": command.model_dump(mode="json")["occurred_at"],
"severity": command.severity,
}.items()
),
"IDEMPOTENCY_CONFLICT",
"Alert facts cannot change after the alert is raised",
status=409,
)
allowed = {
("active", "acknowledged"): "acknowledged",
("active", "resolved"): "resolved",
("acknowledged", "resolved"): "resolved",
}
status = allowed.get((document["status"], command.event_type))
require(status is not None, "INVALID_TRANSITION",
"Alert state transition is not permitted", status=409)
revision += 1
return TaskAlertState(
alert_id=command.alert_id,
revision=revision,
producer_id=command.producer_id,
condition_id=command.condition_id,
source=command.source,
title=command.title,
description=command.description,
occurred_at=command.occurred_at,
severity=command.severity,
status=status,
updated_at=now,
)
+390
View File
@@ -0,0 +1,390 @@
"""Project persisted runtime state into the read-only supply workbench feed."""
from collections.abc import Callable
from datetime import datetime, timezone
from math import isfinite
from typing import Any
from app.domain.schemas.workbench_runtime import (
WorkbenchPersistedAlert,
WorkbenchRuntimeWorkOrder,
)
_ANALYSIS_TYPE_LABELS = {
"burst_analysis": "爆管分析",
"burst_detection": "爆管检测",
"burst_location": "爆管定位",
"contaminant_analysis": "污染物分析",
"contaminant_simulation": "污染物模拟",
"dma_leak_identification": "DMA 漏损识别",
"flushing_analysis": "冲洗分析",
"pressure_regulation": "压力调控",
"sensor_placement": "传感器布点",
"valve_close_analysis": "关阀分析",
}
_ANALYSIS_STATUS_LABELS = {
"queued": "排队中",
"pending": "等待中",
"running": "运行中",
"completed": "已完成",
"succeeded": "已成功",
"failed": "已失败",
"error": "异常",
"cancelled": "已取消",
}
async def build_workbench_runtime_snapshot(
*,
analysis_runs: list[dict[str, Any]] | None = None,
latest_analysis_results: list[dict[str, Any]] | None = None,
latest_analysis_map_payload: dict[str, Any] | None = None,
work_order_storage_configured: bool = False,
work_orders: list[dict[str, Any]] | None = None,
alert_storage_configured: bool = False,
persisted_alerts: list[dict[str, Any]] | None = None,
scenario_availability: str | None = None,
artifact_availability: str | None = None,
schedule_availability: str | None = None,
work_order_availability: str | None = None,
alert_availability: str | None = None,
projection_degraded: bool = False,
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> dict[str, Any]:
persisted_analysis_runs = (analysis_runs or [])[:100]
scenarios = [project_analysis_scenario(run) for run in persisted_analysis_runs[:20]]
latest_analysis = (
project_latest_analysis(
persisted_analysis_runs[0],
latest_analysis_results or [],
referenced_map_payload=latest_analysis_map_payload,
)
if persisted_analysis_runs
else None
)
persisted_work_orders = [
WorkbenchRuntimeWorkOrder.model_validate(item).model_dump()
for item in (
(work_orders or [])[:100]
if work_order_storage_configured
else []
)
]
stored_alerts = [
WorkbenchPersistedAlert.model_validate(item)
for item in (
(persisted_alerts or [])[:100]
if alert_storage_configured
else []
)
]
active_stored_alerts = [
{
"id": f"backend-alert-{alert.alert_id}",
"condition_id": alert.condition_id,
"source": alert.source.model_dump(),
"title": alert.title,
"description": alert.description,
"occurred_at": alert.occurred_at,
"severity": alert.severity,
}
for alert in stored_alerts
if alert.status == "active"
]
projected_alerts = active_stored_alerts
projected_alerts.sort(
key=lambda alert: _timestamp(alert["occurred_at"]),
reverse=True,
)
source = "none"
resolved_scenario_availability = scenario_availability or (
"ready" if scenarios else "not_configured"
)
resolved_artifact_availability = artifact_availability or (
"ready" if latest_analysis else "not_configured"
)
resolved_schedule_availability = schedule_availability or "not_configured"
resolved_work_order_availability = work_order_availability or (
"ready" if work_order_storage_configured else "not_configured"
)
resolved_alert_availability = alert_availability or (
"ready" if alert_storage_configured else "not_configured"
)
section_availabilities = (
resolved_scenario_availability,
resolved_artifact_availability,
resolved_schedule_availability,
resolved_work_order_availability,
resolved_alert_availability,
)
return {
"availability": (
"degraded"
if projection_degraded or "degraded" in section_availabilities
else
"ready"
if (
source != "none"
or work_order_storage_configured
or alert_storage_configured
)
else "not_configured"
),
"source": source,
"scenario_availability": resolved_scenario_availability,
"artifact_availability": resolved_artifact_availability,
"schedule_availability": resolved_schedule_availability,
"work_order_availability": resolved_work_order_availability,
"alert_availability": resolved_alert_availability,
"generated_at": clock(),
"conditions": [],
"alerts": projected_alerts,
"scenarios": scenarios,
"latest_analysis": latest_analysis,
"work_orders": persisted_work_orders,
}
def project_analysis_scenario(run: dict[str, Any]) -> dict[str, Any]:
run_id = str(run["run_id"])
run_type = str(run.get("run_type") or "analysis")
run_status = str(run.get("status") or "unknown").lower()
type_label = _analysis_type_label(run_type)
return {
"id": f"analysis-run-{run_id}",
"run_id": run_id,
"name": str(run.get("name") or type_label),
"description": (
f"{type_label} · {_analysis_status_label(run_status)} · "
f"创建人 {run.get('created_by') or 'unknown'}"
),
"status": _analysis_scenario_state(run_status),
"run_type": run_type,
"run_type_label": type_label,
"run_status": run_status,
"created_at": run["created_at"],
}
def project_analysis_history_item(run: dict[str, Any]) -> dict[str, Any]:
run_type = str(run.get("run_type") or "analysis")
run_status = str(run.get("status") or "unknown").lower()
created_at = run["created_at"]
return {
"run_id": str(run["run_id"]),
"name": str(run.get("name") or _analysis_type_label(run_type)),
"run_type": run_type,
"run_type_label": _analysis_type_label(run_type),
"status": run_status,
"status_label": _analysis_status_label(run_status),
"created_by": str(run.get("created_by") or "unknown"),
"created_at": created_at,
"started_at": run.get("started_at") or created_at,
"has_results": bool(run.get("has_results")),
}
def project_latest_analysis(
run: dict[str, Any],
results: list[dict[str, Any]],
*,
referenced_map_payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
run_type = str(run.get("run_type") or "analysis")
run_status = str(run.get("status") or "unknown").lower()
created_at = run["created_at"]
return {
"run_id": str(run["run_id"]),
"name": str(run.get("name") or _analysis_type_label(run_type)),
"run_type": run_type,
"run_type_label": _analysis_type_label(run_type),
"status": run_status,
"status_label": _analysis_status_label(run_status),
"created_by": str(run.get("created_by") or "unknown"),
"created_at": created_at,
"started_at": run.get("started_at") or created_at,
"results": [
{
"result_type": str(result.get("result_type") or "analysis_result"),
"created_at": result.get("created_at") or created_at,
"facts": _analysis_result_facts(result),
}
for result in results
],
"map_payload": _analysis_map_payload(results) or referenced_map_payload,
}
def sensor_location_ids_for_map_projection(
results: list[dict[str, Any]],
) -> list[str]:
if _analysis_map_payload(results) is not None:
return []
for result in reversed(results):
if result.get("result_type") != "sensor_placement":
continue
payload = result.get("payload")
if not isinstance(payload, dict):
continue
locations = _non_empty_text_list(payload.get("sensor_locations"))
if locations:
return list(dict.fromkeys(locations))[:500]
return []
def project_sensor_location_map_payload(
node_ids: list[str], map_points: list[dict[str, Any]]
) -> dict[str, Any] | None:
points_by_id = {
str(point.get("node_id")): point
for point in map_points
if point.get("node_id") is not None
}
features: list[dict[str, Any]] = []
for position, node_id in enumerate(node_ids, start=1):
point = points_by_id.get(node_id)
if point is None:
continue
longitude = point.get("longitude")
latitude = point.get("latitude")
if (
isinstance(longitude, bool)
or isinstance(latitude, bool)
or not isinstance(longitude, (int, float))
or not isinstance(latitude, (int, float))
or not isfinite(float(longitude))
or not isfinite(float(latitude))
):
continue
features.append({
"type": "Feature",
"id": node_id,
"properties": {
"label": f"监测点 {position} · {node_id}",
"node_id": node_id,
"result_type": "sensor_placement",
"position": position,
},
"geometry": {
"type": "Point",
"coordinates": [float(longitude), float(latitude)],
},
})
if not features:
return None
return {"type": "FeatureCollection", "features": features}
def _analysis_result_facts(result: dict[str, Any]) -> list[dict[str, str]]:
facts: list[dict[str, str]] = []
node_id = _non_empty_text(result.get("node_id"))
link_id = _non_empty_text(result.get("link_id"))
if node_id:
facts.append({"label": "关联节点", "value": node_id})
if link_id:
facts.append({"label": "关联管段", "value": link_id})
payload = result.get("payload")
if not isinstance(payload, dict):
return facts
result_type = str(result.get("result_type") or "")
if result_type == "sensor_placement":
locations = _non_empty_text_list(payload.get("sensor_locations"))
sensor_count = _finite_number_text(payload.get("sensor_number"))
if sensor_count is None and locations:
sensor_count = str(len(locations))
if sensor_count is not None:
facts.append({"label": "测点数量", "value": sensor_count})
minimum_diameter = _finite_number_text(payload.get("minimum_diameter"))
if minimum_diameter is not None:
facts.append({"label": "最小管径参数", "value": minimum_diameter})
if locations:
facts.append({
"label": "测点位置",
"value": _summarize_identifiers(locations),
})
elif result_type == "leakage_identification":
run_status = _non_empty_text(payload.get("run_status"))
if run_status:
facts.append({"label": "结果状态", "value": run_status})
sensor_nodes = _non_empty_text_list(payload.get("sensor_nodes"))
if sensor_nodes:
facts.append({"label": "传感器节点数", "value": str(len(sensor_nodes))})
rows = payload.get("rows")
if isinstance(rows, list):
facts.append({"label": "结果行数", "value": str(len(rows))})
areas = payload.get("areas")
if isinstance(areas, list):
facts.append({"label": "分区数量", "value": str(len(areas))})
return facts
def _non_empty_text(value: object) -> str | None:
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized or None
def _non_empty_text_list(value: object) -> list[str]:
if not isinstance(value, list):
return []
return [text for item in value if (text := _non_empty_text(item)) is not None]
def _finite_number_text(value: object) -> str | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
if isinstance(value, float) and not isfinite(value):
return None
return str(value)
def _summarize_identifiers(values: list[str], limit: int = 12) -> str:
visible = values[:limit]
suffix = f" 等 {len(values)} 个" if len(values) > limit else ""
return "、".join(visible) + suffix
def _analysis_map_payload(
results: list[dict[str, Any]],
) -> dict[str, Any] | None:
for result in reversed(results):
payload = result.get("payload")
if not isinstance(payload, dict):
continue
candidate = payload.get("drawing_payload")
if not isinstance(candidate, dict):
candidate = payload
if (
candidate.get("type") == "FeatureCollection"
and isinstance(candidate.get("features"), list)
and candidate["features"]
):
return candidate
return None
def _analysis_type_label(run_type: str) -> str:
return _ANALYSIS_TYPE_LABELS.get(run_type, run_type)
def _analysis_status_label(status: str) -> str:
return _ANALYSIS_STATUS_LABELS.get(status, status)
def _analysis_scenario_state(status: str) -> str:
if status == "running":
return "active"
if status in {"completed", "succeeded", "failed", "error"}:
return "review"
return "draft"
def _timestamp(value: datetime | str) -> float:
parsed = (
value
if isinstance(value, datetime)
else datetime.fromisoformat(value.replace("Z", "+00:00"))
)
return parsed.timestamp()
+64
View File
@@ -0,0 +1,64 @@
# 外围子系统合成样本
仅供演示,所有数据标为 `synthetic`,所有对象使用 `DEMO-` 前缀。它们不映射真实资产,不写入 `asset/network/scada`,不生成正式工单,不控制设备。
## 内容与复现
纯函数 `samples/peripheral/demo_data.py::build_demo_content` 接收种子和带时区的固定起点,返回结构化数据。相同生成器版本、种子、归一化后的 UTC 起点产生相同记录、UUID 和 JCS SHA-256;不依赖当前时间、进程随机状态或数据库内容。生成器升级必须换版本,不能原地悄改 v1 的行为。
默认版本 `water-demo@2`,种子 `tjwater-peripheral-demo-20260917`;北京时间 2026-09-17 08:00 至 20:00,15 分钟一条,共 48 个时点。
| 内容 | 数量及情节 |
| --- | --- |
| 测点 | 3 个压力点、1 个流量点、1 个液位点 |
| 读数 | 240 条;含 4 条缺测 null、4 条真实零值,保留各自质量标记 |
| 设备状态 | 9 条变化;通信离线与恢复、泵运行/停止、阀门开/关 |
| 告警 | 2 个预置事件,含发生与解除时间;不是从真实数据推断出的故障 |
| 运行记录 | 4 条泵阀历史事件;不是可执行命令或真实设备回执 |
目录 `status=unknown` 不代表设备当前在线。历史状态必须按 `device_states.observed_at` 解读,不能将固定演示时点当成当前现场状态。
本数据压力单位为 m,不自动转换或送入专业分析。
## 独立 PG 存储
用户已授权外围样板使用独立 schema。仅在业务端 `tjwater_v2` 的已有 `peripheral_sample` 中新增一张表,不改其他表。由外部供给流程在事务中执行一次;应用没有建表或生成接口,也没有自动 seed。
```sql
CREATE TABLE peripheral_sample.demo_datasets (
project_id uuid NOT NULL,
dataset_id text NOT NULL,
digest text NOT NULL CHECK (digest ~ '^sha256:[0-9a-f]{64}$'),
document jsonb NOT NULL,
PRIMARY KEY (project_id,dataset_id),
CHECK ((jsonb_typeof(document) = 'object' AND document->>'origin' = 'synthetic'
AND document->>'generator_version' = 'water-demo@2') IS TRUE)
);
```
登记 `dataset_id=water-demo-v1` 时,先生成并验证 DemoContent、计算规范化摘要。已有同编号记录时只允许核对相同摘要,不覆盖;改变种子或生成器应使用新版本/新编号并另行调整读取配置。样本本身永久保存,读取不会重新生成或写入。
经明确授权需要为某个开发项目填充外围样板时,可运行:
```bash
conda run -n server python scripts/seed_peripheral_sample.py --project-id <project-uuid>
```
脚本使用固定 UUIDv5 请求号和专用 synthetic actor,重复执行会读取原幂等回执,不重复创建。它核验或补充 `water-demo-v1`,并创建 3 张测试工单和 12 条带 `DEMO-` 前缀的可编辑测试数据;不创建缺少合法 Action 来源的外围核查任务,不写正式工单、资产或监测表。
## 演示历史窗口配置
外围面板的“历史监测回放”读取现有项目历史监测源,与上述 `water-demo-v1` 合成数据集是两个独立区域。前端可通过 `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE=demo` 默认使用已知有数据的固定窗口,并用 `TJWATER_PERIPHERAL_SAMPLE_DEMO_START`、`TJWATER_PERIPHERAL_SAMPLE_DEMO_END` 配置开始和结束时间;切到 `recent` 时按 `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` 查询最近时段。界面仍允许在运行时切换演示、最近和自定义窗口。
本地默认演示窗口为 UTC 2026-04-30 08:00 至 16:00。它只控制历史监测查询,不改变 `water-demo-v1` 自身的 UTC 2026-09-17 00:00 至 12:00 固定内容。
## 接口与页面
`GET /api/sample/v1/demo-data` 复用 Next 项目身份和读取权限,按项目查 PG 后核验摘要与模型,返回 project_id、source_id、source_revision、固定时间窗和样本。无记录返回 SOURCE_NOT_CONFIGURED,不回退到浏览器造数据;服务不可用演练仍按既有场景拒绝读取。
原历史监测接口不变,真实告警源仍然是 `not_configured`;合成告警只出现在单独的合成样本区域,不能宣称真实告警接口已经接通。
前端在“外围样板”中单独读取合成样本,展示版本、种子和摘要,以及读数、设备状态、告警和运行历史。加载失败不会清空或阻止既有核查任务及历史回放。
核查任务、反馈仍需用户依据已保存来源显式提交。生成器不会伪造 Next 来源、现场反馈或业务审批。
已审核反馈现在可经人工确认[导入独立新证据](field-feedback-import.md),再另行确认建立[人工复核草稿修订](feedback-review-revisions.md)。专业重分析不在本次已确认的样板反馈范围内;生成器不会自动导入或修订成果。
+15
View File
@@ -0,0 +1,15 @@
# 外围样板服务
该独立 FastAPI 服务提供历史监测回放、固定合成数据,以及 synthetic 测试工单和测试数据的版本化 CRUD。管理入口位于供水工作台“外围样板”。它不控制设备,也不修改正式业务资产。
浏览器通过 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` 连接样板服务。服务复用主系统的 Bearer 身份和 `X-Project-Id`,读取需要 `simulation.view`,写入需要 `simulation.run`。
主要接口包括:
- `/api/sample/v1/context` 与 `/scenario`
- `/api/sample/v1/work-orders`
- `/api/sample/v1/test-data`
- `/api/sample/v1/monitoring/points`、`observations`、`alerts`
- `/api/sample/v1/demo-data`
演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。
+20
View File
@@ -0,0 +1,20 @@
# 工作台工单与告警存储契约
工作台只聚合预约工单与任务主动推送的告警。浏览器不推导告警,也不写正式工单。
## 任务告警
DDL 位于 `resources/sql/008_workbench_alert_events.sql`。任务通过 `POST /api/v1/workbench/task-alert-events` 推送 `raised`、`acknowledged` 或 `resolved` 事件。来源包含 `task_type`、`task_id` 和 `run_id`,生产者由认证项目身份绑定。`event_id` 在项目和生产者范围内幂等,告警事实在首次 raised 后不可改写。
## 预约工单
`workbench.work_orders` 的 DDL 位于 `resources/sql/011_workbench_work_orders.sql`。每行保存一个只读工单投影,文档必须满足 `WorkbenchRuntimeWorkOrder`:
- 一张主工单包含一个或多个 `subtasks`。
- 每个子任务保存自己的位置、班组、时间窗和复令要求。
- 每个子任务包含一个或多个有序 `stages`,`sequence` 从 1 连续递增,阶段编号在子任务内唯一,最多只有一个 `current` 阶段。
- 阶段名称不写死为固定枚举。演示数据使用派发、执行、复令,正式工单适配器可以按业务定义其他阶段。
演示数据可以先运行 `scripts/seed_peripheral_sample.py`,再运行 `scripts/seed_workbench_work_orders.py` 投影。工作台会显示一张主工单,三项外围核查分别作为子任务展示。
工作台运行账号对工单表只需 `SELECT`,同步账号使用独立受控写权限;对告警事件表需要 `SELECT, INSERT`,对告警状态表需要 `SELECT, INSERT, UPDATE`。
+10
View File
@@ -0,0 +1,10 @@
# 工作台运行态
`GET /api/v1/workbench/runtime` 按当前项目读取 BizDB 中的分析成果、预约工单和任务告警。工况任务继续由前端演示数据提供,不从后端运行记录或分析结果生成。
- `analysis.runs/results` 只用于分析成果、历史成果和兼容场景字段。
- `workbench.work_orders` 是预约工单的后端持久化投影。主工单包含多个子任务,每个子任务包含自己的有序阶段。表存在时,空列表同样是权威结果,不回退到 mock 工单。
- `workbench.alert_events` 保存任务显式推送的事件,`workbench.alerts` 保存当前状态。分析结果、测点值和缺测状态不会自动变成告警。
- 每个可选分区独立读取并返回 `ready`、`not_configured` 或 `degraded`,一个分区失败不会污染其他分区。
演示工单由 `scripts/seed_peripheral_sample.py` 写入 `peripheral_sample`,再由 `scripts/seed_workbench_work_orders.py` 将三项外围核查组合成一张主工单和三个子任务,写入 `workbench.work_orders`。当前固定演示日期为 2026-09-17。
+1
View File
@@ -126,6 +126,7 @@ pyzmq==26.2.1
reactivex==4.0.4
referencing==0.36.2
requests==2.32.3
rfc8785==0.1.4
rich==14.2.0
rich-rst==1.3.2
rpds-py==0.30.0
@@ -0,0 +1,49 @@
-- Append-only task alert events plus a monotonic current-state projection.
BEGIN;
CREATE SCHEMA IF NOT EXISTS workbench;
CREATE TABLE IF NOT EXISTS workbench.alert_events (
project_id uuid NOT NULL,
producer_id text NOT NULL CHECK (length(producer_id) > 0),
event_id uuid NOT NULL,
alert_id text NOT NULL CHECK (length(alert_id) > 0),
event_type text NOT NULL CHECK (event_type IN ('raised','acknowledged','resolved')),
occurred_at timestamptz NOT NULL,
ingested_at timestamptz NOT NULL DEFAULT now(),
document jsonb NOT NULL,
PRIMARY KEY (project_id,producer_id,event_id),
CHECK ((jsonb_typeof(document)='object'
AND document->>'producer_id'=producer_id
AND document->>'event_id'=event_id::text
AND document->>'alert_id'=alert_id
AND document->>'event_type'=event_type
AND (document->>'occurred_at')::timestamptz=occurred_at) IS TRUE)
);
CREATE INDEX IF NOT EXISTS alert_events_alert_idx
ON workbench.alert_events(project_id,alert_id,occurred_at,event_id);
CREATE TABLE IF NOT EXISTS workbench.alerts (
project_id uuid NOT NULL,
alert_id text NOT NULL CHECK (length(alert_id) > 0),
revision bigint NOT NULL CHECK (revision >= 1),
status text NOT NULL CHECK (status IN ('active','acknowledged','resolved')),
occurred_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (project_id,alert_id),
CHECK ((jsonb_typeof(document)='object'
AND document->>'alert_id'=alert_id
AND document->>'status'=status
AND (document->>'occurred_at')::timestamptz=occurred_at) IS TRUE)
);
CREATE INDEX IF NOT EXISTS alerts_active_recent_idx
ON workbench.alerts(project_id,occurred_at DESC,alert_id)
WHERE status='active';
REVOKE ALL ON ALL TABLES IN SCHEMA workbench FROM PUBLIC;
COMMIT;
@@ -0,0 +1,12 @@
-- Bound and index the persisted analysis reads used by the workbench and API.
-- Safe to re-run after analysis.runs and analysis.results are provisioned.
BEGIN;
CREATE INDEX IF NOT EXISTS runs_created_at_run_id_idx
ON analysis.runs(created_at DESC, run_id);
CREATE INDEX IF NOT EXISTS results_run_created_at_result_id_idx
ON analysis.results(run_id, created_at DESC, result_id DESC);
COMMIT;
@@ -0,0 +1,74 @@
CREATE SCHEMA IF NOT EXISTS source_gis;
CREATE TABLE IF NOT EXISTS source_gis.datasets (
dataset_key TEXT PRIMARY KEY,
title TEXT NOT NULL,
source_date DATE NOT NULL,
declared_srid INTEGER,
effective_srid INTEGER NOT NULL,
coordinate_note TEXT NOT NULL,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS source_gis.layers (
layer_key TEXT PRIMARY KEY,
dataset_key TEXT NOT NULL REFERENCES source_gis.datasets(dataset_key) ON DELETE CASCADE,
display_name TEXT NOT NULL,
source_file TEXT NOT NULL,
feature_kind TEXT NOT NULL,
geometry_type TEXT NOT NULL,
feature_count BIGINT NOT NULL CHECK (feature_count >= 0),
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS source_gis.features (
layer_key TEXT NOT NULL REFERENCES source_gis.layers(layer_key) ON DELETE CASCADE,
source_fid BIGINT NOT NULL,
source_object_id TEXT,
source_keyword TEXT,
source_asset_id TEXT,
source_diameter DOUBLE PRECISION,
from_node_id TEXT,
to_node_id TEXT,
attributes JSONB NOT NULL CHECK (jsonb_typeof(attributes) = 'object'),
geom geometry(Geometry, 900914) NOT NULL,
PRIMARY KEY (layer_key, source_fid)
);
ALTER TABLE source_gis.features
ADD COLUMN IF NOT EXISTS source_diameter DOUBLE PRECISION;
CREATE TABLE IF NOT EXISTS source_gis.element_links (
element_type TEXT NOT NULL,
element_id TEXT NOT NULL,
layer_key TEXT NOT NULL,
source_fid BIGINT NOT NULL,
match_method TEXT NOT NULL,
distance_m DOUBLE PRECISION,
PRIMARY KEY (element_type, element_id, layer_key),
FOREIGN KEY (layer_key, source_fid)
REFERENCES source_gis.features(layer_key, source_fid)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_source_gis_layers_dataset
ON source_gis.layers(dataset_key);
CREATE INDEX IF NOT EXISTS idx_source_gis_features_object_id
ON source_gis.features(layer_key, source_object_id);
CREATE INDEX IF NOT EXISTS idx_source_gis_features_nodes
ON source_gis.features(layer_key, from_node_id, to_node_id);
CREATE INDEX IF NOT EXISTS idx_source_gis_features_nodes_reverse
ON source_gis.features(layer_key, to_node_id, from_node_id);
CREATE INDEX IF NOT EXISTS idx_source_gis_features_geom
ON source_gis.features USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_source_gis_element_links_lookup
ON source_gis.element_links(element_type, element_id);
COMMENT ON SCHEMA source_gis IS
'Imported source GIS inventories retained separately from the hydraulic model.';
COMMENT ON COLUMN source_gis.datasets.declared_srid IS
'SRID declared by the source file, which may be incorrect.';
COMMENT ON COLUMN source_gis.datasets.effective_srid IS
'SRID assigned after validating source coordinates against the project model.';
COMMENT ON COLUMN source_gis.features.attributes IS
'Original non-geometry GIS fields, including null values, preserved as JSONB.';
@@ -0,0 +1,28 @@
-- Read-only work-order projection synchronized by an external work-order source.
BEGIN;
CREATE SCHEMA IF NOT EXISTS workbench;
CREATE TABLE IF NOT EXISTS workbench.work_orders (
project_id uuid NOT NULL,
work_order_id text NOT NULL CHECK (length(work_order_id) > 0),
scheduled_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (project_id, work_order_id),
CHECK ((jsonb_typeof(document) = 'object'
AND document->>'id' = work_order_id
AND (document->>'scheduled_at')::timestamptz = scheduled_at
AND (document->>'updated_at')::timestamptz = updated_at) IS TRUE)
);
CREATE INDEX IF NOT EXISTS work_orders_project_schedule_idx
ON workbench.work_orders(project_id, scheduled_at, work_order_id);
CREATE INDEX IF NOT EXISTS work_orders_project_updated_idx
ON workbench.work_orders(project_id, updated_at DESC, work_order_id);
REVOKE ALL ON workbench.work_orders FROM PUBLIC;
COMMIT;
+13
View File
@@ -0,0 +1,13 @@
BEGIN;
ALTER TABLE peripheral_sample.demo_datasets
DROP CONSTRAINT IF EXISTS demo_datasets_document_check;
ALTER TABLE peripheral_sample.demo_datasets
ADD CONSTRAINT demo_datasets_document_check CHECK ((
jsonb_typeof(document) = 'object'
AND document->>'origin' = 'synthetic'
AND document->>'generator_version' IN ('water-demo@1', 'water-demo@2')
) IS TRUE);
COMMIT;
+122
View File
@@ -0,0 +1,122 @@
DO $$
DECLARE
layer RECORD;
BEGIN
FOR layer IN
SELECT *
FROM (VALUES
('fx_20200423_drain_valves', 'fx_20200423_drain_valves', 'Point'),
('fx_20200423_service_connections', 'fx_20200423_service_connections', 'Point'),
('fx_20200423_flow_meters', 'fx_20200423_flow_meters', 'Point'),
('fx_20200423_hydrants', 'fx_20200423_hydrants', 'Point'),
('fx_20200423_customer_meters', 'fx_20200423_customer_meters', 'Point'),
('fx_20200423_pipes', 'fx_20200423_pipes', 'LineString'),
('fx_20200423_valves', 'fx_20200423_valves', 'Point'),
('fx_20200423_non_residential_meters', 'fx_20200423_non_residential_meters', 'Point'),
('fx_20200811_dn100_pipes', 'fx_20200811_dn100_pipes', 'LineString')
) AS configured(view_name, layer_key, geometry_type)
LOOP
IF to_regclass(format('gis.%I', layer.view_name)) IS NULL THEN
EXECUTE format(
$view$
CREATE MATERIALIZED VIEW gis.%I AS
SELECT
feature.source_fid::text AS id,
feature.source_fid,
feature.source_object_id,
feature.source_diameter AS diameter,
CASE
WHEN GeometryType(feature.geom) IN ('LINESTRING', 'MULTILINESTRING')
THEN ST_Length(feature.geom)
ELSE NULL
END AS length,
dataset.dataset_key,
dataset.title AS dataset_title,
dataset.source_date,
source_layer.layer_key AS source_layer_key,
source_layer.display_name AS source_layer_name,
source_layer.feature_kind,
ST_Transform(feature.geom, 3857)::geometry(%s, 3857) AS geom
FROM source_gis.features AS feature
JOIN source_gis.layers AS source_layer
ON source_layer.layer_key = feature.layer_key
JOIN source_gis.datasets AS dataset
ON dataset.dataset_key = source_layer.dataset_key
WHERE feature.layer_key = %L
AND feature.geom IS NOT NULL
WITH DATA
$view$,
layer.view_name,
layer.geometry_type,
layer.layer_key
);
END IF;
EXECUTE format(
'CREATE UNIQUE INDEX IF NOT EXISTS %I ON gis.%I (id)',
'ux_' || layer.view_name || '_id',
layer.view_name
);
EXECUTE format(
'CREATE INDEX IF NOT EXISTS %I ON gis.%I USING GIST (geom)',
'ix_' || layer.view_name || '_geom',
layer.view_name
);
END LOOP;
END
$$;
DROP VIEW IF EXISTS gis.water_quality_monitoring_points;
CREATE VIEW gis.water_quality_monitoring_points AS
WITH latest_result AS (
SELECT
run.run_id,
run.name AS source_analysis,
run.created_at,
result.payload
FROM analysis.runs AS run
JOIN LATERAL (
SELECT stored.payload
FROM analysis.results AS stored
WHERE stored.run_id = run.run_id
AND stored.result_type = 'sensor_placement'
ORDER BY stored.created_at DESC, stored.result_id DESC
LIMIT 1
) AS result ON true
WHERE run.run_type = 'sensor_placement'
AND run.status = 'completed'
AND result.payload->>'sensor_type' = 'water_quality'
AND jsonb_typeof(result.payload->'sensor_locations') = 'array'
ORDER BY run.created_at DESC, run.run_id DESC
LIMIT 1
), ranked_locations AS (
SELECT
latest.run_id,
latest.source_analysis,
latest.created_at,
latest.payload,
location.node_id,
location.rank
FROM latest_result AS latest
CROSS JOIN LATERAL jsonb_array_elements_text(
latest.payload->'sensor_locations'
) WITH ORDINALITY AS location(node_id, rank)
)
SELECT
ranked.node_id AS id,
ranked.node_id,
ranked.rank::integer AS monitoring_point_no,
ranked.run_id,
ranked.source_analysis,
ranked.created_at,
ranked.payload->>'method' AS selection_method,
(ranked.payload->>'minimum_diameter')::integer AS minimum_diameter,
junction.elevation,
junction.geom
FROM ranked_locations AS ranked
JOIN gis.junctions AS junction
ON junction.id = ranked.node_id;
COMMENT ON VIEW gis.water_quality_monitoring_points IS
'Current water-quality monitoring points selected by the sensitivity service.';
+1
View File
@@ -0,0 +1 @@
"""Independent, explicitly simulated peripheral service; not a production workflow engine."""
+137
View File
@@ -0,0 +1,137 @@
"""Independent sample API for synthetic work orders and monitoring data."""
from typing import get_args
from uuid import UUID
from fastapi import Depends, FastAPI, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .auth import Identity, identity
from .config import SampleSettings
from .errors import SampleFailure
from .store import TRANSITIONS
from .schemas import (
AlertPage, Context, CreateTestDataRecord, CreateWorkOrder,
DeleteResource, DeletedResource, DemoDataset, Observations, PointPage, Receipt,
SampleError, Scenario, ScenarioCommand, ScenarioState, TestDataRecord,
TestDataRecordPage, UpdateTestDataRecord, UpdateWorkOrder, WorkOrder, WorkOrderPage,
)
def store(request: Request):
from .store import PostgresStore
return PostgresStore(request.app.state.settings)
def create_app(settings: SampleSettings | None = None) -> FastAPI:
app = FastAPI(title="TJWater 外围系统模拟接入", version="1.0.0",
description="测试监测与核查反馈样板,不是正式工单平台或设备控制服务。")
app.state.settings = settings or SampleSettings()
app.add_middleware(CORSMiddleware, allow_origins=app.state.settings.cors_origins,
allow_credentials=False, allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-Project-Id"])
@app.exception_handler(SampleFailure)
async def failure(request, exc):
return JSONResponse(status_code=exc.status, content=exc.payload())
@app.exception_handler(RequestValidationError)
async def invalid(request, exc):
return JSONResponse(status_code=422, content=SampleFailure("SCHEMA_INVALID", "请求字段不符合样板契约").payload())
@app.get("/health")
def health():
return {"service": "peripheral-sample", "origin": "synthetic", "storage": "postgresql",
"status": "up", "storage_verified": False}
prefix = "/api/sample/v1"
errors = {code: {"model": SampleError} for code in (401, 403, 404, 409, 422, 502, 503)}
@app.get(prefix + "/context", response_model=Context, responses=errors)
def context(request: Request, who: Identity = Depends(identity), db=Depends(store)):
return {"protocol_version": "tjwater-peripheral-sample@1", "origin": "synthetic",
"project_id": who.project_id, "user_id": who.user_id, "can_write": who.can_write,
"scenarios": list(get_args(Scenario)) if request.app.state.settings.enable_faults else ["normal"],
"state_transitions": TRANSITIONS, "managed_resources": db.managed_resource_capabilities()}
@app.get(prefix + "/scenario", response_model=ScenarioState, responses=errors)
def scenario(who: Identity = Depends(identity), db=Depends(store)):
return db.scenario(who)
@app.post(prefix + "/scenario", response_model=ScenarioState, responses=errors)
def configure_scenario(command: ScenarioCommand, who: Identity = Depends(identity), db=Depends(store)):
return db.set_scenario(who, command)
@app.get(prefix + "/requests/{request_id}", response_model=Receipt, responses=errors)
def receipt(request_id: UUID, who: Identity = Depends(identity), db=Depends(store)):
return db.receipt(who, request_id)
@app.get(prefix + "/work-orders", response_model=WorkOrderPage, responses=errors)
def work_orders(limit: int = Query(20, ge=1, le=100), cursor: UUID | None = None,
who: Identity = Depends(identity), db=Depends(store)):
return db.list_work_orders(who, limit, cursor)
@app.post(prefix + "/work-orders", response_model=WorkOrder, status_code=201, responses=errors)
def create_work_order(command: CreateWorkOrder, who: Identity = Depends(identity), db=Depends(store)):
return db.create_work_order(who, command)
@app.get(prefix + "/work-orders/{work_order_id}", response_model=WorkOrder, responses=errors)
def work_order(work_order_id: UUID, who: Identity = Depends(identity), db=Depends(store)):
return db.get_work_order(who, work_order_id)
@app.patch(prefix + "/work-orders/{work_order_id}", response_model=WorkOrder, responses=errors)
def update_work_order(work_order_id: UUID, command: UpdateWorkOrder,
who: Identity = Depends(identity), db=Depends(store)):
return db.update_work_order(who, work_order_id, command)
@app.delete(prefix + "/work-orders/{work_order_id}", response_model=DeletedResource, responses=errors)
def delete_work_order(work_order_id: UUID, command: DeleteResource,
who: Identity = Depends(identity), db=Depends(store)):
return db.delete_work_order(who, work_order_id, command)
@app.get(prefix + "/test-data", response_model=TestDataRecordPage, responses=errors)
def test_data(limit: int = Query(20, ge=1, le=100), cursor: UUID | None = None,
who: Identity = Depends(identity), db=Depends(store)):
return db.list_test_data(who, limit, cursor)
@app.post(prefix + "/test-data", response_model=TestDataRecord, status_code=201, responses=errors)
def create_test_data(command: CreateTestDataRecord, who: Identity = Depends(identity), db=Depends(store)):
return db.create_test_data(who, command)
@app.get(prefix + "/test-data/{record_id}", response_model=TestDataRecord, responses=errors)
def test_data_record(record_id: UUID, who: Identity = Depends(identity), db=Depends(store)):
return db.get_test_data(who, record_id)
@app.patch(prefix + "/test-data/{record_id}", response_model=TestDataRecord, responses=errors)
def update_test_data(record_id: UUID, command: UpdateTestDataRecord,
who: Identity = Depends(identity), db=Depends(store)):
return db.update_test_data(who, record_id, command)
@app.delete(prefix + "/test-data/{record_id}", response_model=DeletedResource, responses=errors)
def delete_test_data(record_id: UUID, command: DeleteResource,
who: Identity = Depends(identity), db=Depends(store)):
return db.delete_test_data(who, record_id, command)
@app.get(prefix + "/monitoring/points", response_model=PointPage, responses=errors)
def points(limit: int = Query(100, ge=1, le=500), cursor: str | None = Query(None, max_length=128),
who: Identity = Depends(identity), db=Depends(store)):
return db.points(who, limit, cursor)
@app.get(prefix + "/monitoring/observations", response_model=Observations, responses=errors)
def observations(point_id: str, start: str, end: str, who: Identity = Depends(identity), db=Depends(store)):
return db.observations(who, point_id, start, end)
@app.get(prefix + "/monitoring/alerts", response_model=AlertPage, responses=errors)
def alerts(who: Identity = Depends(identity), db=Depends(store)):
return db.alerts(who)
@app.get(prefix + "/demo-data", response_model=DemoDataset, responses=errors)
def demo_data(who: Identity = Depends(identity), db=Depends(store)):
return db.demo_data(who)
return app
app = create_app()
+54
View File
@@ -0,0 +1,54 @@
"""Reuse Next's product identity; no new user store or client-selected identity."""
from dataclasses import dataclass
from uuid import UUID
import httpx
from fastapi import Request
from .errors import SampleFailure, ensure
@dataclass(frozen=True)
class Identity:
project_id: str
user_id: str
can_write: bool
def next_get(request: Request, path: str, params=None):
settings = request.app.state.settings
try:
with httpx.Client(base_url=settings.next_server_url, timeout=settings.timeout_seconds,
follow_redirects=False, trust_env=False) as client:
response = client.get(path, params=params, headers={
"Authorization": request.headers.get("authorization", ""),
"X-Project-Id": request.headers.get("x-project-id", ""),
})
if response.status_code in {401, 403, 404}:
raise SampleFailure("SOURCE_ACCESS_DENIED", "Next 拒绝身份或来源对象访问", response.status_code)
ensure(response.status_code == 200, "NEXT_UNAVAILABLE", "Next 服务不可用,未确认来源", 503)
return response.json()
except (httpx.HTTPError, ValueError) as exc:
raise SampleFailure("NEXT_UNAVAILABLE", "无法核实 Next 身份或来源", 503) from exc
def identity(request: Request) -> Identity:
authorization = request.headers.get("authorization", "")
project = request.headers.get("x-project-id", "")
ensure(authorization.lower().startswith("bearer ") and bool(project),
"AUTHENTICATION_REQUIRED", "需要 Bearer 和 X-Project-Id", 401)
context = next_get(request, "/api/v1/agent-auth-context")
try:
user_id = str(UUID(context["user_id"]))
project_id = str(UUID(context["project_id"]))
ensure(project_id == str(UUID(project)), "PROJECT_MISMATCH", "项目身份不一致", 403)
ensure(context["network"] == request.app.state.settings.project_code,
"PROJECT_NOT_CONFIGURED", "该项目不是本外围样板的数据项目", 403)
permissions = context["permissions"]
ensure(isinstance(permissions, list) and "simulation.view" in permissions,
"PERMISSION_DENIED", "没有样板读取权限", 403)
can_write = "simulation.run" in permissions
ensure(request.method == "GET" or can_write, "PERMISSION_DENIED", "没有样板修改权限", 403)
except (KeyError, TypeError, ValueError) as exc:
raise SampleFailure("INVALID_AUTH_CONTEXT", "Next 身份响应不完整", 502) from exc
return Identity(project_id, user_id, can_write)
+20
View File
@@ -0,0 +1,20 @@
"""Server-only configuration. Reuses BizDB credentials, never browser config."""
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class SampleSettings(BaseSettings):
next_server_url: str = "http://127.0.0.1:8000"
db_name: str = "tjwater_v2"
project_code: str = "tjwater_v2"
cors_origins: list[str] = ["http://127.0.0.1:5173", "http://localhost:5173"]
enable_faults: bool = False
timeout_seconds: float = Field(default=5.0, ge=0.1, le=30)
model_config = SettingsConfigDict(
env_prefix="PERIPHERAL_SAMPLE_",
env_file=Path(__file__).resolve().parents[2] / ".env",
extra="ignore",
)
+82
View File
@@ -0,0 +1,82 @@
"""Pure, versioned generator. No clock, random process state, network or writes."""
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from uuid import NAMESPACE_URL, uuid5
from .schemas import DemoContent
VERSION = "water-demo@2"
DEFAULT_SEED = "tjwater-peripheral-demo-20260917"
DEFAULT_START = datetime(2026, 9, 17, tzinfo=timezone.utc)
def build_demo_content(seed: str = DEFAULT_SEED, start: datetime = DEFAULT_START) -> dict:
"""Same version/seed/UTC start produce identical canonical content and IDs."""
if not isinstance(seed, str) or not 1 <= len(seed) <= 128:
raise ValueError("seed must contain 1 to 128 characters")
if not isinstance(start, datetime) or start.tzinfo is None or start.utcoffset() is None:
raise ValueError("start must include timezone")
start = start.astimezone(timezone.utc)
if start.second or start.microsecond or start.minute % 15:
raise ValueError("start must be aligned to a 15-minute UTC boundary")
prefix = f"{VERSION}/{seed}/{start.isoformat()}"
def record_id(kind: str, key: str) -> str:
return str(uuid5(NAMESPACE_URL, f"{prefix}/{kind}/{key}"))
def at(index: int) -> str:
return (start + timedelta(minutes=15 * index)).isoformat()
points = [
("DEMO-P001", "演示压力测点 1", "m", 24.37),
("DEMO-P002", "演示压力测点 2", "m", 31.26),
("DEMO-P003", "演示压力测点 3", "m", 31.81),
("DEMO-F001", "演示出水流量", "m3/h", 186.43),
("DEMO-L001", "演示水池液位", "m", 4.26),
]
observations = []
for index in range(48):
for point_id, _label, unit, base in points:
noise = int(sha256(f"{prefix}/{point_id}/{index}".encode()).hexdigest()[:8], 16) % 101
value = round(base + (noise - 50) / 100, 2)
quality = "valid"
if point_id == "DEMO-P001" and 12 <= index < 16:
value, quality = None, "missing"
elif point_id == "DEMO-F001" and 24 <= index < 28:
value = 0.0
elif point_id == "DEMO-L001" and 36 <= index < 40:
value = round(1.35 + noise / 1000, 3)
observations.append({"record_id": record_id("observation", f"{point_id}/{index}"),
"point_id": point_id, "observed_at": at(index), "value": value,
"quality": quality, "unit": unit})
states = []
for asset, kind, changes in (
("DEMO-P001", "pressure_sensor", [(0, "online"), (12, "offline"), (16, "online")]),
("DEMO-PUMP-01", "pump", [(0, "running"), (24, "stopped"), (28, "running")]),
("DEMO-VALVE-01", "valve", [(0, "open"), (24, "closed"), (28, "open")]),
):
for index, state in changes:
states.append({"record_id": record_id("state", f"{asset}/{index}"), "asset_id": asset,
"asset_type": kind, "observed_at": at(index), "state": state})
states.sort(key=lambda record: (record["observed_at"], record["asset_id"]))
alerts = [
{"alert_id": record_id("alert", "communication"), "asset_id": "DEMO-P001",
"occurred_at": at(12), "cleared_at": at(16), "code": "communication_lost",
"message": "预置通信中断告警样例,仅为合成情节,不是现场诊断"},
{"alert_id": record_id("alert", "level"), "asset_id": "DEMO-L001",
"occurred_at": at(36), "cleared_at": at(40), "code": "low_level",
"message": "预置低液位告警样例,不采用生产阈值或专业告警算法"},
]
operations = [
{"record_id": record_id("operation", operation), "asset_id": asset, "occurred_at": at(index),
"operation": operation, "actor": "synthetic-generator", "note": "合成运行历史,不是设备指令或实际操作回执"}
for asset, index, operation in (("DEMO-PUMP-01", 24, "pump_stop"),
("DEMO-VALVE-01", 24, "valve_close"), ("DEMO-PUMP-01", 28, "pump_start"),
("DEMO-VALVE-01", 28, "valve_open"))
]
content = {"generator_version": VERSION, "seed": seed, "origin": "synthetic",
"window": {"start": at(0), "end": at(48)},
"points": [{"point_id": point, "label": label, "unit": unit, "status": "unknown", "origin": "synthetic"}
for point, label, unit, _ in points],
"observations": observations, "device_states": states, "alerts": alerts, "operations": operations}
return DemoContent.model_validate(content).model_dump(mode="json")
+18
View File
@@ -0,0 +1,18 @@
from uuid import UUID
class SampleFailure(Exception):
def __init__(self, code: str, message: str, status: int = 422, *,
effect: str = "none", request_id: UUID | str | None = None):
super().__init__(message)
self.code, self.message, self.status = code, message, status
self.effect, self.request_id = effect, str(request_id) if request_id else None
def payload(self):
return {"code": self.code, "message": self.message,
"request_id": self.request_id, "effect": self.effect}
def ensure(condition, code, message, status=422):
if not condition:
raise SampleFailure(code, message, status)
+397
View File
@@ -0,0 +1,397 @@
from __future__ import annotations
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, model_validator
from app.domain.schemas.shared import ObjectRef, TimeRange, WireDateTime
Text = Annotated[str, Field(min_length=1, max_length=4000)]
PositiveInt = Annotated[StrictInt, Field(ge=1)]
TaskStatus = Literal["pending", "processing", "pending_review", "completed"]
Scenario = Literal["normal", "missing_data", "unavailable", "response_lost", "duplicate_feedback", "conflicting_feedback", "late_state"]
class Model(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
class SourceLink(Model):
system: Literal["next"]
task_id: UUID
object_type: Literal["validation", "artifact"]
object_id: UUID
revision: PositiveInt
class CreateTask(Model):
request_id: UUID
title: Annotated[str, Field(min_length=1, max_length=200)]
summary: Text
source: SourceLink
asset_ids: Annotated[list[Annotated[str, Field(min_length=1, max_length=128)]], Field(min_length=1, max_length=100)]
analysis_window: TimeRange
evidence_refs: Annotated[list[ObjectRef], Field(max_length=100)]
requested_check: Text
@model_validator(mode="after")
def unique_assets(self):
if len(set(self.asset_ids)) != len(self.asset_ids):
raise ValueError("asset_ids must be unique")
return self
class Transition(Model):
request_id: UUID
expected_revision: PositiveInt
target_status: TaskStatus
reason: Text
class AddFeedback(Model):
request_id: UUID
expected_revision: PositiveInt
feedback_id: UUID
occurred_at: WireDateTime
applicable_time: TimeRange
asset_ids: Annotated[list[Annotated[str, Field(min_length=1, max_length=128)]], Field(min_length=1, max_length=100)]
finding: Literal["instrument_fault", "no_instrument_fault", "uncertain"]
notes: Text
attachment_refs: Annotated[list[Annotated[str, Field(min_length=1, max_length=500)]], Field(max_length=20)]
@model_validator(mode="after")
def aware_observation(self):
if self.occurred_at.tzinfo is None:
raise ValueError("occurred_at must include timezone")
if len(set(self.asset_ids)) != len(self.asset_ids):
raise ValueError("asset_ids must be unique")
return self
class Feedback(Model):
feedback_id: UUID
task_id: UUID
recorded_by: str
recorded_at: WireDateTime
occurred_at: WireDateTime
applicable_time: TimeRange
asset_ids: list[str]
finding: Literal["instrument_fault", "no_instrument_fault", "uncertain"]
notes: str
attachment_refs: list[str]
origin: Literal["synthetic"]
class HistoryEntry(Model):
sequence: PositiveInt
from_status: TaskStatus | None
to_status: TaskStatus
actor: str
at: WireDateTime
reason: str
class Task(Model):
task_id: UUID
project_id: UUID
revision: PositiveInt
title: str
summary: str
source: SourceLink
asset_ids: list[str]
analysis_window: TimeRange
evidence_refs: list[ObjectRef]
requested_check: str
status: TaskStatus
origin: Literal["synthetic"]
created_at: WireDateTime
updated_at: WireDateTime
created_by: str
history: list[HistoryEntry]
feedback: list[Feedback]
class TaskPage(Model):
items: list[Task]
next_cursor: UUID | None
WorkOrderStatus = Literal["draft", "open", "in_progress", "completed", "cancelled"]
Priority = Literal["low", "normal", "high", "urgent"]
class CreateWorkOrder(Model):
request_id: UUID
title: Annotated[str, Field(min_length=1, max_length=200)]
summary: Text
status: WorkOrderStatus = "draft"
priority: Priority = "normal"
assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None
scheduled_at: WireDateTime | None = None
@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")
return self
class UpdateWorkOrder(Model):
request_id: UUID
expected_revision: PositiveInt
title: Annotated[str, Field(min_length=1, max_length=200)] | None = None
summary: Text | None = None
status: WorkOrderStatus | None = None
priority: Priority | None = None
assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None
clear_assignee: StrictBool = False
scheduled_at: WireDateTime | None = None
clear_scheduled_at: StrictBool = False
@model_validator(mode="after")
def valid_update(self):
changed = set(self.model_fields_set) - {"request_id", "expected_revision"}
if not changed:
raise ValueError("at least one work order field must be updated")
if self.clear_assignee and self.assignee is not None:
raise ValueError("clear_assignee conflicts with assignee")
if self.clear_scheduled_at and self.scheduled_at is not None:
raise ValueError("clear_scheduled_at conflicts with scheduled_at")
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
raise ValueError("scheduled_at must include timezone")
return self
class DeleteResource(Model):
request_id: UUID
expected_revision: PositiveInt
class WorkOrder(Model):
work_order_id: UUID
project_id: UUID
revision: PositiveInt
title: str
summary: str
status: WorkOrderStatus
priority: Priority
assignee: str | None
scheduled_at: WireDateTime | None
origin: Literal["synthetic"]
created_at: WireDateTime
updated_at: WireDateTime
created_by: str
updated_by: str
class WorkOrderPage(Model):
items: list[WorkOrder]
next_cursor: UUID | None
class CreateTestDataRecord(Model):
request_id: UUID
label: Annotated[str, Field(min_length=1, max_length=200)]
point_id: Annotated[str, Field(min_length=1, max_length=128)]
observed_at: WireDateTime
value: StrictFloat | StrictInt | None
unit: Annotated[str, Field(min_length=1, max_length=32)]
quality: Literal["valid", "unknown", "missing"] = "valid"
notes: Annotated[str, Field(max_length=4000)] = ""
@model_validator(mode="after")
def aware_observation(self):
if self.observed_at.tzinfo is None:
raise ValueError("observed_at must include timezone")
return self
class UpdateTestDataRecord(Model):
request_id: UUID
expected_revision: PositiveInt
label: Annotated[str, Field(min_length=1, max_length=200)] | None = None
point_id: Annotated[str, Field(min_length=1, max_length=128)] | None = None
observed_at: WireDateTime | None = None
value: StrictFloat | StrictInt | None = None
clear_value: StrictBool = False
unit: Annotated[str, Field(min_length=1, max_length=32)] | None = None
quality: Literal["valid", "unknown", "missing"] | None = None
notes: Annotated[str, Field(max_length=4000)] | None = None
@model_validator(mode="after")
def valid_update(self):
changed = set(self.model_fields_set) - {"request_id", "expected_revision"}
if not changed:
raise ValueError("at least one test data field must be updated")
if self.clear_value and self.value is not None:
raise ValueError("clear_value conflicts with value")
if self.observed_at is not None and self.observed_at.tzinfo is None:
raise ValueError("observed_at must include timezone")
return self
class TestDataRecord(Model):
record_id: UUID
project_id: UUID
revision: PositiveInt
label: str
point_id: str
observed_at: WireDateTime
value: float | int | None
unit: str
quality: Literal["valid", "unknown", "missing"]
notes: str
origin: Literal["synthetic"]
created_at: WireDateTime
updated_at: WireDateTime
created_by: str
updated_by: str
class TestDataRecordPage(Model):
items: list[TestDataRecord]
next_cursor: UUID | None
class DeletedResource(Model):
resource_type: Literal["work_order", "test_data_record"]
resource_id: UUID
revision: Annotated[StrictInt, Field(ge=2)]
deleted: Literal[True]
class Receipt(Model):
request_id: UUID
task_id: UUID | None
result: Task | ScenarioState | WorkOrder | TestDataRecord | DeletedResource
class ScenarioCommand(Model):
request_id: UUID
scenario: Scenario
class ScenarioState(Model):
scenario: Scenario
revision: PositiveInt
ResourceAvailability = Literal["available", "not_configured"]
class ManagedResourceCapabilities(Model):
work_orders: ResourceAvailability
test_data: ResourceAvailability
class Context(Model):
protocol_version: Literal["tjwater-peripheral-sample@1"]
origin: Literal["synthetic"]
project_id: UUID
user_id: UUID
can_write: StrictBool
scenarios: list[Scenario]
state_transitions: dict[str, list[str]]
managed_resources: ManagedResourceCapabilities
class Point(Model):
point_id: str
label: str
unit: str
status: str
origin: Literal["historical_replay", "synthetic"]
class PointPage(Model):
items: list[Point]
source_id: str
origin: Literal["historical_replay", "synthetic"]
next_cursor: str | None
class Observation(Model):
record_id: str
point_id: str
observed_at: WireDateTime
value: float | None
quality: Literal["valid", "unknown", "missing"]
unit: str
class Observations(Model):
origin: Literal["historical_replay", "synthetic"]
source_id: str
source_revision: str
items: list[Observation]
truncated: StrictBool
class Alert(Model):
alert_id: str
point_id: str
occurred_at: WireDateTime
message: str
origin: Literal["historical_replay", "synthetic"]
class AlertPage(Model):
items: list[Alert]
source_id: str
origin: Literal["historical_replay", "synthetic"]
availability: Literal["available", "not_configured"]
reason: str | None
class SampleError(Model):
code: str
message: str
request_id: UUID | None
effect: Literal["none", "unknown"]
class DemoDeviceState(Model):
record_id: UUID
asset_id: str
asset_type: Literal["pressure_sensor", "pump", "valve"]
observed_at: WireDateTime
state: Literal["online", "offline", "running", "stopped", "open", "closed"]
class DemoAlert(Model):
alert_id: UUID
asset_id: str
occurred_at: WireDateTime
cleared_at: WireDateTime | None
code: Literal["communication_lost", "low_level"]
message: str
class DemoOperation(Model):
record_id: UUID
asset_id: str
occurred_at: WireDateTime
operation: Literal["pump_start", "pump_stop", "valve_open", "valve_close"]
actor: Literal["synthetic-generator"]
note: str
class DemoContent(Model):
generator_version: Literal["water-demo@2"]
seed: str
origin: Literal["synthetic"]
window: TimeRange
points: list[Point]
observations: list[Observation]
device_states: list[DemoDeviceState]
alerts: list[DemoAlert]
operations: list[DemoOperation]
class DemoDataset(DemoContent):
project_id: UUID
source_id: str
source_revision: str
Receipt.model_rebuild()
+483
View File
@@ -0,0 +1,483 @@
"""PostgreSQL persistence isolated to peripheral_sample, never existing business writes."""
from contextlib import contextmanager
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from math import isfinite
from typing import Any, Iterator
from uuid import UUID, uuid4
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.domain.schemas.shared import TimeRange
from app.services.content_digest import digest
from .errors import SampleFailure, ensure
from .auth import Identity
from .config import SampleSettings
from .schemas import (
AddFeedback, CreateTask, CreateTestDataRecord, CreateWorkOrder, DeleteResource,
DemoContent, ScenarioCommand, Task, TestDataRecord, Transition,
UpdateTestDataRecord, UpdateWorkOrder, WorkOrder,
)
TRANSITIONS = {"pending": ["processing"], "processing": ["pending_review"],
"pending_review": ["processing", "completed"], "completed": []}
class PostgresStore:
def __init__(self, settings: SampleSettings) -> None:
self.settings = settings
@contextmanager
def connection(self, *, readonly: bool = False, timeseries: bool = False) -> Iterator[psycopg.Connection]:
from app.core.config import settings as product
ensure(not timeseries or readonly, "READ_ONLY_SOURCE", "历史监测源仅允许只读连接", 403)
prefix = "TIMESCALEDB_DB_" if timeseries else "DB_"
try:
with psycopg.connect(host=getattr(product, prefix + "HOST"), port=getattr(product, prefix + "PORT"),
user=getattr(product, prefix + "USER"), password=getattr(product, prefix + "PASSWORD"),
dbname=self.settings.db_name, connect_timeout=5,
options="-c statement_timeout=5000", row_factory=dict_row) as conn:
if readonly:
conn.execute("SET TRANSACTION READ ONLY")
actual = conn.execute("SELECT current_database() AS name").fetchone()["name"]
ensure(actual == self.settings.db_name, "DATABASE_MISMATCH", "样板数据库不匹配", 503)
yield conn
except (psycopg.errors.UndefinedTable, psycopg.errors.InvalidSchemaName) as exc:
raise SampleFailure("STORAGE_NOT_CONFIGURED", "样板存储表尚未配置", 503) from exc
except psycopg.Error as exc:
# Do not leak connection strings, SQL, passwords or business values.
raise SampleFailure("STORAGE_UNAVAILABLE", "样板数据库不可用,请查询请求回执再重试", 503, effect="unknown" if not readonly else "none") from exc
def _scenario(self, conn, who):
row = conn.execute("SELECT revision,scenario FROM peripheral_sample.scenarios WHERE project_id=%s",
(who.project_id,)).fetchone()
state = dict(row) if row else {"revision": 1, "scenario": "normal"}
if not self.settings.enable_faults:
state["scenario"] = "normal"
return state
def managed_resource_capabilities(self) -> dict[str, str]:
"""Report optional test-resource storage without touching product data."""
with self.connection(readonly=True) as conn:
row = conn.execute("""SELECT
to_regclass('peripheral_sample.requests') IS NOT NULL AS requests,
to_regclass('peripheral_sample.scenarios') IS NOT NULL AS scenarios,
to_regclass('peripheral_sample.work_orders') IS NOT NULL AS work_orders,
to_regclass('peripheral_sample.work_order_revisions') IS NOT NULL AS work_order_revisions,
to_regclass('peripheral_sample.test_data_records') IS NOT NULL AS test_data_records,
to_regclass('peripheral_sample.test_data_record_revisions') IS NOT NULL AS test_data_record_revisions
""").fetchone()
common = row["requests"] and row["scenarios"]
return {
"work_orders": "available" if common and row["work_orders"] and row["work_order_revisions"]
else "not_configured",
"test_data": "available" if common and row["test_data_records"] and row["test_data_record_revisions"]
else "not_configured",
}
def scenario(self, who: Identity) -> dict[str, Any]:
with self.connection(readonly=True) as conn:
return self._scenario(conn, who)
def _available(self, conn, who):
scenario = self._scenario(conn, who)["scenario"]
ensure(scenario != "unavailable", "SCENARIO_UNAVAILABLE", "故障演练:外围服务不可用", 503)
return scenario
def _cached(self, conn, who, key, command):
payload = command.model_dump(mode="json", exclude={"request_id"})
request_digest = digest({"command": key, "payload": payload})
row = conn.execute("""SELECT command_key,request_digest,result FROM peripheral_sample.requests
WHERE project_id=%s AND actor_id=%s AND request_id=%s""",
(who.project_id, who.user_id, command.request_id)).fetchone()
if row:
ensure(row["command_key"] == key and row["request_digest"] == request_digest,
"IDEMPOTENCY_CONFLICT", "同一 request_id 不能用于不同命令或载荷", 409)
return row["result"], request_digest
return None, request_digest
def replay(self, who: Identity, key: str, command: CreateTask) -> dict[str, Any] | None:
with self.connection(readonly=True) as conn:
return self._cached(conn, who, key, command)[0]
def _mutate(self, who, key, command, operation, *, control=False):
ensure(who.can_write, "PERMISSION_DENIED", "没有样板修改权限", 403)
lost = False
try:
with self.connection() as conn:
conn.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))",
("peripheral-sample:" + who.project_id,))
cached, request_digest = self._cached(conn, who, key, command)
if cached is not None:
return cached
scenario = "normal" if control else self._available(conn, who)
result = operation(conn, scenario)
task_id = result.get("task_id")
conn.execute("""INSERT INTO peripheral_sample.requests
(project_id,actor_id,request_id,command_key,request_digest,task_id,result)
VALUES (%s,%s,%s,%s,%s,%s,%s)""",
(who.project_id, who.user_id, command.request_id, key, request_digest, task_id, Jsonb(result)))
lost = scenario == "response_lost" and key == "create_task"
except SampleFailure as exc:
exc.request_id = str(command.request_id)
raise
if lost:
raise SampleFailure("RESPONSE_LOST", "故障演练:写入已提交但响应未知,请按 request_id 查询回执",
503, effect="unknown", request_id=command.request_id)
return result
def set_scenario(self, who: Identity, command: ScenarioCommand) -> dict[str, Any]:
ensure(command.scenario == "normal" or self.settings.enable_faults,
"FAULTS_DISABLED", "故障注入未由服务端配置启用", 403)
def write(conn, _):
state = {"revision": self._scenario(conn, who)["revision"] + 1, "scenario": command.scenario}
conn.execute("""INSERT INTO peripheral_sample.scenarios(project_id,revision,scenario) VALUES (%s,%s,%s)
ON CONFLICT(project_id) DO UPDATE SET revision=excluded.revision,scenario=excluded.scenario""",
(who.project_id, state["revision"], state["scenario"]))
return state
return self._mutate(who, "scenario", command, write, control=True)
def _task(self, conn, who, task_id):
row = conn.execute("SELECT document FROM peripheral_sample.tasks WHERE project_id=%s AND task_id=%s",
(who.project_id, task_id)).fetchone()
ensure(row is not None, "TASK_NOT_FOUND", "样板核查任务不存在", 404)
return row["document"]
def _save(self, conn, who, document):
document = Task.model_validate(document).model_dump(mode="json")
conn.execute("""INSERT INTO peripheral_sample.tasks(project_id,task_id,revision,source_task_id,document)
VALUES (%s,%s,%s,%s,%s) ON CONFLICT(project_id,task_id)
DO UPDATE SET revision=excluded.revision,document=excluded.document""",
(who.project_id, document["task_id"], document["revision"], document["source"]["task_id"], Jsonb(document)))
conn.execute("""INSERT INTO peripheral_sample.revisions(project_id,task_id,revision,document)
VALUES (%s,%s,%s,%s)""", (who.project_id, document["task_id"], document["revision"], Jsonb(document)))
return document
def create_task(self, who: Identity, command: CreateTask) -> dict[str, Any]:
def write(conn, _):
now = datetime.now(timezone.utc).isoformat()
document = command.model_dump(mode="json", exclude={"request_id"}) | {
"task_id": str(uuid4()), "project_id": who.project_id, "revision": 1,
"status": "pending", "origin": "synthetic", "created_at": now, "updated_at": now,
"created_by": who.user_id, "feedback": [], "history": [{"sequence": 1, "from_status": None,
"to_status": "pending", "actor": who.user_id, "at": now, "reason": "提交外围样板核查,不是正式工单"}]}
return self._save(conn, who, document)
return self._mutate(who, "create_task", command, write)
def _project(self, conn, who, doc, scenario):
if scenario == "late_state" and doc["revision"] > 1:
row = conn.execute("""SELECT document FROM peripheral_sample.revisions
WHERE project_id=%s AND task_id=%s AND revision=%s""",
(who.project_id, doc["task_id"], doc["revision"] - 1)).fetchone()
if row:
return row["document"]
if scenario == "duplicate_feedback" and doc["feedback"]:
return {**doc, "feedback": doc["feedback"] + [deepcopy(doc["feedback"][-1])]}
return doc
def get_task(self, who: Identity, task_id: UUID | str) -> dict[str, Any]:
with self.connection(readonly=True) as conn:
scenario = self._available(conn, who)
return self._project(conn, who, self._task(conn, who, task_id), scenario)
def list_tasks(self, who: Identity, limit: int, cursor: UUID | None = None, source_task_id: UUID | None = None) -> dict[str, Any]:
with self.connection(readonly=True) as conn:
scenario = self._available(conn, who)
params = [who.project_id]
sql = "SELECT document FROM peripheral_sample.tasks WHERE project_id=%s"
if cursor is not None:
sql += " AND task_id>%s"
params.append(cursor)
if source_task_id is not None:
sql += " AND source_task_id=%s"
params.append(source_task_id)
rows = conn.execute(sql + " ORDER BY task_id LIMIT %s", params + [limit + 1]).fetchall()
docs = [self._project(conn, who, row["document"], scenario) for row in rows[:limit]]
return {"items": docs, "next_cursor": docs[-1]["task_id"] if len(rows) > limit else None}
def receipt(self, who: Identity, request_id: UUID | str) -> dict[str, Any]:
# Remains available during faults; no implicit re-submission.
with self.connection(readonly=True) as conn:
row = conn.execute("""SELECT task_id,result FROM peripheral_sample.requests
WHERE project_id=%s AND actor_id=%s AND request_id=%s""",
(who.project_id, who.user_id, request_id)).fetchone()
ensure(row is not None, "REQUEST_NOT_FOUND", "未找到该主体的请求回执", 404)
return {"request_id": str(request_id), "task_id": str(row["task_id"]) if row["task_id"] else None,
"result": row["result"]}
def transition(self, who: Identity, task_id: UUID | str, command: Transition) -> dict[str, Any]:
def write(conn, _):
doc = self._task(conn, who, task_id)
ensure(doc["revision"] == command.expected_revision, "REVISION_CONFLICT", "任务版本已变化,请重新读取", 409)
ensure(command.target_status in TRANSITIONS[doc["status"]], "INVALID_TRANSITION", "不允许跳过处置或审核步骤", 409)
ensure(command.target_status != "completed" or bool(doc["feedback"]),
"FEEDBACK_REQUIRED", "没有现场反馈,不能审核完成", 409)
now = datetime.now(timezone.utc).isoformat()
doc["history"].append({"sequence": len(doc["history"]) + 1, "from_status": doc["status"],
"to_status": command.target_status, "actor": who.user_id, "at": now, "reason": command.reason})
doc.update(status=command.target_status, revision=doc["revision"] + 1, updated_at=now)
return self._save(conn, who, doc)
return self._mutate(who, f"transition:{task_id}", command, write)
def add_feedback(self, who: Identity, task_id: UUID | str, command: AddFeedback) -> dict[str, Any]:
def write(conn, scenario):
doc = self._task(conn, who, task_id)
content = command.model_dump(mode="json", exclude={"request_id", "expected_revision"})
content_digest = digest(content)
previous = conn.execute("SELECT task_id,digest FROM peripheral_sample.feedback WHERE project_id=%s AND feedback_id=%s",
(who.project_id, command.feedback_id)).fetchone()
if previous:
ensure(str(previous["task_id"]) == str(task_id) and previous["digest"] == content_digest,
"FEEDBACK_CONFLICT", "同一反馈编号不能覆盖不同内容", 409)
return doc
ensure(doc["revision"] == command.expected_revision, "REVISION_CONFLICT", "任务版本已变化,请重新读取", 409)
ensure(doc["status"] in {"processing", "pending_review"}, "INVALID_TRANSITION", "仅处理中或待审核任务可记录反馈", 409)
ensure(set(command.asset_ids) <= set(doc["asset_ids"]), "FEEDBACK_SCOPE_MISMATCH", "反馈对象不属于本核查任务")
window = TimeRange.model_validate(doc["analysis_window"])
ensure(command.applicable_time.start >= window.start and command.applicable_time.end <= window.end,
"FEEDBACK_SCOPE_MISMATCH", "反馈适用时段超出原分析范围")
now = datetime.now(timezone.utc).isoformat()
item = {**content, "task_id": str(task_id), "recorded_by": who.user_id, "recorded_at": now, "origin": "synthetic"}
items = [(item, content_digest)]
if scenario == "conflicting_feedback" and command.finding != "uncertain":
opposite = {**item, "feedback_id": str(uuid4()), "recorded_by": "scenario:conflicting_feedback",
"finding": "no_instrument_fault" if command.finding == "instrument_fault" else "instrument_fault",
"notes": "故障演练自动注入的相反反馈,不是现场事实"}
items.append((opposite, digest(opposite)))
for item, item_digest in items:
conn.execute("INSERT INTO peripheral_sample.feedback(project_id,feedback_id,task_id,digest,document) VALUES (%s,%s,%s,%s,%s)",
(who.project_id, item["feedback_id"], task_id, item_digest, Jsonb(item)))
doc["feedback"].append(item)
doc.update(revision=doc["revision"] + 1, updated_at=now)
return self._save(conn, who, doc)
return self._mutate(who, f"feedback:{task_id}", command, write)
def _managed_document(self, conn, who: Identity, table: str, id_column: str,
resource_id: UUID | str, missing_code: str, missing_message: str) -> dict[str, Any]:
ensure(table in {"work_orders", "test_data_records"} and
id_column in {"work_order_id", "record_id"}, "SCHEMA_INVALID", "无效的样板资源")
row = conn.execute(
f"SELECT document FROM peripheral_sample.{table} WHERE project_id=%s AND {id_column}=%s AND deleted_at IS NULL",
(who.project_id, resource_id)).fetchone()
ensure(row is not None, missing_code, missing_message, 404)
return row["document"]
def _list_managed(self, who: Identity, table: str, id_column: str,
limit: int, cursor: UUID | None) -> dict[str, Any]:
ensure(table in {"work_orders", "test_data_records"} and
id_column in {"work_order_id", "record_id"}, "SCHEMA_INVALID", "无效的样板资源")
with self.connection(readonly=True) as conn:
self._available(conn, who)
params: list[Any] = [who.project_id]
sql = f"SELECT document FROM peripheral_sample.{table} WHERE project_id=%s AND deleted_at IS NULL"
if cursor is not None:
sql += f" AND {id_column}>%s"
params.append(cursor)
rows = conn.execute(sql + f" ORDER BY {id_column} LIMIT %s", params + [limit + 1]).fetchall()
docs = [row["document"] for row in rows[:limit]]
return {"items": docs, "next_cursor": docs[-1][id_column] if len(rows) > limit else None}
def _save_work_order(self, conn, who: Identity, document: dict[str, Any]) -> dict[str, Any]:
document = WorkOrder.model_validate(document).model_dump(mode="json")
conn.execute("""INSERT INTO peripheral_sample.work_orders
(project_id,work_order_id,revision,document,deleted_at) VALUES (%s,%s,%s,%s,NULL)
ON CONFLICT(project_id,work_order_id) DO UPDATE SET
revision=excluded.revision,document=excluded.document,deleted_at=NULL""",
(who.project_id, document["work_order_id"], document["revision"], Jsonb(document)))
conn.execute("""INSERT INTO peripheral_sample.work_order_revisions
(project_id,work_order_id,revision,document) VALUES (%s,%s,%s,%s)""",
(who.project_id, document["work_order_id"], document["revision"], Jsonb(document)))
return document
def list_work_orders(self, who: Identity, limit: int, cursor: UUID | None = None) -> dict[str, Any]:
return self._list_managed(who, "work_orders", "work_order_id", limit, cursor)
def get_work_order(self, who: Identity, work_order_id: UUID | str) -> dict[str, Any]:
with self.connection(readonly=True) as conn:
self._available(conn, who)
return self._managed_document(conn, who, "work_orders", "work_order_id", work_order_id,
"WORK_ORDER_NOT_FOUND", "测试工单不存在")
def create_work_order(self, who: Identity, command: CreateWorkOrder) -> dict[str, Any]:
def write(conn, _):
now = datetime.now(timezone.utc).isoformat()
document = command.model_dump(mode="json", exclude={"request_id"}) | {
"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,
}
return self._save_work_order(conn, who, document)
return self._mutate(who, "create_work_order", command, write)
def update_work_order(self, who: Identity, work_order_id: UUID | str,
command: UpdateWorkOrder) -> dict[str, Any]:
def write(conn, _):
document = self._managed_document(conn, who, "work_orders", "work_order_id", work_order_id,
"WORK_ORDER_NOT_FOUND", "测试工单不存在")
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"})
if command.clear_assignee:
changes["assignee"] = None
if command.clear_scheduled_at:
changes["scheduled_at"] = None
document.update(changes)
document.update(revision=document["revision"] + 1, updated_at=datetime.now(timezone.utc).isoformat(),
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)
def delete_work_order(self, who: Identity, work_order_id: UUID | str,
command: DeleteResource) -> dict[str, Any]:
def write(conn, _):
document = self._managed_document(conn, who, "work_orders", "work_order_id", work_order_id,
"WORK_ORDER_NOT_FOUND", "测试工单不存在")
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409)
revision = document["revision"] + 1
deleted_at = datetime.now(timezone.utc).isoformat()
result = {"resource_type": "work_order", "resource_id": str(work_order_id),
"revision": revision, "deleted": True}
tombstone = {**document, "revision": revision, "updated_at": deleted_at,
"updated_by": who.user_id, "deleted_at": deleted_at, "deleted_by": who.user_id}
conn.execute("""UPDATE peripheral_sample.work_orders SET revision=%s,document=%s,deleted_at=%s,deleted_by=%s
WHERE project_id=%s AND work_order_id=%s AND deleted_at IS NULL""",
(revision, Jsonb(tombstone), deleted_at, who.user_id, who.project_id, work_order_id))
conn.execute("""INSERT INTO peripheral_sample.work_order_revisions
(project_id,work_order_id,revision,document) VALUES (%s,%s,%s,%s)""",
(who.project_id, work_order_id, revision, Jsonb(tombstone)))
return result
return self._mutate(who, f"delete_work_order:{work_order_id}", command, write)
def _save_test_data(self, conn, who: Identity, document: dict[str, Any]) -> dict[str, Any]:
document = TestDataRecord.model_validate(document).model_dump(mode="json")
conn.execute("""INSERT INTO peripheral_sample.test_data_records
(project_id,record_id,revision,document,deleted_at) VALUES (%s,%s,%s,%s,NULL)
ON CONFLICT(project_id,record_id) DO UPDATE SET
revision=excluded.revision,document=excluded.document,deleted_at=NULL""",
(who.project_id, document["record_id"], document["revision"], Jsonb(document)))
conn.execute("""INSERT INTO peripheral_sample.test_data_record_revisions
(project_id,record_id,revision,document) VALUES (%s,%s,%s,%s)""",
(who.project_id, document["record_id"], document["revision"], Jsonb(document)))
return document
def list_test_data(self, who: Identity, limit: int, cursor: UUID | None = None) -> dict[str, Any]:
return self._list_managed(who, "test_data_records", "record_id", limit, cursor)
def get_test_data(self, who: Identity, record_id: UUID | str) -> dict[str, Any]:
with self.connection(readonly=True) as conn:
self._available(conn, who)
return self._managed_document(conn, who, "test_data_records", "record_id", record_id,
"TEST_DATA_NOT_FOUND", "测试数据不存在")
def create_test_data(self, who: Identity, command: CreateTestDataRecord) -> dict[str, Any]:
def write(conn, _):
now = datetime.now(timezone.utc).isoformat()
document = command.model_dump(mode="json", exclude={"request_id"}) | {
"record_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,
}
return self._save_test_data(conn, who, document)
return self._mutate(who, "create_test_data", command, write)
def update_test_data(self, who: Identity, record_id: UUID | str,
command: UpdateTestDataRecord) -> dict[str, Any]:
def write(conn, _):
document = self._managed_document(conn, who, "test_data_records", "record_id", record_id,
"TEST_DATA_NOT_FOUND", "测试数据不存在")
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "数据版本已变化,请重新读取", 409)
changes = command.model_dump(mode="json", exclude_unset=True,
exclude={"request_id", "expected_revision", "clear_value"})
if command.clear_value:
changes["value"] = None
document.update(changes)
document.update(revision=document["revision"] + 1, updated_at=datetime.now(timezone.utc).isoformat(),
updated_by=who.user_id)
return self._save_test_data(conn, who, document)
return self._mutate(who, f"update_test_data:{record_id}", command, write)
def delete_test_data(self, who: Identity, record_id: UUID | str,
command: DeleteResource) -> dict[str, Any]:
def write(conn, _):
document = self._managed_document(conn, who, "test_data_records", "record_id", record_id,
"TEST_DATA_NOT_FOUND", "测试数据不存在")
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "数据版本已变化,请重新读取", 409)
revision = document["revision"] + 1
deleted_at = datetime.now(timezone.utc).isoformat()
result = {"resource_type": "test_data_record", "resource_id": str(record_id),
"revision": revision, "deleted": True}
tombstone = {**document, "revision": revision, "updated_at": deleted_at,
"updated_by": who.user_id, "deleted_at": deleted_at, "deleted_by": who.user_id}
conn.execute("""UPDATE peripheral_sample.test_data_records SET revision=%s,document=%s,deleted_at=%s,deleted_by=%s
WHERE project_id=%s AND record_id=%s AND deleted_at IS NULL""",
(revision, Jsonb(tombstone), deleted_at, who.user_id, who.project_id, record_id))
conn.execute("""INSERT INTO peripheral_sample.test_data_record_revisions
(project_id,record_id,revision,document) VALUES (%s,%s,%s,%s)""",
(who.project_id, record_id, revision, Jsonb(tombstone)))
return result
return self._mutate(who, f"delete_test_data:{record_id}", command, write)
def points(self, who: Identity, limit: int = 100, cursor: str | None = None) -> dict[str, Any]:
with self.connection(readonly=True) as conn:
self._available(conn, who)
# Existing project-owned table; absolutely no DDL/DML on it.
exists = conn.execute("SELECT to_regclass('asset.scada_devices') AS table_name").fetchone()["table_name"]
ensure(exists is not None, "SOURCE_NOT_CONFIGURED", "项目测点目录尚未映射", 503)
rows = conn.execute("""SELECT device_id,device_type,measurement_unit FROM asset.scada_devices
WHERE (%s::text IS NULL OR device_id::text>%s) ORDER BY device_id::text LIMIT %s""",
(cursor, cursor, limit + 1)).fetchall()
return {"source_id": self.settings.db_name + ":asset.scada_devices", "origin": "historical_replay",
"items": [{"point_id": str(r["device_id"]).strip(), "label": str(r["device_id"]).strip() + " · " + str(r["device_type"]),
"unit": str(r["measurement_unit"]), "status": "unknown", "origin": "historical_replay"} for r in rows[:limit]],
"next_cursor": str(rows[limit-1]["device_id"]).strip() if len(rows) > limit else None}
def observations(self, who: Identity, point_id: str, start: str, end: str) -> dict[str, Any]:
try:
window = TimeRange.model_validate({"start": start, "end": end})
except ValueError as exc:
raise SampleFailure("SCHEMA_INVALID", "需要带时区的有效起止时间") from exc
ensure(window.end - window.start <= timedelta(days=7), "WINDOW_TOO_LARGE", "样板查询窗口最多 7 天")
with self.connection(readonly=True) as conn:
scenario = self._available(conn, who)
point = conn.execute("SELECT measurement_unit,api_query_id FROM asset.scada_devices WHERE device_id=%s", (point_id,)).fetchone()
ensure(point is not None, "POINT_NOT_FOUND", "测点不存在", 404)
ensure(point["api_query_id"], "SOURCE_NOT_CONFIGURED", "测点历史查询标识未配置", 503)
with self.connection(readonly=True, timeseries=True) as conn:
rows = conn.execute("""SELECT time,monitored_value FROM scada.measurements
WHERE device_id=%s AND time>=%s AND time<%s ORDER BY time LIMIT 1001""",
(point["api_query_id"], window.start, window.end)).fetchall()
items = [{"record_id": digest({"point": point_id, "time": r["time"].isoformat()}),
"point_id": point_id, "observed_at": r["time"].isoformat(),
"value": r["monitored_value"] if r["monitored_value"] is not None and isfinite(r["monitored_value"]) else None,
"quality": "unknown" if r["monitored_value"] is not None and isfinite(r["monitored_value"]) else "missing",
"unit": point["measurement_unit"]} for r in rows[:1000]]
if scenario == "missing_data":
for item in items[::2]:
item.update(value=None, quality="missing")
return {"origin": "synthetic" if scenario == "missing_data" else "historical_replay",
"source_id": self.settings.db_name + ":scada.measurements", "source_revision": digest(items),
"items": items, "truncated": len(rows) > 1000}
def alerts(self, who: Identity) -> dict[str, Any]:
with self.connection(readonly=True) as conn:
self._available(conn, who)
return {"items": [], "source_id": self.settings.db_name, "origin": "historical_replay",
"availability": "not_configured", "reason": "告警来源未映射;不从压力读数猜测告警或阈值"}
def demo_data(self, who: Identity) -> dict[str, Any]:
with self.connection(readonly=True) as conn:
self._available(conn, who)
row = conn.execute("""SELECT document,digest FROM peripheral_sample.demo_datasets
WHERE project_id=%s AND dataset_id='water-demo-v1'""", (who.project_id,)).fetchone()
ensure(row is not None, "SOURCE_NOT_CONFIGURED", "当前项目尚未登记合成样本", 503)
ensure(digest(row["document"]) == row["digest"], "INTEGRITY_MISMATCH", "合成样本摘要不匹配", 503)
try:
content = DemoContent.model_validate(row["document"]).model_dump(mode="json")
except ValueError as exc:
raise SampleFailure("INTEGRITY_MISMATCH", "合成样本格式不匹配", 503) from exc
return {**content, "project_id": who.project_id,
"source_id": self.settings.db_name + ":peripheral_sample.demo_datasets:water-demo-v1",
"source_revision": row["digest"]}
+253
View File
@@ -0,0 +1,253 @@
"""Read-only readiness check for workbench query indexes and test resources."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import psycopg
from psycopg.rows import dict_row
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.core.config import settings
from samples.peripheral.config import SampleSettings
RESOURCES = {
"analysis_runtime_queries": (
"analysis.runs",
"analysis.runs_created_at_run_id_idx",
"analysis.results",
"analysis.results_run_created_at_result_id_idx",
),
"task_alerts": (
"workbench.alert_events",
"workbench.alert_events_alert_idx",
"workbench.alerts",
"workbench.alerts_active_recent_idx",
),
"peripheral_work_orders": (
"peripheral_sample.requests",
"peripheral_sample.scenarios",
"peripheral_sample.work_orders",
"peripheral_sample.work_order_revisions",
),
"peripheral_test_data": (
"peripheral_sample.test_data_records",
"peripheral_sample.test_data_record_revisions",
),
}
COLUMN_REQUIREMENTS = {
"analysis_runtime_queries": {
"analysis.runs": {"run_id", "created_at"},
"analysis.results": {"result_id", "run_id", "created_at"},
},
"task_alerts": {
"workbench.alert_events": {
"project_id", "producer_id", "event_id", "alert_id", "event_type",
"occurred_at", "ingested_at", "document",
},
"workbench.alerts": {
"project_id", "alert_id", "revision", "status", "occurred_at",
"updated_at", "document",
},
},
}
INDEX_REQUIREMENTS = {
"analysis.runs_created_at_run_id_idx": (
"on analysis.runs",
"(created_at desc, run_id)",
),
"analysis.results_run_created_at_result_id_idx": (
"on analysis.results",
"(run_id, created_at desc, result_id desc)",
),
"workbench.alert_events_alert_idx": (
"(project_id, alert_id, occurred_at, event_id)",
),
"workbench.alerts_active_recent_idx": (
"(project_id, occurred_at desc, alert_id)",
"status = 'active'",
),
}
RUNTIME_PRIVILEGES = {
"analysis.runs": ("SELECT",),
"analysis.results": ("SELECT",),
"workbench.alert_events": ("SELECT", "INSERT"),
"workbench.alerts": ("SELECT", "INSERT", "UPDATE"),
"peripheral_sample.requests": ("SELECT", "INSERT"),
"peripheral_sample.scenarios": ("SELECT", "INSERT", "UPDATE"),
"peripheral_sample.tasks": ("SELECT", "INSERT", "UPDATE"),
"peripheral_sample.revisions": ("SELECT", "INSERT"),
"peripheral_sample.feedback": ("SELECT", "INSERT"),
"peripheral_sample.demo_datasets": ("SELECT",),
"peripheral_sample.work_orders": ("SELECT", "INSERT", "UPDATE"),
"peripheral_sample.work_order_revisions": ("SELECT", "INSERT"),
"peripheral_sample.test_data_records": ("SELECT", "INSERT", "UPDATE"),
"peripheral_sample.test_data_record_revisions": ("SELECT", "INSERT"),
}
PROHIBITED_PRIVILEGES = {
relation: ("DELETE", "TRUNCATE") for relation in RUNTIME_PRIVILEGES
} | {
"workbench.alert_events": ("UPDATE", "DELETE", "TRUNCATE"),
}
def main() -> int:
sample_settings = SampleSettings()
with psycopg.connect(
host=settings.DB_HOST,
port=settings.DB_PORT,
user=settings.DB_USER,
password=settings.DB_PASSWORD,
dbname=sample_settings.db_name,
connect_timeout=5,
options="-c statement_timeout=5000",
row_factory=dict_row,
) as connection:
connection.execute("SET TRANSACTION READ ONLY")
database = connection.execute("SELECT current_database() AS name").fetchone()["name"]
availability: dict[str, dict[str, object]] = {}
for resource, relations in RESOURCES.items():
rows = connection.execute(
"SELECT value,to_regclass(value)::text AS relation FROM unnest(%s::text[]) value",
(list(relations),),
).fetchall()
missing = [row["value"] for row in rows if row["relation"] is None]
missing_columns: dict[str, list[str]] = {}
for relation, required in COLUMN_REQUIREMENTS.get(resource, {}).items():
schema_name, table_name = relation.split(".", 1)
columns = connection.execute(
"""SELECT column_name FROM information_schema.columns
WHERE table_schema=%s AND table_name=%s""",
(schema_name, table_name),
).fetchall()
present = {row["column_name"] for row in columns}
absent = sorted(required - present)
if absent:
missing_columns[relation] = absent
invalid_indexes: dict[str, list[str]] = {}
for relation in relations:
required_fragments = INDEX_REQUIREMENTS.get(relation)
if required_fragments is None or relation in missing:
continue
definition_row = connection.execute(
"SELECT pg_get_indexdef(to_regclass(%s)) AS definition",
(relation,),
).fetchone()
definition = " ".join(
str(definition_row["definition"] or "").lower().split()
)
absent_fragments = [
fragment
for fragment in required_fragments
if fragment not in definition
]
if absent_fragments:
invalid_indexes[relation] = absent_fragments
availability[resource] = {
"status": (
"available"
if not missing and not missing_columns and not invalid_indexes
else "not_configured"
),
"missing": missing,
"missing_columns": missing_columns,
"invalid_indexes": invalid_indexes,
}
role = connection.execute(
"""SELECT current_user AS name,rolsuper,rolcreatedb,rolcreaterole
FROM pg_roles WHERE rolname=current_user"""
).fetchone()
database_owner = connection.execute(
"""SELECT pg_get_userbyid(datdba) AS name
FROM pg_database WHERE datname=current_database()"""
).fetchone()["name"]
owned_relations = [
f"{row['schema_name']}.{row['relation_name']}"
for row in connection.execute(
"""SELECT namespace.nspname AS schema_name,
relation.relname AS relation_name
FROM pg_class relation
JOIN pg_namespace namespace ON namespace.oid=relation.relnamespace
WHERE pg_get_userbyid(relation.relowner)=current_user
AND namespace.nspname=ANY(%s::text[])
AND relation.relkind IN ('r','p')
ORDER BY namespace.nspname,relation.relname""",
(["analysis", "peripheral_sample", "workbench"],),
).fetchall()
]
missing_runtime_privileges: list[str] = []
prohibited_runtime_privileges: list[str] = []
for relation, privileges in RUNTIME_PRIVILEGES.items():
exists = connection.execute(
"SELECT to_regclass(%s) IS NOT NULL AS available", (relation,)
).fetchone()["available"]
if not exists:
missing_runtime_privileges.extend(
f"{relation}:{privilege}" for privilege in privileges
)
continue
for privilege in privileges:
allowed = connection.execute(
"SELECT has_table_privilege(current_user,%s,%s) AS allowed",
(relation, privilege),
).fetchone()["allowed"]
if not allowed:
missing_runtime_privileges.append(f"{relation}:{privilege}")
for relation, privileges in PROHIBITED_PRIVILEGES.items():
exists = connection.execute(
"SELECT to_regclass(%s) IS NOT NULL AS available", (relation,)
).fetchone()["available"]
if not exists:
continue
for privilege in privileges:
allowed = connection.execute(
"SELECT has_table_privilege(current_user,%s,%s) AS allowed",
(relation, privilege),
).fetchone()["allowed"]
if allowed:
prohibited_runtime_privileges.append(
f"{relation}:{privilege}"
)
production_safe_role = bool(
role
and not role["rolsuper"]
and not role["rolcreatedb"]
and not role["rolcreaterole"]
and role["name"] != database_owner
and not owned_relations
and not missing_runtime_privileges
and not prohibited_runtime_privileges
)
ready = all(item["status"] == "available" for item in availability.values())
print(json.dumps({
"database": database,
"ready": ready,
"production_ready": ready and production_safe_role,
"connection_role": {
"name": role["name"] if role else None,
"superuser": role["rolsuper"] if role else None,
"create_database": role["rolcreatedb"] if role else None,
"create_role": role["rolcreaterole"] if role else None,
"owns_database": role["name"] == database_owner if role else None,
"owned_relations": owned_relations,
"missing_runtime_privileges": missing_runtime_privileges,
"prohibited_runtime_privileges": prohibited_runtime_privileges,
"production_safe": production_safe_role,
},
"resources": availability,
}, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if ready else 2
if __name__ == "__main__":
raise SystemExit(main())
+15
View File
@@ -0,0 +1,15 @@
"""Generate the independent peripheral sample contract without opening a DB."""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from samples.peripheral.api import app
if __name__ == "__main__":
target = ROOT / "contracts/peripheral-sample.openapi.json"
target.write_text(json.dumps(app.openapi(), ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"Exported {len(app.openapi()['paths'])} sample paths")
@@ -0,0 +1,82 @@
"""Generate one reproducible Fengxian water-quality monitoring-point dataset."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.native.wndb.core.connection import project_connection
from app.services.sensor_placement import optimize_sensor_placement_by_sensitivity
def find_existing_run(
project: str,
*,
sensor_count: int,
min_diameter: int,
) -> dict[str, object] | None:
with project_connection(project) as conn:
return conn.execute(
"""
SELECT run.run_id::text AS run_id, run.name, run.created_at,
result.payload
FROM analysis.runs AS run
JOIN LATERAL (
SELECT stored.payload
FROM analysis.results AS stored
WHERE stored.run_id = run.run_id
AND stored.result_type = 'sensor_placement'
ORDER BY stored.created_at DESC, stored.result_id DESC
LIMIT 1
) AS result ON true
WHERE run.run_type = 'sensor_placement'
AND run.status = 'completed'
AND result.payload->>'sensor_type' = 'water_quality'
AND result.payload->>'method' = 'sensitivity'
AND (result.payload->>'sensor_number')::integer = %s
AND (result.payload->>'minimum_diameter')::integer = %s
ORDER BY run.created_at DESC, run.run_id DESC
LIMIT 1
""",
(sensor_count, min_diameter),
).fetchone()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", default="tjwater_v2")
parser.add_argument("--sensor-count", type=int, default=50)
parser.add_argument("--min-diameter", type=int, default=100)
parser.add_argument("--run-name", default="奉贤水质监测点灵敏度分析(50点)")
parser.add_argument("--created-by", default="tjwater")
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
existing = find_existing_run(
args.project,
sensor_count=args.sensor_count,
min_diameter=args.min_diameter,
)
if existing is not None and not args.force:
print(json.dumps(dict(existing), ensure_ascii=False, default=str, indent=2))
return
created = optimize_sensor_placement_by_sensitivity(
project_code=args.project,
run_name=args.run_name,
sensor_count=args.sensor_count,
min_diameter=args.min_diameter,
created_by=args.created_by,
sensor_type="water_quality",
)
print(json.dumps(created, ensure_ascii=False, default=str, indent=2))
if __name__ == "__main__":
main()
+475
View File
@@ -0,0 +1,475 @@
"""Import the two Fengxian GIS deliveries into one project BizDB.
The source PRJ files declare EPSG:21481, while their coordinate values match the
project's Shanghai local CRS (SRID 900914). The importer records both facts and
assigns the validated local CRS without changing coordinate values.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from psycopg import sql
from psycopg.conninfo import conninfo_to_dict
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from app.infra.db.project_routing import get_project_pgconn_string
from app.native.wndb.core.connection import project_connection, project_transaction
SCHEMA_SQL = PROJECT_ROOT / "resources/sql/010_source_gis_inventory.sql"
DEFAULT_APRIL_ROOT = Path(
"/mnt/c/Users/admin/Desktop/data/奉贤/20200423全网除头桥数据导出"
)
DEFAULT_AUGUST_ROOT = Path(
"/mnt/c/Users/admin/Desktop/data/奉贤/20200811奉贤100口径以上(含台账)(1)"
)
@dataclass(frozen=True)
class DatasetSpec:
key: str
title: str
source_date: date
@dataclass(frozen=True)
class LayerSpec:
key: str
dataset: DatasetSpec
display_name: str
source_file: Path
feature_kind: str
asset_id_candidates: tuple[str, ...] = ()
APRIL_DATASET = DatasetSpec(
key="fx_water_assets_20200423",
title="奉贤全网除头桥 GIS 数据导出",
source_date=date(2020, 4, 23),
)
AUGUST_DATASET = DatasetSpec(
key="fx_pipe_inventory_20200811",
title="奉贤 DN100 及以上管网台账",
source_date=date(2020, 8, 11),
)
def layer_specs(april_root: Path, august_root: Path) -> tuple[LayerSpec, ...]:
return (
LayerSpec("fx_20200423_drain_valves", APRIL_DATASET, "排水阀", april_root / "排水阀.shp", "drain_valve", ("排水阀ID编", "排水阀ID编码")),
LayerSpec("fx_20200423_service_connections", APRIL_DATASET, "接水点", april_root / "接水点.shp", "service_connection", ("接水点ID编", "接水点ID编码")),
LayerSpec("fx_20200423_flow_meters", APRIL_DATASET, "流量仪", april_root / "流量仪.shp", "flow_meter", ("流量仪ID编", "流量仪ID编码")),
LayerSpec("fx_20200423_hydrants", APRIL_DATASET, "消火栓", april_root / "消火栓.shp", "hydrant", ("消火栓ID编", "消火栓ID编码")),
LayerSpec("fx_20200423_customer_meters", APRIL_DATASET, "用户水表", april_root / "用户水表.shp", "customer_meter", ("水表ID编码", "用户水表ID")),
LayerSpec("fx_20200423_pipes", APRIL_DATASET, "管线", april_root / "管线.shp", "pipe", ("管线ID编码",)),
LayerSpec("fx_20200423_valves", APRIL_DATASET, "阀门", april_root / "阀门.shp", "valve", ("阀门ID编码",)),
LayerSpec("fx_20200423_non_residential_meters", APRIL_DATASET, "非居民用户水表", april_root / "非居民用户水表.shp", "non_residential_meter", ("水表ID编码", "用户水表ID")),
LayerSpec(
"fx_20200811_dn100_pipes",
AUGUST_DATASET,
"DN100 及以上管线台账",
august_root / "20200811奉贤管网100及以上(含台账).shp",
"pipe",
("PIPE_ID",),
),
)
def _postgres_environment(database_name: str) -> dict[str, str]:
parts = conninfo_to_dict(get_project_pgconn_string(db_name=database_name))
env = os.environ.copy()
for source, target in (
("host", "PGHOST"),
("port", "PGPORT"),
("user", "PGUSER"),
("password", "PGPASSWORD"),
("dbname", "PGDATABASE"),
):
value = parts.get(source)
if value:
env[target] = value
return env
def _load_staging_table(spec: LayerSpec, database_name: str) -> str:
stage_name = f"_stage_{spec.key}"
with project_connection(database_name) as conn:
conn.execute(
sql.SQL("DROP TABLE IF EXISTS {}.{} CASCADE").format(
sql.Identifier("source_gis"), sql.Identifier(stage_name)
)
)
command = [
"ogr2ogr",
"--config",
"PG_USE_COPY",
"YES",
"--config",
"OSR_USE_NON_DEPRECATED",
"NO",
"-f",
"PostgreSQL",
"PG:",
str(spec.source_file),
"-overwrite",
"-preserve_fid",
"-dim",
"XY",
"-mapFieldType",
"Date=String",
"-nln",
stage_name,
"-lco",
"SCHEMA=source_gis",
"-lco",
"FID=source_fid",
"-lco",
"GEOMETRY_NAME=geom",
"-lco",
"LAUNDER=NO",
]
subprocess.run(
command,
env=_postgres_environment(database_name),
check=True,
text=True,
)
return stage_name
def _first_column(columns: set[str], candidates: tuple[str, ...]) -> str | None:
return next((candidate for candidate in candidates if candidate in columns), None)
def _column_as_text(column: str | None) -> sql.Composable:
if column is None:
return sql.SQL("NULL::text")
return sql.SQL("NULLIF(BTRIM({}::text), '')").format(sql.Identifier(column))
def _column_as_float(column: str | None) -> sql.Composable:
if column is None:
return sql.SQL("NULL::double precision")
return sql.SQL("{}::double precision").format(sql.Identifier(column))
def _store_layer(database_name: str, spec: LayerSpec, stage_name: str) -> int:
with project_connection(database_name) as conn, conn.transaction(), conn.cursor() as cur:
cur.execute(
"""
SELECT a.attname
FROM pg_attribute a
WHERE a.attrelid = to_regclass(%s)
AND a.attnum > 0
AND NOT a.attisdropped
""",
(f"source_gis.{stage_name}",),
)
columns = {row["attname"] for row in cur.fetchall()}
if not {"source_fid", "geom"}.issubset(columns):
raise RuntimeError(f"Staging table for {spec.key} is incomplete")
cur.execute(
"""
INSERT INTO source_gis.datasets (
dataset_key, title, source_date, declared_srid, effective_srid,
coordinate_note, imported_at
) VALUES (%s, %s, %s, 21481, 900914, %s, NOW())
ON CONFLICT (dataset_key) DO UPDATE SET
title = EXCLUDED.title,
source_date = EXCLUDED.source_date,
declared_srid = EXCLUDED.declared_srid,
effective_srid = EXCLUDED.effective_srid,
coordinate_note = EXCLUDED.coordinate_note,
imported_at = NOW()
""",
(
spec.dataset.key,
spec.dataset.title,
spec.dataset.source_date,
"源 PRJ 声明 EPSG:21481,但坐标值与项目 SRID 900914 一致;原坐标未重投影。",
),
)
cur.execute(
sql.SQL("SELECT COUNT(*) AS count, GeometryType(geom) AS geometry_type FROM {}.{} GROUP BY GeometryType(geom)").format(
sql.Identifier("source_gis"), sql.Identifier(stage_name)
)
)
geometry_rows = cur.fetchall()
count = sum(int(row["count"]) for row in geometry_rows)
geometry_type = ",".join(sorted({row["geometry_type"] for row in geometry_rows}))
cur.execute(
"""
INSERT INTO source_gis.layers (
layer_key, dataset_key, display_name, source_file, feature_kind,
geometry_type, feature_count, imported_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (layer_key) DO UPDATE SET
dataset_key = EXCLUDED.dataset_key,
display_name = EXCLUDED.display_name,
source_file = EXCLUDED.source_file,
feature_kind = EXCLUDED.feature_kind,
geometry_type = EXCLUDED.geometry_type,
feature_count = EXCLUDED.feature_count,
imported_at = NOW()
""",
(
spec.key,
spec.dataset.key,
spec.display_name,
spec.source_file.name,
spec.feature_kind,
geometry_type,
count,
),
)
cur.execute("DELETE FROM source_gis.features WHERE layer_key = %s", (spec.key,))
object_id = _first_column(columns, ("OBJECTID", "ObjectID", "objectid"))
keyword = _first_column(columns, ("GJZ", "关键字"))
asset_id = _first_column(columns, spec.asset_id_candidates)
diameter = _first_column(columns, ("PIPE_D", "DIAMETER", "直径"))
from_node = _first_column(columns, ("FNODE", "起点编号", "起点ID"))
to_node = _first_column(columns, ("TNODE", "终点编号", "终点ID"))
insert_query = sql.SQL(
"""
INSERT INTO source_gis.features (
layer_key, source_fid, source_object_id, source_keyword,
source_asset_id, source_diameter, from_node_id, to_node_id,
attributes, geom
)
SELECT %s, source_fid, {}, {}, {}, {}, {}, {},
to_jsonb(stage) - 'source_fid' - 'geom',
ST_Force2D(ST_SetSRID(geom, 900914))
FROM {}.{} AS stage
"""
).format(
_column_as_text(object_id),
_column_as_text(keyword),
_column_as_text(asset_id),
_column_as_float(diameter),
_column_as_text(from_node),
_column_as_text(to_node),
sql.Identifier("source_gis"),
sql.Identifier(stage_name),
)
cur.execute(insert_query, (spec.key,))
cur.execute(
sql.SQL("DROP TABLE {}.{}").format(
sql.Identifier("source_gis"), sql.Identifier(stage_name)
)
)
return count
def _rebuild_element_links(database_name: str) -> dict[str, int]:
with project_connection(database_name) as conn, conn.transaction(), conn.cursor() as cur:
cur.execute(
"""
UPDATE source_gis.features AS april
SET source_diameter = august.source_diameter
FROM source_gis.features AS august
WHERE april.layer_key = 'fx_20200423_pipes'
AND august.layer_key = 'fx_20200811_dn100_pipes'
AND april.source_object_id = august.source_object_id
AND ST_Equals(april.geom, august.geom)
AND april.source_diameter IS NULL
"""
)
cur.execute("DELETE FROM source_gis.element_links")
cur.execute(
"""
INSERT INTO source_gis.element_links (
element_type, element_id, layer_key, source_fid,
match_method, distance_m
)
SELECT 'pipes', pipe.id, layer.layer_key, match.source_fid,
'node_pair', 0
FROM gis.pipes AS pipe
CROSS JOIN source_gis.layers AS layer
CROSS JOIN LATERAL (
SELECT feature.source_fid
FROM source_gis.features AS feature
WHERE feature.layer_key = layer.layer_key
AND (
(feature.from_node_id = pipe.start_node_id AND feature.to_node_id = pipe.end_node_id)
OR
(feature.from_node_id = pipe.end_node_id AND feature.to_node_id = pipe.start_node_id)
)
ORDER BY feature.source_fid
LIMIT 1
) AS match
WHERE layer.feature_kind = 'pipe'
"""
)
node_pair_count = cur.rowcount
cur.execute(
"""
INSERT INTO source_gis.element_links (
element_type, element_id, layer_key, source_fid,
match_method, distance_m
)
SELECT 'pipes', model.element_id, model.layer_key, match.source_fid,
'geometry', match.distance_m
FROM (
SELECT pipe.id AS element_id, layer.layer_key,
pipe.diameter,
ST_Transform(pipe.geom, 900914) AS geom
FROM gis.pipes AS pipe
CROSS JOIN source_gis.layers AS layer
WHERE layer.feature_kind = 'pipe'
AND NOT EXISTS (
SELECT 1 FROM source_gis.element_links AS existing
WHERE existing.element_type = 'pipes'
AND existing.element_id = pipe.id
AND existing.layer_key = layer.layer_key
)
AND ST_Length(ST_Transform(pipe.geom, 900914)) >= 0.1
) AS model
CROSS JOIN LATERAL (
SELECT candidate.source_fid, candidate.distance_m
FROM (
SELECT feature.source_fid,
ST_HausdorffDistance(feature.geom, model.geom) AS distance_m
FROM source_gis.features AS feature
WHERE feature.layer_key = model.layer_key
AND feature.geom && ST_Expand(model.geom, 1.0)
AND (
feature.source_diameter IS NULL
OR model.diameter IS NULL
OR ABS(feature.source_diameter - model.diameter)
<= GREATEST(1.0, model.diameter * 0.02)
)
ORDER BY feature.geom <-> model.geom
LIMIT 8
) AS candidate
ORDER BY candidate.distance_m, candidate.source_fid
LIMIT 1
) AS match
WHERE match.distance_m <= 1.0
ON CONFLICT (element_type, element_id, layer_key) DO NOTHING
"""
)
geometry_count = cur.rowcount
cur.execute(
"""
WITH point_matches AS (
SELECT feature.layer_key, feature.source_fid,
nearest.node_id AS element_id,
nearest.distance_m,
ROW_NUMBER() OVER (
PARTITION BY nearest.node_id, feature.layer_key
ORDER BY nearest.distance_m, feature.source_fid
) AS match_rank
FROM source_gis.features AS feature
JOIN source_gis.layers AS layer
ON layer.layer_key = feature.layer_key
CROSS JOIN LATERAL (
SELECT node.node_id,
ST_Distance(node.geom, feature.geom) AS distance_m
FROM gis.node_geometries AS node
ORDER BY node.geom <-> feature.geom
LIMIT 1
) AS nearest
JOIN gis.junctions AS junction
ON junction.id = nearest.node_id
WHERE layer.feature_kind <> 'pipe'
AND nearest.distance_m <= 0.1
)
INSERT INTO source_gis.element_links (
element_type, element_id, layer_key, source_fid,
match_method, distance_m
)
SELECT 'junctions', element_id, layer_key, source_fid,
'geometry', distance_m
FROM point_matches
WHERE match_rank = 1
"""
)
junction_count = cur.rowcount
cur.execute(
"""
INSERT INTO source_gis.element_links (
element_type, element_id, layer_key, source_fid,
match_method, distance_m
)
SELECT 'valves', valve.id, layer.layer_key, match.source_fid,
'geometry', match.distance_m
FROM gis.valves AS valve
CROSS JOIN source_gis.layers AS layer
CROSS JOIN LATERAL (
SELECT feature.source_fid,
ST_Distance(feature.geom, ST_Transform(valve.geom, 900914)) AS distance_m
FROM source_gis.features AS feature
WHERE feature.layer_key = layer.layer_key
ORDER BY feature.geom <-> ST_Transform(valve.geom, 900914)
LIMIT 1
) AS match
WHERE layer.feature_kind = 'valve'
AND match.distance_m <= 2.0
"""
)
valve_count = cur.rowcount
return {
"node_pair": node_pair_count,
"pipe_geometry": geometry_count,
"junction_geometry": junction_count,
"valve_geometry": valve_count,
}
def import_sources(database_name: str, specs: tuple[LayerSpec, ...]) -> None:
missing = [str(spec.source_file) for spec in specs if not spec.source_file.is_file()]
if missing:
raise FileNotFoundError("Missing GIS source files: " + ", ".join(missing))
with project_connection(database_name) as conn:
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
staged_layers: list[tuple[LayerSpec, str]] = []
for spec in specs:
print(f"Loading {spec.display_name} ({spec.key})")
staged_layers.append((spec, _load_staging_table(spec, database_name)))
total = 0
with project_transaction(database_name) as conn:
conn.execute(
"TRUNCATE TABLE source_gis.element_links, source_gis.features"
)
for spec, stage_name in staged_layers:
count = _store_layer(database_name, spec, stage_name)
total += count
print(f" stored {count:,} features")
links = _rebuild_element_links(database_name)
print(f"Imported {total:,} features into {database_name}")
print(
"Element links: "
f"{links['node_pair']:,} node-pair, "
f"{links['pipe_geometry']:,} pipe geometry, "
f"{links['junction_geometry']:,} junction geometry, "
f"{links['valve_geometry']:,} valve geometry"
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", default="tjwater_v2")
parser.add_argument("--april-root", type=Path, default=DEFAULT_APRIL_ROOT)
parser.add_argument("--august-root", type=Path, default=DEFAULT_AUGUST_ROOT)
args = parser.parse_args()
import_sources(args.database, layer_specs(args.april_root, args.august_root))
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
"""Create and publish Fengxian source GIS and water-quality monitoring layers."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from psycopg import sql
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.infra.db.postgresql.project_spatial import get_project_map_bbox
from app.infra.geoserver.client import GeoServerAdminClient
from app.native.wndb.core.connection import project_connection
SCHEMA_SQL = ROOT / "resources/sql/013_fengxian_map_layers.sql"
SOURCE_MATERIALIZED_LAYERS = (
("fx_20200423_drain_valves", "奉贤排水阀"),
("fx_20200423_service_connections", "奉贤接水点"),
("fx_20200423_flow_meters", "奉贤流量仪"),
("fx_20200423_hydrants", "奉贤消火栓"),
("fx_20200423_customer_meters", "奉贤用户水表"),
("fx_20200423_pipes", "奉贤全网管线"),
("fx_20200423_valves", "奉贤阀门"),
("fx_20200423_non_residential_meters", "奉贤非居民用户水表"),
("fx_20200811_dn100_pipes", "奉贤 DN100 及以上管线"),
)
PUBLISHED_LAYERS = SOURCE_MATERIALIZED_LAYERS + (
("water_quality_monitoring_points", "奉贤水质监测点"),
)
def prepare_layers(database: str) -> dict[str, int]:
with project_connection(database) as conn, conn.transaction():
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
for layer, _title in SOURCE_MATERIALIZED_LAYERS:
conn.execute(
sql.SQL("REFRESH MATERIALIZED VIEW gis.{}").format(
sql.Identifier(layer)
)
)
counts: dict[str, int] = {}
for layer, _title in PUBLISHED_LAYERS:
row = conn.execute(
sql.SQL("SELECT count(*) AS count FROM gis.{}").format(
sql.Identifier(layer)
)
).fetchone()
counts[layer] = int(row["count"])
return counts
def publish_layers(database: str, workspace: str) -> dict[str, int]:
counts = prepare_layers(database)
map_bbox = get_project_map_bbox(database)
with GeoServerAdminClient() as geoserver:
geoserver.check_ready()
for layer, title in PUBLISHED_LAYERS:
geoserver.publish_feature_type(
workspace=workspace,
datastore=workspace,
layer=layer,
title=title,
map_bbox=map_bbox,
)
geoserver.truncate_layer_cache(workspace, layer)
return counts
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", default="tjwater_v2")
parser.add_argument("--workspace", default="tjwater_next")
args = parser.parse_args()
counts = publish_layers(args.database, args.workspace)
for layer, count in counts.items():
print(f"{layer}: {count:,}")
if __name__ == "__main__":
main()
+153
View File
@@ -0,0 +1,153 @@
"""Idempotently seed synthetic peripheral demo resources for one explicit project."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from uuid import NAMESPACE_URL, UUID, uuid5
from psycopg.types.json import Jsonb
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
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.store import PostgresStore
SEED_NAMESPACE = uuid5(NAMESPACE_URL, "tjwater/peripheral-sample/seed/v1")
ACTOR_ID = uuid5(SEED_NAMESPACE, "actor")
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"),
("DEMO-P001", "合成压力 1 · 00:30 缺测", "2026-09-17T00:30:00Z", None, "m", "missing"),
("DEMO-P001", "合成压力 1 · 00:45", "2026-09-17T00:45:00Z", 24.42, "m", "valid"),
("DEMO-P002", "合成压力 2 · 00:00", "2026-09-17T00:00:00Z", 31.26, "m", "valid"),
("DEMO-P002", "合成压力 2 · 00:15", "2026-09-17T00:15:00Z", 31.31, "m", "valid"),
("DEMO-P002", "合成压力 2 · 00:30", "2026-09-17T00:30:00Z", 31.18, "m", "valid"),
("DEMO-P002", "合成压力 2 · 00:45", "2026-09-17T00:45:00Z", 31.36, "m", "valid"),
("DEMO-F001", "合成流量 00:00", "2026-09-17T00:00:00Z", 186.43, "m3/h", "valid"),
("DEMO-F001", "合成流量 00:15", "2026-09-17T00:15:00Z", 185.90, "m3/h", "valid"),
("DEMO-F001", "合成零流量 00:30", "2026-09-17T00:30:00Z", 0.0, "m3/h", "valid"),
("DEMO-F001", "合成流量 00:45", "2026-09-17T00:45:00Z", 187.12, "m3/h", "valid"),
)
def ensure_demo_dataset(store: PostgresStore, project_id: str) -> str:
content = build_demo_content()
content_digest = digest(content)
with store.connection() as connection:
row = connection.execute(
"""SELECT digest FROM peripheral_sample.demo_datasets
WHERE project_id=%s AND dataset_id='water-demo-v1'""",
(project_id,),
).fetchone()
if row is None:
connection.execute(
"""INSERT INTO peripheral_sample.demo_datasets
(project_id,dataset_id,digest,document)
VALUES (%s,'water-demo-v1',%s,%s)""",
(project_id, content_digest, Jsonb(content)),
)
elif row["digest"] != content_digest:
connection.execute(
"""UPDATE peripheral_sample.demo_datasets
SET digest=%s, document=%s
WHERE project_id=%s AND dataset_id='water-demo-v1'""",
(content_digest, Jsonb(content), project_id),
)
return content_digest
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--project-id", required=True, type=UUID)
args = parser.parse_args()
project_id = str(args.project_id)
store = PostgresStore(SampleSettings(db_name="tjwater_v2", enable_faults=False))
who = Identity(project_id, str(ACTOR_ID), True)
dataset_digest = ensure_demo_dataset(store, project_id)
order_ids = []
for item in WORK_ORDERS:
command = CreateWorkOrder(
request_id=request_id(item["key"]),
title=item["title"],
summary=item["summary"],
status=item["status"],
priority=item["priority"],
assignee=item["assignee"],
scheduled_at=item["scheduled_at"],
)
order_ids.append(store.create_work_order(who, command)["work_order_id"])
record_ids = []
for point_id, label, observed_at, value, unit, quality in TEST_DATA:
command = CreateTestDataRecord(
request_id=request_id(f"test-data/{point_id}/{observed_at}"),
label=label,
point_id=point_id,
observed_at=observed_at,
value=value,
unit=unit,
quality=quality,
notes="外围样板固定合成数据,不是现场监测记录。",
)
record_ids.append(store.create_test_data(who, command)["record_id"])
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))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+254
View File
@@ -0,0 +1,254 @@
"""Project peripheral demo orders into the backend workbench order store."""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
from uuid import UUID
from zoneinfo import ZoneInfo
from psycopg.types.json import Jsonb
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.domain.schemas.workbench_runtime import WorkbenchRuntimeWorkOrder
from app.native.wndb.core.connection import project_connection
SCHEMA_SQL = ROOT / "resources/sql/011_workbench_work_orders.sql"
SHANGHAI = ZoneInfo("Asia/Shanghai")
PROJECTION_SOURCE = "合成数据预约工单"
DEMO_WORK_ORDER_ID = "demo-work-order-peripheral-inspection"
ORDER_DETAILS = {
"合成通信核查": {
"location": "DEMO-P001",
"duration_minutes": 30,
"reply_requirements": ["记录通信恢复时间", "确认缺口数据是否补传"],
},
"合成压力偏差复核": {
"location": "DEMO-P001 / DEMO-P002",
"duration_minutes": 45,
"reply_requirements": ["回填相邻测点对照结果", "确认合成样本复核结论"],
},
"合成零流量样本确认": {
"location": "DEMO-F001",
"duration_minutes": 20,
"reply_requirements": ["确认零值显示", "确认缺测值显示"],
},
}
def _parse_timestamp(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _time_label(value: datetime) -> str:
return value.astimezone(SHANGHAI).strftime("%H:%M")
def _time_range(start: datetime, end: datetime) -> str:
return f"{_time_label(start)}-{_time_label(end)}"
def _subtask_status(status: str) -> str:
mapped = {
"open": "pending",
"in_progress": "running",
"completed": "completed",
}.get(status)
if mapped is None:
raise ValueError(f"unsupported peripheral work-order status: {status}")
return mapped
def _stage_statuses(status: str) -> tuple[str, str, str]:
if status == "completed":
return "done", "done", "done"
if status == "running":
return "done", "current", "pending"
return "current", "pending", "pending"
def project_peripheral_subtask(document: dict) -> dict:
status = _subtask_status(document.get("status"))
scheduled_at = _parse_timestamp(document["scheduled_at"])
details = ORDER_DETAILS.get(document["title"], {})
duration_minutes = int(details.get("duration_minutes", 30))
execution_end = scheduled_at + timedelta(minutes=duration_minutes)
reply_end = execution_end + timedelta(minutes=15)
dispatch_status, execution_status, reply_status = _stage_statuses(status)
assignee = document.get("assignee") or "待分派班组"
return {
"id": document["work_order_id"],
"title": document["title"],
"summary": document["summary"],
"status": status,
"scheduled_at": document["scheduled_at"],
"location": details.get("location", "演示点位"),
"assignee": assignee,
"duration_minutes": duration_minutes,
"reply_window_minutes": 15,
"stages": [
{
"id": "dispatch",
"sequence": 1,
"title": "派发",
"status": dispatch_status,
"owner": "演示调度",
"time_label": _time_label(scheduled_at - timedelta(minutes=15)),
"description": "预约工单子任务已进入演示调度流程。",
},
{
"id": "execution",
"sequence": 2,
"title": "执行",
"status": execution_status,
"owner": assignee,
"time_label": _time_range(scheduled_at, execution_end),
"description": "按外围合成场景执行本项核查。",
},
{
"id": "reply",
"sequence": 3,
"title": "复令",
"status": reply_status,
"owner": assignee,
"time_label": _time_range(execution_end, reply_end),
"description": "回传本项核查结果并完成子任务闭环。",
},
],
"reply_requirements": details.get(
"reply_requirements", ["回传演示核查结果"]
),
}
def project_peripheral_work_order(source_documents: list[dict]) -> dict:
if not source_documents:
raise ValueError("source documents must not be empty")
subtasks = [project_peripheral_subtask(document) for document in source_documents]
scheduled_values = [_parse_timestamp(item["scheduled_at"]) for item in source_documents]
updated_values = [_parse_timestamp(item["updated_at"]) for item in source_documents]
end_values = [
scheduled + timedelta(minutes=subtask["duration_minutes"])
for scheduled, subtask in zip(scheduled_values, subtasks, strict=True)
]
statuses = {subtask["status"] for subtask in subtasks}
if statuses == {"completed"}:
status = "completed"
elif statuses == {"pending"}:
status = "pending"
else:
status = "running"
priority = "urgent" if any(
document.get("priority") in {"high", "urgent"}
for document in source_documents
) else "normal"
projected = {
"id": DEMO_WORK_ORDER_ID,
"code": "DEMO-WO-001",
"scheduled_at": min(scheduled_values),
"title": "外围合成数据核查",
"summary": "依次核查合成通信、压力偏差和零流量样本。",
"status": status,
"risk_level": "attention" if priority == "urgent" else "normal",
"updated_at": max(updated_values),
"detail": "外围样板中的三项 synthetic 核查已组合为一张预约工单,仅用于演示。",
"recommendation": "按子任务逐项核查并分别完成阶段回传。",
"duration_minutes": max(
1,
int((max(end_values) - min(scheduled_values)).total_seconds() // 60),
),
"source": PROJECTION_SOURCE,
"dispatcher": "演示调度",
"priority": priority,
"subtasks": subtasks,
}
return WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--database-name", default="tjwater_v2")
parser.add_argument("--project-id", required=True, type=UUID)
args = parser.parse_args()
with project_connection(args.database_name) as conn, conn.transaction():
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
source_rows = conn.execute(
"""
SELECT document
FROM peripheral_sample.work_orders
WHERE project_id = %s
AND deleted_at IS NULL
AND document->>'origin' = 'synthetic'
AND document->>'scheduled_at' IS NOT NULL
AND document->>'status' IN ('open', 'in_progress', 'completed')
ORDER BY (document->>'scheduled_at')::timestamptz, work_order_id
""",
(args.project_id,),
).fetchall()
source_documents = [row["document"] for row in source_rows]
if not source_documents:
raise RuntimeError("no eligible peripheral demo work orders found")
documents = [project_peripheral_work_order(source_documents)]
conn.execute(
"""
DELETE FROM workbench.work_orders
WHERE project_id = %s
AND (
work_order_id LIKE 'demo-work-order-%%'
OR document->>'source' = %s
OR document->>'source' = '外围样板预约工单'
)
""",
(args.project_id, PROJECTION_SOURCE),
)
for document in documents:
conn.execute(
"""
INSERT INTO workbench.work_orders
(project_id, work_order_id, scheduled_at, updated_at, document)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (project_id, work_order_id) DO UPDATE
SET scheduled_at = EXCLUDED.scheduled_at,
updated_at = EXCLUDED.updated_at,
document = EXCLUDED.document
""",
(
args.project_id,
document["id"],
document["scheduled_at"],
document["updated_at"],
Jsonb(document),
),
)
print(json.dumps({
"database": args.database_name,
"project_id": str(args.project_id),
"source": PROJECTION_SOURCE,
"work_orders": [
{
"id": document["id"],
"code": document["code"],
"title": document["title"],
"subtask_count": len(document["subtasks"]),
}
for document in documents
],
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+135
View File
@@ -0,0 +1,135 @@
import asyncio
from datetime import date
import pytest
from fastapi import HTTPException
from app.api.v1.endpoints import project_data
def test_element_gis_properties_endpoint_returns_typed_groups(monkeypatch):
async def list_element_groups(conn, element_type, element_id):
assert conn is connection
assert element_type == "pipes"
assert element_id == "DEMO-P001"
return [
{
"dataset_key": "fx_pipe_inventory_20200811",
"dataset_title": "奉贤 DN100 及以上管网台账",
"source_date": date(2020, 8, 11),
"layer_key": "fx_20200811_dn100_pipes",
"layer_name": "DN100 及以上管线台账",
"source_fid": 117,
"match_method": "node_pair",
"match_distance_m": 0,
"attributes": {"PIPE_D": 300},
}
]
connection = object()
monkeypatch.setattr(
project_data.GisAttributeRepository,
"list_element_groups",
list_element_groups,
)
result = asyncio.run(
project_data.get_element_gis_properties(
element_type="pipes",
element_id=" DEMO-P001 ",
conn=connection,
)
)
assert result.element_id == "DEMO-P001"
assert result.groups[0].attributes == {"PIPE_D": 300}
def test_element_gis_properties_endpoint_rejects_blank_id():
with pytest.raises(HTTPException) as failure:
asyncio.run(
project_data.get_element_gis_properties(
element_type="pipes",
element_id=" ",
conn=object(),
)
)
assert failure.value.status_code == 422
def test_source_gis_feature_endpoint_returns_direct_source_group(monkeypatch):
async def get_source_feature_group(conn, layer_key, source_fid):
assert conn is connection
assert layer_key == "fx_20200423_flow_meters"
assert source_fid == 17
return {
"dataset_key": "fx_water_assets_20200423",
"dataset_title": "奉贤全网除头桥 GIS 数据导出",
"source_date": date(2020, 4, 23),
"layer_key": layer_key,
"layer_name": "流量仪",
"source_fid": source_fid,
"match_method": "source",
"match_distance_m": None,
"attributes": {"流量仪ID编码": "FM-17"},
}
connection = object()
monkeypatch.setattr(
project_data.GisAttributeRepository,
"get_source_feature_group",
get_source_feature_group,
)
result = asyncio.run(
project_data.get_source_gis_feature_properties(
layer_key=" fx_20200423_flow_meters ",
source_fid=17,
conn=connection,
)
)
assert result.layer_key == "fx_20200423_flow_meters"
assert result.group.match_method == "source"
assert result.group.attributes == {"流量仪ID编码": "FM-17"}
def test_analysis_run_endpoint_forwards_page_bounds(monkeypatch):
captured = {}
async def list_runs(conn, *, limit, offset):
captured.update(conn=conn, limit=limit, offset=offset)
return [{"run_id": "run-1"}]
connection = object()
monkeypatch.setattr(project_data.AnalysisRepository, "list_runs", list_runs)
result = asyncio.run(
project_data.get_analysis_runs(limit=25, offset=50, conn=connection)
)
assert captured == {"conn": connection, "limit": 25, "offset": 50}
assert result == {
"success": True,
"data": [{"run_id": "run-1"}],
"count": 1,
"limit": 25,
"offset": 50,
}
def test_analysis_endpoints_do_not_return_database_exception_text(monkeypatch):
async def unavailable(*_args, **_kwargs):
raise RuntimeError("postgresql://user:secret@database/internal")
monkeypatch.setattr(project_data.AnalysisRepository, "list_runs", unavailable)
with pytest.raises(HTTPException) as failure:
asyncio.run(
project_data.get_analysis_runs(limit=100, offset=0, conn=object())
)
assert failure.value.status_code == 500
assert failure.value.detail == "查询分析运行失败"
assert "secret" not in failure.value.detail
@@ -0,0 +1,508 @@
from datetime import datetime, timezone
from uuid import uuid4
from fastapi.testclient import TestClient
from app.api.v1.endpoints import workbench_runtime
from app.auth.project_dependencies import ProjectContext
from app.infra.db.postgresql.workbench_operations import (
WorkbenchOperationsAvailability,
)
from tests.conftest import build_test_app
PROJECT_CONTEXT = ProjectContext(
project_id=uuid4(),
project_code="test-project",
user_id=uuid4(),
project_role="viewer",
)
def configure_dependencies(app):
app.dependency_overrides[workbench_runtime.get_project_context] = lambda: PROJECT_CONTEXT
app.dependency_overrides[workbench_runtime.get_project_pg_connection] = lambda: object()
def test_alert_ingress_binds_producer_to_authenticated_project_principal(monkeypatch):
app = build_test_app(workbench_runtime.router, "/api/v1")
member = ProjectContext(
project_id=uuid4(),
project_code="test-project",
user_id=uuid4(),
project_role="member",
)
app.dependency_overrides[workbench_runtime.get_project_context] = lambda: member
app.dependency_overrides[workbench_runtime.get_project_pg_connection] = lambda: object()
captured = {}
class CapturingAlertService:
def __init__(self, _repository, context):
assert context == member
async def push(self, event):
captured["event"] = event
return event.model_dump(exclude={"event_id", "event_type"}) | {
"revision": 1,
"status": "active",
"updated_at": datetime(2026, 9, 18, 2, 30, tzinfo=timezone.utc),
}
monkeypatch.setattr(workbench_runtime, "TaskAlertService", CapturingAlertService)
body = {
"event_id": str(uuid4()),
"alert_id": "schedule-1:occurrence-1",
"event_type": "raised",
"occurred_at": "2026-09-18T02:30:00Z",
"condition_id": None,
"source": {
"task_type": "scheduled_task",
"task_id": "schedule-1",
"run_id": "occurrence-1",
},
"title": "定时任务失败",
"description": "任务显式推送的告警。",
"severity": "warning",
}
response = TestClient(app).post("/api/v1/workbench/task-alert-events", json=body)
assert response.status_code == 200
assert response.json()["producer_id"] == f"principal:{member.user_id}"
assert captured["event"].producer_id == f"principal:{member.user_id}"
spoofed = TestClient(app).post(
"/api/v1/workbench/task-alert-events",
json=body | {"producer_id": "investigation-scheduler"},
)
assert spoofed.status_code == 422
def test_runtime_endpoint_returns_typed_empty_projection(monkeypatch):
app = build_test_app(workbench_runtime.router, "/api/v1")
configure_dependencies(app)
async def empty_runs(_conn, *, limit):
assert limit == 100
return []
async def no_operations(_conn):
return WorkbenchOperationsAvailability(
schedules=False,
work_orders=False,
alerts=False,
)
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs)
monkeypatch.setattr(
workbench_runtime.WorkbenchOperationsRepository,
"availability",
no_operations,
)
response = TestClient(app).get("/api/v1/workbench/runtime")
assert response.status_code == 200
assert response.json() == {
"availability": "not_configured",
"source": "none",
"scenario_availability": "not_configured",
"artifact_availability": "not_configured",
"schedule_availability": "not_configured",
"work_order_availability": "not_configured",
"alert_availability": "not_configured",
"generated_at": response.json()["generated_at"],
"conditions": [],
"alerts": [],
"scenarios": [],
"latest_analysis": None,
"work_orders": [],
}
assert datetime.fromisoformat(response.json()["generated_at"]).tzinfo is timezone.utc
def test_analysis_history_endpoint_returns_a_sanitized_bounded_page(monkeypatch):
app = build_test_app(workbench_runtime.router, "/api/v1")
configure_dependencies(app)
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
run_ids = [uuid4(), uuid4(), uuid4()]
async def analysis_runs(_conn, *, limit, offset):
assert (limit, offset) == (3, 20)
return [
{
"run_id": run_ids[index],
"name": f"分析运行 {index + 1}",
"run_type": "sensor_placement",
"created_by": "alice",
"created_at": now,
"started_at": now,
"status": "completed",
"has_results": index != 1,
"parameters": {"internal_secret": "must-not-leak"},
}
for index in range(3)
]
monkeypatch.setattr(
workbench_runtime.AnalysisRepository,
"list_run_summaries",
analysis_runs,
)
response = TestClient(app).get(
"/api/v1/workbench/analysis-runs?limit=2&offset=20"
)
assert response.status_code == 200
payload = response.json()
assert payload["next_offset"] == 22
assert [item["run_id"] for item in payload["items"]] == [
str(run_ids[0]),
str(run_ids[1]),
]
assert payload["items"][0]["run_type_label"] == "传感器布点"
assert payload["items"][1]["has_results"] is False
assert "internal_secret" not in response.text
def test_runtime_endpoint_keeps_analysis_projection_out_of_conditions(monkeypatch):
app = build_test_app(workbench_runtime.router, "/api/v1")
configure_dependencies(app)
run_id = uuid4()
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
async def analysis_runs(_conn, *, limit):
assert limit == 100
return [{
"run_id": run_id,
"name": "北区爆管定位",
"run_type": "burst_location",
"created_by": "alice",
"created_at": now,
"started_at": now,
"status": "completed",
"parameters": {},
}]
async def analysis_results(_conn, requested_run_id, *, limit):
assert requested_run_id == run_id
assert limit == 100
return [{"result_type": "burst_location", "created_at": now}]
async def no_operations(_conn):
return WorkbenchOperationsAvailability(
schedules=False,
work_orders=False,
alerts=False,
)
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", analysis_runs)
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_results", analysis_results)
monkeypatch.setattr(
workbench_runtime.WorkbenchOperationsRepository,
"availability",
no_operations,
)
response = TestClient(app).get("/api/v1/workbench/runtime")
assert response.status_code == 200
payload = response.json()
assert payload["availability"] == "not_configured"
assert payload["source"] == "none"
assert payload["conditions"] == []
assert payload["scenarios"][0]["run_id"] == str(run_id)
assert payload["latest_analysis"]["results"] == [{
"result_type": "burst_location",
"created_at": "2026-09-17T02:30:00Z",
"facts": [],
}]
assert payload["latest_analysis"]["map_payload"] is None
def test_analysis_run_endpoint_returns_the_requested_typed_projection(monkeypatch):
app = build_test_app(workbench_runtime.router, "/api/v1")
configure_dependencies(app)
run_id = uuid4()
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
async def analysis_run(_conn, requested_run_id):
assert requested_run_id == run_id
return {
"run_id": run_id,
"name": "历史爆管定位",
"run_type": "burst_location",
"created_by": "alice",
"created_at": now,
"started_at": now,
"status": "completed",
"parameters": {},
}
async def analysis_results(_conn, requested_run_id, *, limit):
assert requested_run_id == run_id
assert limit == 100
return [{
"result_type": "burst_location",
"link_id": "P-42",
"created_at": now,
"payload": {
"drawing_payload": {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"properties": {"label": "历史运行几何"},
"geometry": {
"type": "Point",
"coordinates": [121.4, 30.8],
},
}],
},
"internal_debug": "must-not-leak",
},
}]
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", analysis_run)
monkeypatch.setattr(
workbench_runtime.AnalysisRepository,
"list_recent_results",
analysis_results,
)
response = TestClient(app).get(f"/api/v1/workbench/analysis-runs/{run_id}")
assert response.status_code == 200
payload = response.json()
assert payload["run_id"] == str(run_id)
assert payload["name"] == "历史爆管定位"
assert payload["results"][0]["facts"] == [
{"label": "关联管段", "value": "P-42"}
]
assert payload["map_payload"]["features"][0]["properties"] == {
"label": "历史运行几何"
}
assert "must-not-leak" not in response.text
def test_analysis_run_endpoint_projects_sensor_locations_as_map_points(monkeypatch):
app = build_test_app(workbench_runtime.router, "/api/v1")
configure_dependencies(app)
run_id = uuid4()
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
async def analysis_run(_conn, requested_run_id):
assert requested_run_id == run_id
return {
"run_id": run_id,
"name": "北区传感器布点",
"run_type": "sensor_placement",
"created_by": "alice",
"created_at": now,
"started_at": now,
"status": "completed",
"parameters": {},
}
async def analysis_results(_conn, requested_run_id, *, limit):
assert requested_run_id == run_id
assert limit == 100
return [{
"result_type": "sensor_placement",
"created_at": now,
"payload": {
"sensor_number": 2,
"minimum_diameter": 300,
"sensor_locations": ["J-2", "J-1"],
"internal_debug": "must-not-leak",
},
}]
async def junction_points(_conn, node_ids):
assert node_ids == ["J-2", "J-1"]
return [
{"node_id": "J-1", "longitude": 121.4, "latitude": 30.8},
{"node_id": "J-2", "longitude": 121.5, "latitude": 30.9},
]
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", analysis_run)
monkeypatch.setattr(
workbench_runtime.AnalysisRepository,
"list_recent_results",
analysis_results,
)
monkeypatch.setattr(
workbench_runtime.AnalysisRepository,
"list_junction_map_points",
junction_points,
)
response = TestClient(app).get(f"/api/v1/workbench/analysis-runs/{run_id}")
assert response.status_code == 200
payload = response.json()
assert [
feature["properties"]["node_id"]
for feature in payload["map_payload"]["features"]
] == ["J-2", "J-1"]
assert payload["map_payload"]["features"][0] == {
"type": "Feature",
"id": "J-2",
"properties": {
"label": "监测点 1 · J-2",
"node_id": "J-2",
"result_type": "sensor_placement",
"position": 1,
},
"geometry": {"type": "Point", "coordinates": [121.5, 30.9]},
}
assert "must-not-leak" not in response.text
def test_analysis_run_endpoint_returns_404_for_an_unknown_run(monkeypatch):
app = build_test_app(workbench_runtime.router, "/api/v1")
configure_dependencies(app)
async def missing_run(_conn, _run_id):
return None
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", missing_run)
response = TestClient(app).get(
f"/api/v1/workbench/analysis-runs/{uuid4()}"
)
assert response.status_code == 404
def test_runtime_endpoint_reads_work_order_and_alert_storage(monkeypatch):
app = build_test_app(workbench_runtime.router, "/api/v1")
configure_dependencies(app)
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
async def empty_runs(_conn, *, limit):
assert limit == 100
return []
async def operations_available(_conn):
return WorkbenchOperationsAvailability(
schedules=False,
work_orders=True,
alerts=True,
)
async def work_orders(_conn, project_id):
assert project_id == str(PROJECT_CONTEXT.project_id)
return [{
"id": "wo-1",
"code": "WO-1",
"scheduled_at": now,
"title": "阀门复核",
"summary": "现场复核阀门。",
"status": "completed",
"risk_level": "normal",
"updated_at": now,
"detail": "正式工单同步记录。",
"recommendation": None,
"duration_minutes": 30,
"source": "生产工单系统",
"dispatcher": "调度中心",
"priority": "normal",
"subtasks": [{
"id": "inspect-valve",
"title": "阀门复核",
"summary": "现场复核阀门。",
"status": "completed",
"scheduled_at": now,
"location": "V-1",
"assignee": "现场班组",
"duration_minutes": 30,
"reply_window_minutes": 15,
"stages": [
{"id": "dispatch", "sequence": 1, "title": "派发", "status": "done", "owner": "调度中心", "time_label": "10:30", "description": "已派发。"},
{"id": "execution", "sequence": 2, "title": "执行", "status": "done", "owner": "现场班组", "time_label": "10:30—11:00", "description": "已执行。"},
{"id": "reply", "sequence": 3, "title": "复令", "status": "done", "owner": "现场班组", "time_label": "11:00—11:15", "description": "已复令。"},
],
"reply_requirements": ["回传结果"],
}],
}]
async def alerts(_conn, project_id):
assert project_id == str(PROJECT_CONTEXT.project_id)
return [{
"alert_id": "alarm-1",
"condition_id": None,
"source": {
"task_type": "scheduled_task",
"task_id": "diagnosis-5m",
"run_id": "trigger-1",
},
"title": "泵站通信告警",
"description": "由诊断任务明确推送。",
"occurred_at": now,
"severity": "warning",
"status": "active",
}]
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs)
monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "availability", operations_available)
monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "list_work_orders", work_orders)
monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "list_alerts", alerts)
response = TestClient(app).get("/api/v1/workbench/runtime")
assert response.status_code == 200
payload = response.json()
assert payload["source"] == "none"
assert payload["schedule_availability"] == "not_configured"
assert payload["conditions"] == []
assert payload["work_order_availability"] == "ready"
assert payload["work_orders"][0]["code"] == "WO-1"
assert payload["work_orders"][0]["status"] == "completed"
assert payload["alert_availability"] == "ready"
assert payload["alerts"][0]["title"] == "泵站通信告警"
assert payload["alerts"][0]["source"] == {
"task_type": "scheduled_task",
"task_id": "diagnosis-5m",
"run_id": "trigger-1",
}
def test_runtime_endpoint_isolates_invalid_work_order_documents(monkeypatch):
app = build_test_app(workbench_runtime.router, "/api/v1")
configure_dependencies(app)
async def empty_runs(_conn, *, limit):
assert limit == 100
return []
async def operations_available(_conn):
return WorkbenchOperationsAvailability(
schedules=False,
work_orders=True,
alerts=False,
)
async def invalid_work_orders(_conn, _project_id):
return [{"id": "wo-invalid", "subtasks": []}]
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs)
monkeypatch.setattr(
workbench_runtime.WorkbenchOperationsRepository,
"availability",
operations_available,
)
monkeypatch.setattr(
workbench_runtime.WorkbenchOperationsRepository,
"list_work_orders",
invalid_work_orders,
)
response = TestClient(app).get("/api/v1/workbench/runtime")
assert response.status_code == 200
payload = response.json()
assert payload["source"] == "none"
assert payload["schedule_availability"] == "not_configured"
assert payload["work_order_availability"] == "degraded"
assert payload["availability"] == "degraded"
assert payload["work_orders"] == []
@@ -0,0 +1,102 @@
"""Opt-in persistence checks for the isolated generated demo dataset."""
import os
import subprocess
import sys
from uuid import uuid4
import psycopg
import pytest
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.core.config import settings
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.errors import SampleFailure
from samples.peripheral.store import PostgresStore
pytestmark = pytest.mark.skipif(
os.getenv("PERIPHERAL_SAMPLE_LIVE_TEST") != "1",
reason="set PERIPHERAL_SAMPLE_LIVE_TEST=1 to use provisioned peripheral_sample storage",
)
def connection():
return psycopg.connect(
host=settings.DB_HOST, port=settings.DB_PORT, user=settings.DB_USER,
password=settings.DB_PASSWORD, dbname="tjwater_v2", connect_timeout=3,
options="-c statement_timeout=10000", row_factory=dict_row,
)
@pytest.fixture
def live_demo_project():
project_id = uuid4()
content = build_demo_content("live-" + str(project_id))
revision = digest(content)
with connection() as conn:
conn.execute(
"""INSERT INTO peripheral_sample.demo_datasets
(project_id,dataset_id,digest,document) VALUES (%s,'water-demo-v1',%s,%s)""",
(project_id, revision, Jsonb(content)),
)
try:
yield project_id, content, revision
finally:
with connection() as conn:
conn.execute("DELETE FROM peripheral_sample.demo_datasets WHERE project_id=%s", (project_id,))
conn.execute("DELETE FROM peripheral_sample.scenarios WHERE project_id=%s", (project_id,))
def test_persisted_demo_is_read_from_fresh_connection_and_process(live_demo_project):
project_id, content, revision = live_demo_project
store = PostgresStore(SampleSettings(db_name="tjwater_v2"))
result = store.demo_data(Identity(str(project_id), str(uuid4()), False))
assert result["source_revision"] == revision
assert result["observations"] == content["observations"]
script = """
import sys
from samples.peripheral.auth import Identity
from samples.peripheral.config import SampleSettings
from samples.peripheral.store import PostgresStore
result = PostgresStore(SampleSettings(db_name='tjwater_v2')).demo_data(Identity(sys.argv[1], sys.argv[2], False))
assert result['source_revision'] == sys.argv[3] and len(result['observations']) == 240
print('ok')
"""
child = subprocess.run(
[sys.executable, "-c", script, str(project_id), str(uuid4()), revision],
check=True, capture_output=True, text=True,
)
assert child.stdout.strip() == "ok"
def test_demo_read_is_project_isolated_and_never_calls_generator(live_demo_project, monkeypatch):
project_id, _, _ = live_demo_project
monkeypatch.setattr(
"samples.peripheral.demo_data.build_demo_content",
lambda *args, **kwargs: pytest.fail("persisted reads must not invoke the generator"),
)
store = PostgresStore(SampleSettings(db_name="tjwater_v2"))
assert store.demo_data(Identity(str(project_id), str(uuid4()), False))["project_id"] == str(project_id)
with pytest.raises(SampleFailure) as missing:
store.demo_data(Identity(str(uuid4()), str(uuid4()), False))
assert missing.value.code == "SOURCE_NOT_CONFIGURED"
def test_tampered_document_is_rejected_and_cleanup_remains_scoped(live_demo_project):
project_id, _, _ = live_demo_project
with connection() as conn:
conn.execute(
"""UPDATE peripheral_sample.demo_datasets
SET document=jsonb_set(document,'{seed}',to_jsonb(%s::text))
WHERE project_id=%s AND dataset_id='water-demo-v1'""",
("tampered", project_id),
)
store = PostgresStore(SampleSettings(db_name="tjwater_v2"))
with pytest.raises(SampleFailure) as rejected:
store.demo_data(Identity(str(project_id), str(uuid4()), False))
assert rejected.value.code == "INTEGRITY_MISMATCH"
@@ -0,0 +1,355 @@
"""Opt-in checks against provisioned sample tables, never business-table writes."""
from concurrent.futures import ThreadPoolExecutor
import os
import subprocess
import sys
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from samples.peripheral import schemas as peripheral_schemas
from samples.peripheral.api import create_app
from samples.peripheral.auth import Identity, identity
from samples.peripheral.config import SampleSettings
from samples.peripheral.errors import SampleFailure
from samples.peripheral.schemas import (
AddFeedback,
CreateTask,
CreateTestDataRecord,
CreateWorkOrder,
DeleteResource,
Observations,
ScenarioCommand,
Task,
Transition,
UpdateTestDataRecord,
UpdateWorkOrder,
WorkOrder,
)
from samples.peripheral.store import PostgresStore
pytestmark = pytest.mark.skipif(os.getenv("PERIPHERAL_SAMPLE_LIVE_TEST") != "1", reason="explicit live sample opt-in required")
@pytest.fixture
def sample():
store = PostgresStore(SampleSettings(enable_faults=True))
who = Identity(str(uuid4()), str(uuid4()), True)
yield store, who
# Only this test's unguessable project key; tables created by the authorized setup.
with store.connection() as conn:
for table in (
"work_order_revisions", "test_data_record_revisions", "work_orders",
"test_data_records", "feedback", "requests", "revisions", "tasks", "scenarios",
):
from psycopg import sql
exists = conn.execute(
"SELECT to_regclass(%s) AS name", (f"peripheral_sample.{table}",)
).fetchone()["name"]
if exists is not None:
conn.execute(
sql.SQL("DELETE FROM peripheral_sample.{} WHERE project_id=%s").format(
sql.Identifier(table)
),
(who.project_id,),
)
def command():
return CreateTask.model_validate({
"request_id": str(uuid4()), "title": "AUTOTEST isolated sample", "summary": "not a production source",
"source": {"system": "next", "task_id": str(uuid4()), "object_type": "validation", "object_id": str(uuid4()), "revision": 1},
"asset_ids": ["test-pressure"], "analysis_window": {"start": "2026-04-30T08:00:00Z", "end": "2026-04-30T09:00:00Z"},
"evidence_refs": [], "requested_check": "verification only",
})
def move(store, who, task, status):
return store.transition(who, task["task_id"], Transition(request_id=uuid4(), expected_revision=task["revision"], target_status=status, reason="AUTOTEST"))
def feedback(task):
return AddFeedback.model_validate({"request_id": str(uuid4()), "expected_revision": task["revision"], "feedback_id": str(uuid4()),
"occurred_at": "2026-04-30T08:30:00Z", "applicable_time": task["analysis_window"], "asset_ids": task["asset_ids"],
"finding": "instrument_fault", "notes": "AUTOTEST synthetic only", "attachment_refs": []})
def scenario(store, who, name):
return store.set_scenario(who, ScenarioCommand(request_id=uuid4(), scenario=name))
def test_persistence_transitions_feedback_idempotency_and_isolation(sample):
store, who = sample
cmd = command()
task = store.create_task(who, cmd)
Task.model_validate(task)
fresh = PostgresStore(store.settings)
assert fresh.get_task(who, task["task_id"]) == task
# A new interpreter has no service memory or open DB connections to reuse.
probe = subprocess.run([sys.executable, "-c",
"import sys; from samples.peripheral.store import PostgresStore; "
"from samples.peripheral.config import SampleSettings; from samples.peripheral.auth import Identity; "
"print(PostgresStore(SampleSettings()).get_task(Identity(sys.argv[1],sys.argv[2],True),sys.argv[3])['revision'])",
who.project_id, who.user_id, task["task_id"]], capture_output=True, text=True, timeout=15, check=True)
assert probe.stdout.strip() == "1"
assert fresh.create_task(who, cmd) == task
with pytest.raises(SampleFailure, match="同一 request_id"):
store.create_task(who, cmd.model_copy(update={"title": "changed"}))
outsider = Identity(str(uuid4()), who.user_id, True)
with pytest.raises(SampleFailure) as exc:
store.get_task(outsider, task["task_id"])
assert exc.value.code == "TASK_NOT_FOUND"
with pytest.raises(SampleFailure):
store.receipt(Identity(who.project_id, str(uuid4()), True), cmd.request_id)
with pytest.raises(SampleFailure):
store.create_task(Identity(who.project_id, who.user_id, False), command())
with pytest.raises(SampleFailure):
move(store, who, task, "completed")
task = move(store, who, task, "processing")
stale = task.copy()
cmd_feedback = feedback(task)
task = store.add_feedback(who, task["task_id"], cmd_feedback)
repeated = cmd_feedback.model_copy(update={"request_id": uuid4()})
assert store.add_feedback(who, task["task_id"], repeated) == task
with pytest.raises(SampleFailure) as exc:
store.add_feedback(who, task["task_id"], repeated.model_copy(update={"request_id": uuid4(), "notes": "different"}))
assert exc.value.code == "FEEDBACK_CONFLICT"
with pytest.raises(SampleFailure) as exc:
move(store, who, stale, "pending_review")
assert exc.value.code == "REVISION_CONFLICT"
task = move(store, who, task, "pending_review")
task = move(store, who, task, "processing")
task = move(store, who, task, "pending_review")
task = move(store, who, task, "completed")
assert len(task["feedback"]) == 1
assert len(task["history"]) == 6
with store.connection(readonly=True) as conn:
assert conn.execute("SELECT count(*) AS n FROM peripheral_sample.revisions WHERE project_id=%s", (who.project_id,)).fetchone()["n"] == task["revision"]
def test_concurrent_same_request_and_revision_conflict(sample):
store, who = sample
cmd = command()
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda _: store.create_task(who, cmd), range(4)))
assert len({r["task_id"] for r in results}) == 1
task = results[0]
def race(_):
try:
return move(store, who, task, "processing")["revision"]
except SampleFailure as exc:
return exc.code
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = list(pool.map(race, range(2)))
assert sorted(map(str, outcomes)) == ["2", "REVISION_CONFLICT"]
def test_fault_receipt_recovery_duplicate_conflict_and_late_state(sample):
store, who = sample
scenario(store, who, "response_lost")
cmd = command()
with pytest.raises(SampleFailure) as exc:
store.create_task(who, cmd)
assert exc.value.effect == "unknown"
task = store.receipt(who, cmd.request_id)["result"]
assert store.create_task(who, cmd) == task
scenario(store, who, "unavailable")
with pytest.raises(SampleFailure):
store.get_task(who, task["task_id"])
assert store.receipt(who, cmd.request_id)["task_id"] == task["task_id"]
scenario(store, who, "conflicting_feedback")
task = move(store, who, task, "processing")
task = store.add_feedback(who, task["task_id"], feedback(task))
assert {f["finding"] for f in task["feedback"]} == {"instrument_fault", "no_instrument_fault"}
scenario(store, who, "duplicate_feedback")
assert len(store.get_task(who, task["task_id"])["feedback"]) == 3
scenario(store, who, "late_state")
assert store.get_task(who, task["task_id"])["revision"] == task["revision"] - 1
scenario(store, who, "normal")
assert store.get_task(who, task["task_id"]) == task
disabled = PostgresStore(SampleSettings(enable_faults=False))
with pytest.raises(SampleFailure) as exc:
scenario(disabled, who, "missing_data")
assert exc.value.code == "FAULTS_DISABLED"
def test_historical_monitoring_readonly_mapping_and_missing_data(sample):
store, who = sample
points = store.points(who, limit=500)
assert points["origin"] == "historical_replay"
assert points["items"]
point = points["items"][0]
kwargs = {"point_id": point["point_id"], "start": "2026-04-30T08:00:00Z", "end": "2026-04-30T16:00:00Z"}
normal = store.observations(who, **kwargs)
Observations.model_validate(normal)
assert normal["items"]
assert {x["unit"] for x in normal["items"]} == {point["unit"]}
with store.connection(readonly=True, timeseries=True) as conn:
assert conn.execute("SHOW transaction_read_only").fetchone()["transaction_read_only"] == "on"
scenario(store, who, "missing_data")
missing = store.observations(who, **kwargs)
assert missing["origin"] == "synthetic"
assert all(item["value"] is None for item in missing["items"][::2])
assert store.alerts(who)["availability"] == "not_configured"
def test_managed_work_orders_and_test_data_persist_revision_and_isolation(sample):
store, who = sample
capabilities = store.managed_resource_capabilities()
if capabilities != {"work_orders": "available", "test_data": "available"}:
pytest.skip("managed peripheral resource tables are not provisioned")
order_command = CreateWorkOrder(
request_id=uuid4(),
title="演示 live test work order",
summary="AUTOTEST isolated synthetic resource",
status="open",
priority="high",
assignee="AUTOTEST",
scheduled_at="2026-09-18T01:00:00Z",
)
order = store.create_work_order(who, order_command)
WorkOrder.model_validate(order)
assert PostgresStore(store.settings).get_work_order(who, order["work_order_id"]) == order
assert store.create_work_order(who, order_command) == order
order = store.update_work_order(who, order["work_order_id"], UpdateWorkOrder(
request_id=uuid4(), expected_revision=1, status="in_progress", priority="urgent",
))
assert order["revision"] == 2 and order["status"] == "in_progress"
with pytest.raises(SampleFailure) as stale_order:
store.update_work_order(who, order["work_order_id"], UpdateWorkOrder(
request_id=uuid4(), expected_revision=1, status="completed",
))
assert stale_order.value.code == "REVISION_CONFLICT"
data_command = CreateTestDataRecord(
request_id=uuid4(),
label="演示 live pressure",
point_id="演示",
observed_at="2026-09-18T00:00:00Z",
value=31.2,
unit="m",
quality="valid",
notes="AUTOTEST synthetic only",
)
record = store.create_test_data(who, data_command)
peripheral_schemas.TestDataRecord.model_validate(record)
assert PostgresStore(store.settings).get_test_data(who, record["record_id"]) == record
assert store.create_test_data(who, data_command) == record
record = store.update_test_data(who, record["record_id"], UpdateTestDataRecord(
request_id=uuid4(), expected_revision=1, value=None, clear_value=True, quality="missing",
))
assert record["revision"] == 2 and record["value"] is None
outsider = Identity(str(uuid4()), who.user_id, True)
assert store.list_work_orders(outsider, 20)["items"] == []
assert store.list_test_data(outsider, 20)["items"] == []
assert [item["work_order_id"] for item in store.list_work_orders(who, 20)["items"]] == [
order["work_order_id"]
]
assert [item["record_id"] for item in store.list_test_data(who, 20)["items"]] == [
record["record_id"]
]
deleted_order = store.delete_work_order(who, order["work_order_id"], DeleteResource(
request_id=uuid4(), expected_revision=2,
))
deleted_data = store.delete_test_data(who, record["record_id"], DeleteResource(
request_id=uuid4(), expected_revision=2,
))
assert deleted_order["revision"] == 3 and deleted_data["revision"] == 3
assert store.list_work_orders(who, 20)["items"] == []
assert store.list_test_data(who, 20)["items"] == []
with store.connection(readonly=True) as conn:
order_revisions = conn.execute(
"""SELECT count(*) AS n FROM peripheral_sample.work_order_revisions
WHERE project_id=%s AND work_order_id=%s""",
(who.project_id, order["work_order_id"]),
).fetchone()["n"]
data_revisions = conn.execute(
"""SELECT count(*) AS n FROM peripheral_sample.test_data_record_revisions
WHERE project_id=%s AND record_id=%s""",
(who.project_id, record["record_id"]),
).fetchone()["n"]
assert order_revisions == 3 and data_revisions == 3
def test_managed_resource_http_routes_use_provisioned_storage(sample):
store, who = sample
capabilities = store.managed_resource_capabilities()
if capabilities != {"work_orders": "available", "test_data": "available"}:
pytest.skip("managed peripheral resource tables are not provisioned")
app = create_app(store.settings)
app.dependency_overrides[identity] = lambda: who
headers = {
"Authorization": "Bearer TEST-FIXTURE-ONLY",
"X-Project-Id": who.project_id,
}
with TestClient(app) as client:
order = client.post("/api/sample/v1/work-orders", headers=headers, json={
"request_id": str(uuid4()),
"title": "演示 HTTP test work order",
"summary": "AUTOTEST isolated synthetic resource",
"status": "open",
"priority": "normal",
"assignee": None,
"scheduled_at": None,
})
assert order.status_code == 201, order.text
order_document = order.json()
listed_orders = client.get("/api/sample/v1/work-orders", headers=headers)
assert [item["work_order_id"] for item in listed_orders.json()["items"]] == [
order_document["work_order_id"]
]
changed_order = client.patch(
f"/api/sample/v1/work-orders/{order_document['work_order_id']}",
headers=headers,
json={
"request_id": str(uuid4()), "expected_revision": 1,
"status": "in_progress",
},
)
assert changed_order.status_code == 200 and changed_order.json()["revision"] == 2
record = client.post("/api/sample/v1/test-data", headers=headers, json={
"request_id": str(uuid4()),
"label": "演示 HTTP pressure",
"point_id": "演示",
"observed_at": "2026-09-18T00:00:00Z",
"value": 31.2,
"unit": "m",
"quality": "valid",
"notes": "AUTOTEST synthetic only",
})
assert record.status_code == 201, record.text
record_document = record.json()
listed_data = client.get("/api/sample/v1/test-data", headers=headers)
assert [item["record_id"] for item in listed_data.json()["items"]] == [
record_document["record_id"]
]
changed_data = client.patch(
f"/api/sample/v1/test-data/{record_document['record_id']}",
headers=headers,
json={
"request_id": str(uuid4()), "expected_revision": 1,
"value": 30.8,
},
)
assert changed_data.status_code == 200 and changed_data.json()["revision"] == 2
deleted_order = client.request(
"DELETE",
f"/api/sample/v1/work-orders/{order_document['work_order_id']}",
headers=headers,
json={"request_id": str(uuid4()), "expected_revision": 2},
)
deleted_data = client.request(
"DELETE",
f"/api/sample/v1/test-data/{record_document['record_id']}",
headers=headers,
json={"request_id": str(uuid4()), "expected_revision": 2},
)
assert deleted_order.status_code == 200 and deleted_order.json()["revision"] == 3
assert deleted_data.status_code == 200 and deleted_data.json()["revision"] == 3
@@ -0,0 +1,93 @@
"""Opt-in PostgreSQL checks for the task alert event state machine."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from psycopg import AsyncConnection
from psycopg.rows import dict_row
from app.core.config import settings
from app.domain.schemas.task_alert import TaskAlertEvent
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
from app.services.task_alerts import TaskAlertService
pytestmark = [
pytest.mark.skipif(
os.getenv("TASK_ALERT_LIVE_TEST") != "1",
reason="set TASK_ALERT_LIVE_TEST=1 to use provisioned workbench alert tables",
),
pytest.mark.anyio,
]
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
async def test_alert_event_round_trip_and_idempotency():
project_id = uuid4()
now = datetime(2026, 9, 18, 3, tzinfo=timezone.utc)
conn = await AsyncConnection.connect(
host=settings.DB_HOST,
port=settings.DB_PORT,
user=settings.DB_USER,
password=settings.DB_PASSWORD,
dbname="tjwater_v2",
connect_timeout=3,
options="-c statement_timeout=10000",
row_factory=dict_row,
)
try:
service = TaskAlertService(
TaskAlertRepository(conn, project_id),
None,
clock=lambda: now,
internal_producer_id="AUTOTEST-scheduler",
)
event = TaskAlertEvent(
event_id=uuid4(),
producer_id="AUTOTEST-scheduler",
alert_id=f"AUTOTEST-{uuid4()}",
event_type="raised",
occurred_at=now,
condition_id=None,
source={
"task_type": "scheduled_task",
"task_id": "AUTOTEST-schedule",
"run_id": "AUTOTEST-occurrence",
},
title="AUTOTEST alert",
description="Synthetic integration record",
severity="warning",
)
first = await service.push(event)
repeated = await service.push(event)
assert first == repeated
row = await conn.execute(
"""SELECT revision,status,document FROM workbench.alerts
WHERE project_id=%s AND alert_id=%s""",
(project_id, event.alert_id),
)
stored = await row.fetchone()
assert stored["revision"] == 1 and stored["status"] == "active"
assert stored["document"]["producer_id"] == "AUTOTEST-scheduler"
finally:
# Assertion reads open an implicit transaction. End it before starting the
# cleanup transaction, otherwise closing the connection rolls the nested
# cleanup savepoint back together with that read transaction.
await conn.rollback()
async with conn.transaction():
await conn.execute(
"DELETE FROM workbench.alert_events WHERE project_id=%s", (project_id,)
)
await conn.execute(
"DELETE FROM workbench.alerts WHERE project_id=%s", (project_id,)
)
await conn.close()
+31
View File
@@ -44,3 +44,34 @@ def test_project_workspace_sets_30_day_client_cache(monkeypatch):
assert layers == geoserver_client.PROJECT_LAYER_NAMES
assert cache_updates == ["2592000"] * len(layers)
def test_publish_feature_type_updates_existing_layer_and_cache(monkeypatch):
requests: list[tuple[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path))
if request.method == "GET" and request.url.path.endswith("/featuretypes/demo_layer.json"):
return httpx.Response(200, json={"featureType": {"name": "demo_layer"}})
if request.method == "GET" and "/gwc/rest/layers/" in request.url.path:
return httpx.Response(200, content=b"<GeoServerLayer><name>demo</name></GeoServerLayer>")
return httpx.Response(200)
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_USERNAME", "admin")
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_PASSWORD", "secret")
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_CLIENT_CACHE_SECONDS", 2_592_000)
http_client = httpx.Client(
base_url="http://geoserver.example/geoserver",
transport=httpx.MockTransport(handler),
)
geoserver_client.GeoServerAdminClient(client=http_client).publish_feature_type(
workspace="demo",
datastore="demo",
layer="demo_layer",
title="Demo layer",
map_bbox=(1.0, 2.0, 3.0, 4.0),
)
assert ("PUT", "/geoserver/rest/workspaces/demo/datastores/demo/featuretypes/demo_layer") in requests
assert ("PUT", "/geoserver/gwc/rest/layers/demo:demo_layer.xml") in requests
@@ -0,0 +1,45 @@
import asyncio
from datetime import date
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
from tests.conftest import FakeConnection, FakeCursor
def test_list_element_groups_returns_empty_when_source_gis_is_not_provisioned():
cursor = FakeCursor(fetchone_results=[{"relation": None}])
result = asyncio.run(
GisAttributeRepository.list_element_groups(
FakeConnection(cursor), "pipes", "P-1"
)
)
assert result == []
assert len(cursor.executed) == 1
def test_list_element_groups_reads_linked_attributes():
row = {
"dataset_key": "fx_pipe_inventory_20200811",
"dataset_title": "奉贤 DN100 及以上管网台账",
"source_date": date(2020, 8, 11),
"layer_key": "fx_20200811_dn100_pipes",
"layer_name": "DN100 及以上管线台账",
"source_fid": 42,
"match_method": "geometry",
"match_distance_m": 0.12,
"attributes": {"PIPE_D": 300, "MATERIAL": "DI"},
}
cursor = FakeCursor(
fetchone_results=[{"relation": "source_gis.element_links"}],
fetchall_results=[[row]],
)
result = asyncio.run(
GisAttributeRepository.list_element_groups(
FakeConnection(cursor), "pipes", "P-42"
)
)
assert result == [row]
assert cursor.executed[1][1] == ("pipes", "P-42")
+154
View File
@@ -0,0 +1,154 @@
import json
import inspect
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
import pytest
from fastapi.testclient import TestClient
from app.services.content_digest import digest
from samples.peripheral import auth
from samples.peripheral.api import create_app, store
from samples.peripheral.config import SampleSettings
from samples.peripheral.demo_data import VERSION, build_demo_content
from samples.peripheral.errors import SampleFailure
from samples.peripheral.store import PostgresStore
START = datetime(2026, 9, 17, tzinfo=timezone.utc)
def test_same_seed_start_and_version_are_stable_across_processes():
content = build_demo_content("repeatable", START)
assert content == build_demo_content("repeatable", START)
assert content["generator_version"] == VERSION
expected_digest = digest(content)
script = """
import json
from datetime import datetime
from app.services.content_digest import digest
from samples.peripheral.demo_data import build_demo_content
content = build_demo_content('repeatable', datetime.fromisoformat('2026-09-17T00:00:00+00:00'))
print(json.dumps({'digest': digest(content), 'ids': [x['record_id'] for x in content['observations'][:3]]}))
"""
result = subprocess.run(
[sys.executable, "-c", script], check=True, capture_output=True, text=True,
)
child = json.loads(result.stdout)
assert child["digest"] == expected_digest
assert child["ids"] == [item["record_id"] for item in content["observations"][:3]]
assert all(UUID(item["record_id"]).version == 5 for item in content["observations"])
def test_default_canonical_digest_is_versioned_regression_anchor():
content = build_demo_content()
assert digest(content) == "sha256:c630f66ad0247f70a4afe79cc5f95f4be35d54acfe85fbfe39f3166ee09a57c5"
assert (len(content["points"]), len(content["observations"]), len(content["device_states"])) == (5, 240, 9)
def test_seed_changes_content_and_timezone_is_normalized():
base = build_demo_content("seed-a", START)
assert digest(base) != digest(build_demo_content("seed-b", START))
same_in_china = build_demo_content("seed-a", START.astimezone(timezone(timedelta(hours=8))))
assert same_in_china == base
assert base["window"]["start"] == "2026-09-17T00:00:00Z"
@pytest.mark.parametrize("seed", ["", "x" * 129, None, 7])
def test_invalid_seed_is_rejected(seed):
with pytest.raises(ValueError, match="seed"):
build_demo_content(seed, START)
@pytest.mark.parametrize("start", [
datetime(2026, 9, 17),
datetime(2026, 9, 17, 0, 1, tzinfo=timezone.utc),
datetime(2026, 9, 17, 0, 0, 1, tzinfo=timezone.utc),
])
def test_invalid_start_is_rejected(start):
with pytest.raises(ValueError, match="timezone|aligned"):
build_demo_content("seed", start)
def test_story_counts_null_zero_and_temporal_links_are_explicit():
content = build_demo_content("story", START)
assert len(content["points"]) == 5
assert len(content["observations"]) == 240
assert len(content["device_states"]) == 9
assert len(content["alerts"]) == 2
assert len(content["operations"]) == 4
missing = [item for item in content["observations"] if item["value"] is None]
zero = [item for item in content["observations"] if item["value"] == 0.0]
assert len(missing) == 4 and all(item["quality"] == "missing" for item in missing)
assert len(zero) == 4 and all(item["quality"] == "valid" for item in zero)
states = {(item["asset_id"], item["state"]): item["observed_at"] for item in content["device_states"]}
communication = next(item for item in content["alerts"] if item["code"] == "communication_lost")
assert communication["asset_id"] == "DEMO-P001"
assert communication["occurred_at"] == states[("DEMO-P001", "offline")]
assert communication["cleared_at"] == states[("DEMO-P001", "online")]
operations = {(item["asset_id"], item["operation"]): item["occurred_at"] for item in content["operations"]}
assert operations[("DEMO-PUMP-01", "pump_stop")] == states[("DEMO-PUMP-01", "stopped")]
assert operations[("DEMO-VALVE-01", "valve_close")] == states[("DEMO-VALVE-01", "closed")]
assert len({item["record_id"] for item in content["observations"] + content["device_states"] + content["operations"]}) == 253
class FakeStore:
def __init__(self, result=None, failure=None):
self.result = result
self.failure = failure
self.reads = 0
def demo_data(self, who):
self.reads += 1
if self.failure:
raise self.failure
return {**self.result, "project_id": who.project_id,
"source_id": "test:demo", "source_revision": digest(self.result)}
def test_demo_endpoint_uses_next_auth_and_allows_read_only_permission(monkeypatch):
project_id, user_id = uuid4(), uuid4()
fake = FakeStore(build_demo_content("api", START))
app = create_app(SampleSettings(project_code="tjwater_v2"))
app.dependency_overrides[store] = lambda: fake
monkeypatch.setattr(auth, "next_get", lambda request, path, params=None: {
"project_id": str(project_id), "user_id": str(user_id), "network": "tjwater_v2",
"permissions": ["simulation.view"],
})
response = TestClient(app).get("/api/sample/v1/demo-data", headers={
"Authorization": "Bearer TEST", "X-Project-Id": str(project_id),
})
assert response.status_code == 200
assert response.json()["project_id"] == str(project_id)
assert fake.reads == 1
def test_demo_endpoint_requires_auth_and_reports_missing_storage_without_seeding():
fake = FakeStore(failure=SampleFailure(
"STORAGE_NOT_CONFIGURED", "样板存储表尚未配置", 503,
))
app = create_app(SampleSettings(project_code="tjwater_v2"))
app.dependency_overrides[store] = lambda: fake
client = TestClient(app)
unauthorized = client.get("/api/sample/v1/demo-data")
assert unauthorized.status_code == 401
assert fake.reads == 0
# Authentication is deliberately isolated here so this assertion tests the
# missing-table behavior: the API reports it and never invokes a generator.
from samples.peripheral.api import identity
from samples.peripheral.auth import Identity
app.dependency_overrides[identity] = lambda: Identity(str(uuid4()), str(uuid4()), False)
unavailable = client.get("/api/sample/v1/demo-data")
assert unavailable.status_code == 503
assert unavailable.json()["code"] == "STORAGE_NOT_CONFIGURED"
assert fake.reads == 1
def test_demo_read_path_contains_no_schema_or_seed_writes():
source = inspect.getsource(PostgresStore.demo_data).upper()
assert "SELECT " in source
assert all(keyword not in source for keyword in ("CREATE ", "ALTER ", "INSERT ", "UPDATE ", "DELETE "))
@@ -0,0 +1,129 @@
from uuid import uuid4
from fastapi.testclient import TestClient
from samples.peripheral.api import create_app, store
from samples.peripheral.auth import Identity, identity
from samples.peripheral.schemas import CreateTestDataRecord, UpdateTestDataRecord, UpdateWorkOrder
PROJECT_ID = uuid4()
USER_ID = uuid4()
class ManagedStore:
def __init__(self):
self.calls = []
def list_work_orders(self, who, limit, cursor):
self.calls.append(("list_work_orders", who, limit, cursor))
return {"items": [], "next_cursor": None}
def create_work_order(self, who, command):
self.calls.append(("create_work_order", who, command))
now = "2026-09-18T00:00:00Z"
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),
}
def update_work_order(self, who, work_order_id, command):
self.calls.append(("update_work_order", who, work_order_id, command))
return self.create_work_order(who, type("Command", (), {"model_dump": lambda self, **kwargs: {
"title": command.title or "updated", "summary": command.summary or "summary",
"status": command.status or "open", "priority": command.priority or "normal",
"assignee": None, "scheduled_at": None}})()) | {"revision": command.expected_revision + 1,
"work_order_id": str(work_order_id)}
def delete_work_order(self, who, work_order_id, command):
self.calls.append(("delete_work_order", who, work_order_id, command))
return {"resource_type": "work_order", "resource_id": str(work_order_id),
"revision": command.expected_revision + 1, "deleted": True}
def list_test_data(self, who, limit, cursor):
self.calls.append(("list_test_data", who, limit, cursor))
return {"items": [], "next_cursor": None}
def create_test_data(self, who, command):
self.calls.append(("create_test_data", who, command))
now = "2026-09-18T00:00:00Z"
return command.model_dump(mode="json", exclude={"request_id"}) | {
"record_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),
}
def update_test_data(self, who, record_id, command):
self.calls.append(("update_test_data", who, record_id, command))
return self.create_test_data(who, CreateTestDataRecord(
request_id=command.request_id, label=command.label or "updated", point_id=command.point_id or "演示",
observed_at=command.observed_at or "2026-09-18T00:00:00Z", value=command.value,
unit=command.unit or "m", quality=command.quality or "valid", notes=command.notes or "",
)) | {"revision": command.expected_revision + 1, "record_id": str(record_id)}
def delete_test_data(self, who, record_id, command):
self.calls.append(("delete_test_data", who, record_id, command))
return {"resource_type": "test_data_record", "resource_id": str(record_id),
"revision": command.expected_revision + 1, "deleted": True}
def client_with(fake):
app = create_app()
app.dependency_overrides[store] = lambda: fake
app.dependency_overrides[identity] = lambda: Identity(str(PROJECT_ID), str(USER_ID), True)
return TestClient(app)
def test_managed_work_order_and_data_routes_are_typed_crud():
fake = ManagedStore()
with client_with(fake) as client:
assert client.get("/api/sample/v1/work-orders").json()["items"] == []
created = client.post("/api/sample/v1/work-orders", json={
"request_id": str(uuid4()), "title": "演示 测试工单", "summary": "外围联调",
"status": "open", "priority": "high", "assignee": None, "scheduled_at": None,
})
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
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": "in_progress",
"clear_assignee": False, "clear_scheduled_at": False,
})
assert changed.status_code == 200 and changed.json()["revision"] == 2
deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={
"request_id": str(uuid4()), "expected_revision": 2,
})
assert deleted.json()["resource_type"] == "work_order"
assert client.get("/api/sample/v1/test-data").json()["items"] == []
data = client.post("/api/sample/v1/test-data", json={
"request_id": str(uuid4()), "label": "演示 压力", "point_id": "演示",
"observed_at": "2026-09-18T00:00:00Z", "value": 31.2, "unit": "m",
"quality": "valid", "notes": "联调",
})
assert data.status_code == 201 and data.json()["point_id"] == "演示"
record_id = data.json()["record_id"]
changed_data = client.patch(f"/api/sample/v1/test-data/{record_id}", json={
"request_id": str(uuid4()), "expected_revision": 1, "value": 30.8, "clear_value": False,
})
assert changed_data.status_code == 200 and changed_data.json()["revision"] == 2
deleted_data = client.request("DELETE", f"/api/sample/v1/test-data/{record_id}", json={
"request_id": str(uuid4()), "expected_revision": 2,
})
assert deleted_data.json()["resource_type"] == "test_data_record"
def test_managed_update_models_reject_empty_or_conflicting_changes():
request_id = uuid4()
for model, payload in (
(UpdateWorkOrder, {"request_id": request_id, "expected_revision": 1}),
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1}),
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1,
"value": 1.0, "clear_value": True}),
):
try:
model.model_validate(payload)
except ValueError:
pass
else:
raise AssertionError("invalid partial update was accepted")
@@ -40,7 +40,7 @@ def test_list_results_uses_typed_comparison_when_result_type_is_present():
query, params = conn.cursor_instance.calls[0]
assert "result_type = %s" in query
assert "%s IS NULL" not in query
assert params == (run_id, "leakage_identification")
assert params == (run_id, "leakage_identification", 100, 0)
def test_list_results_omits_result_type_filter_when_not_requested():
@@ -51,4 +51,56 @@ def test_list_results_omits_result_type_filter_when_not_requested():
query, params = conn.cursor_instance.calls[0]
assert "result_type = %s" not in query
assert params == (run_id,)
assert params == (run_id, 100, 0)
def test_list_runs_and_results_apply_explicit_page_bounds():
conn = _FakeConnection()
run_id = uuid4()
asyncio.run(AnalysisRepository.list_runs(conn, limit=25, offset=50))
asyncio.run(
AnalysisRepository.list_results(conn, run_id, limit=40, offset=80)
)
run_query, run_params = conn.cursor_instance.calls[0]
result_query, result_params = conn.cursor_instance.calls[1]
assert "LIMIT %s OFFSET %s" in run_query
assert run_params == (25, 50)
assert "LIMIT %s OFFSET %s" in result_query
assert result_params == (run_id, 40, 80)
def test_list_recent_runs_is_bounded_and_orders_newest_first():
conn = _FakeConnection()
asyncio.run(AnalysisRepository.list_recent_runs(conn, limit=25))
query, params = conn.cursor_instance.calls[0]
assert "ORDER BY created_at DESC, run_id" in query
assert "LIMIT %s" in query
assert params == (25,)
def test_list_recent_results_bounds_latest_rows_then_restores_display_order():
conn = _FakeConnection()
run_id = uuid4()
asyncio.run(AnalysisRepository.list_recent_results(conn, run_id, limit=25))
query, params = conn.cursor_instance.calls[0]
assert "ORDER BY created_at DESC, result_id DESC" in query
assert "ORDER BY created_at, result_id" in query
assert params == (run_id, 25)
def test_list_run_summaries_is_bounded_and_checks_for_results():
conn = _FakeConnection()
asyncio.run(AnalysisRepository.list_run_summaries(conn, limit=11, offset=20))
query, params = conn.cursor_instance.calls[0]
assert "EXISTS" in query
assert "analysis.results" in query
assert "ORDER BY r.created_at DESC, r.run_id" in query
assert params == (11, 20)
@@ -159,9 +159,13 @@ def test_create_sensor_placement_writes_run_and_result_atomically(monkeypatch):
min_diameter=300,
created_by="alice",
sensor_locations=["J1", "J2"],
sensor_type="water_quality",
method="sensitivity",
)
assert created["sensor_count"] == 2
assert created["sensor_type"] == "water_quality"
assert created["method"] == "sensitivity"
statements = [call.args[0] for call in cursor.execute.call_args_list]
assert "INSERT INTO analysis.runs" in statements[0]
assert "INSERT INTO analysis.results" in statements[1]
+140
View File
@@ -0,0 +1,140 @@
from contextlib import asynccontextmanager
from copy import deepcopy
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from app.domain.domain_errors import DomainError
from app.domain.schemas.task_alert import TaskAlertEvent
from app.services.task_alerts import TaskAlertService
NOW = datetime(2026, 9, 18, 2, 30, tzinfo=timezone.utc)
USER_ID = uuid4()
@pytest.fixture
def anyio_backend():
return "asyncio"
class Repository:
def __init__(self):
self.events = {}
self.alerts = {}
@asynccontextmanager
async def transaction(self):
yield self
async def lock_alert(self, _alert_id):
return None
async def get_event(self, producer_id, event_id):
return deepcopy(self.events.get((producer_id, event_id)))
async def get_alert(self, alert_id):
value = self.alerts.get(alert_id)
return (value["revision"], deepcopy(value["document"])) if value else None
async def save_event(self, event, resulting_state):
self.events[(event["producer_id"], event["event_id"])] = deepcopy(
event | {"resulting_state": resulting_state}
)
async def save_alert(self, state):
self.alerts[state["alert_id"]] = {
"revision": state["revision"],
"document": deepcopy({key: value for key, value in state.items() if key != "revision"}),
}
def command(
event_type="raised",
*,
event_id=None,
title="Low pressure",
producer_id=f"principal:{USER_ID}",
):
return TaskAlertEvent(
event_id=event_id or uuid4(),
producer_id=producer_id,
alert_id="schedule-1:2026-09-18T02:30:00Z",
event_type=event_type,
occurred_at=NOW,
condition_id=None,
source={
"task_type": "scheduled_task",
"task_id": "schedule-1",
"run_id": "occurrence-1",
},
title=title,
description="Scheduled task reported a failure.",
severity="warning",
)
@pytest.mark.anyio
async def test_alert_events_are_idempotent_and_transitions_are_monotonic():
repository = Repository()
service = TaskAlertService(
repository,
SimpleNamespace(project_role="member", user_id=USER_ID),
clock=lambda: NOW,
)
raised_command = command()
raised = await service.push(raised_command)
repeated = await service.push(raised_command)
assert raised.status == repeated.status == "active"
assert raised.revision == repeated.revision == 1
acknowledged = await service.push(command("acknowledged"))
resolved = await service.push(command("resolved"))
assert acknowledged.status == "acknowledged" and acknowledged.revision == 2
assert resolved.status == "resolved" and resolved.revision == 3
with pytest.raises(DomainError) as invalid:
await service.push(command("acknowledged"))
assert invalid.value.code == "INVALID_TRANSITION"
@pytest.mark.anyio
async def test_alert_event_reuse_and_fact_rewrites_are_rejected():
repository = Repository()
service = TaskAlertService(
repository,
SimpleNamespace(project_role="member", user_id=USER_ID),
clock=lambda: NOW,
)
event_id = uuid4()
await service.push(command(event_id=event_id))
with pytest.raises(DomainError) as reused:
await service.push(command(event_id=event_id, title="Changed"))
assert reused.value.code == "IDEMPOTENCY_CONFLICT"
with pytest.raises(DomainError) as rewritten:
await service.push(command("resolved", title="Changed"))
assert rewritten.value.code == "IDEMPOTENCY_CONFLICT"
@pytest.mark.anyio
async def test_internal_alert_producer_is_bound_without_user_membership():
repository = Repository()
service = TaskAlertService(
repository,
None,
clock=lambda: NOW,
internal_producer_id="scheduler:demo",
)
state = await service.push(command(producer_id="scheduler:demo"))
assert state.status == "active"
mismatched = command(producer_id="scheduler:demo")
mismatched.producer_id = "another-task"
with pytest.raises(DomainError) as denied:
await service.push(mismatched)
assert denied.value.code == "PERMISSION_DENIED"
+284
View File
@@ -0,0 +1,284 @@
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.domain.schemas.workbench_runtime import WorkbenchPersistedAlert
from app.services.workbench_runtime import (
build_workbench_runtime_snapshot,
project_latest_analysis,
project_sensor_location_map_payload,
sensor_location_ids_for_map_projection,
)
NOW = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
@pytest.fixture
def anyio_backend():
return "asyncio"
def test_workbench_alert_rejects_naive_datetimes():
with pytest.raises(ValidationError):
WorkbenchPersistedAlert.model_validate({
"alert_id": "alert-1",
"condition_id": None,
"source": {
"task_type": "scheduled_task",
"task_id": "schedule-1",
"run_id": "occurrence-1",
},
"title": "test",
"description": "test",
"occurred_at": "2026-09-18T02:30:00",
"severity": "warning",
"status": "active",
})
@pytest.mark.anyio
async def test_snapshot_reports_not_configured_without_persisted_sections():
snapshot = await build_workbench_runtime_snapshot(
clock=lambda: NOW,
)
assert snapshot == {
"availability": "not_configured",
"source": "none",
"scenario_availability": "not_configured",
"artifact_availability": "not_configured",
"schedule_availability": "not_configured",
"work_order_availability": "not_configured",
"alert_availability": "not_configured",
"generated_at": NOW,
"conditions": [],
"alerts": [],
"scenarios": [],
"latest_analysis": None,
"work_orders": [],
}
def analysis_run(**overrides):
value = {
"run_id": uuid4(),
"name": "北区爆管定位",
"run_type": "burst_location",
"created_by": "alice",
"created_at": NOW,
"started_at": NOW,
"status": "completed",
"parameters": {},
}
value.update(overrides)
return value
@pytest.mark.anyio
async def test_snapshot_keeps_analysis_results_out_of_operational_conditions():
failed_analysis = analysis_run(status="failed")
result_created_at = NOW.replace(minute=31)
snapshot = await build_workbench_runtime_snapshot(
analysis_runs=[failed_analysis],
latest_analysis_results=[
{
"result_type": "burst_location",
"created_at": result_created_at,
"payload": {
"drawing_payload": {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"properties": {"label": "后端结果"},
"geometry": {
"type": "Point",
"coordinates": [121.5, 30.9],
},
}],
}
},
}
],
clock=lambda: NOW,
)
assert snapshot["source"] == "none"
assert snapshot["scenario_availability"] == "ready"
assert snapshot["artifact_availability"] == "ready"
assert snapshot["schedule_availability"] == "not_configured"
assert snapshot["work_order_availability"] == "not_configured"
assert snapshot["conditions"] == []
assert snapshot["scenarios"] == [
{
"id": f"analysis-run-{failed_analysis['run_id']}",
"run_id": str(failed_analysis["run_id"]),
"name": "北区爆管定位",
"description": "爆管定位 · 已失败 · 创建人 alice",
"status": "review",
"run_type": "burst_location",
"run_type_label": "爆管定位",
"run_status": "failed",
"created_at": NOW,
}
]
assert snapshot["latest_analysis"]["results"] == [
{
"result_type": "burst_location",
"created_at": result_created_at,
"facts": [],
}
]
assert snapshot["latest_analysis"]["map_payload"]["features"][0]["properties"] == {
"label": "后端结果"
}
assert snapshot["alerts"] == []
def test_latest_analysis_projects_only_known_result_facts():
projected = project_latest_analysis(
analysis_run(run_type="sensor_placement"),
[{
"result_type": "sensor_placement",
"node_id": "J-1",
"created_at": NOW,
"payload": {
"sensor_number": 2,
"minimum_diameter": 300,
"sensor_locations": ["J-1", "J-2"],
"internal_debug": "must-not-leak",
},
}],
)
assert projected["results"][0]["facts"] == [
{"label": "关联节点", "value": "J-1"},
{"label": "测点数量", "value": "2"},
{"label": "最小管径参数", "value": "300"},
{"label": "测点位置", "value": "J-1、J-2"},
]
assert "must-not-leak" not in str(projected)
def test_sensor_location_projection_uses_latest_result_order_and_known_nodes():
results = [
{
"result_type": "sensor_placement",
"payload": {"sensor_locations": ["J-old"]},
},
{
"result_type": "sensor_placement",
"payload": {"sensor_locations": ["J-2", "J-1", "J-2"]},
},
]
node_ids = sensor_location_ids_for_map_projection(results)
payload = project_sensor_location_map_payload(node_ids, [
{"node_id": "J-1", "longitude": 121.4, "latitude": 30.8},
{"node_id": "J-2", "longitude": 121.5, "latitude": 30.9},
])
assert node_ids == ["J-2", "J-1"]
assert payload is not None
assert [feature["id"] for feature in payload["features"]] == ["J-2", "J-1"]
@pytest.mark.anyio
async def test_snapshot_projects_persisted_work_orders_and_alerts():
work_order = {
"id": "wo-1",
"code": "WO-20260917-001",
"scheduled_at": NOW.replace(hour=3),
"title": "北区阀门现场复核",
"summary": "复核阀门开度并回传。",
"status": "running",
"risk_level": "attention",
"updated_at": NOW,
"detail": "来自正式工单同步适配器。",
"recommendation": "完成后复核压力。",
"duration_minutes": 45,
"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": ["回传阀门开度", "复核压力"],
}],
}
persisted_alert = {
"alert_id": "alarm-1",
"condition_id": None,
"source": {
"task_type": "scheduled_task",
"task_id": "scada-diagnosis-5m",
"run_id": "trigger-1",
},
"title": "北区设备通信告警",
"description": "由 SCADA 数据诊断任务明确推送。",
"occurred_at": NOW.replace(minute=29),
"severity": "warning",
"status": "active",
}
snapshot = await build_workbench_runtime_snapshot(
work_order_storage_configured=True,
work_orders=[work_order],
alert_storage_configured=True,
persisted_alerts=[persisted_alert],
clock=lambda: NOW,
)
assert snapshot["availability"] == "ready"
assert snapshot["source"] == "none"
assert snapshot["schedule_availability"] == "not_configured"
assert snapshot["work_order_availability"] == "ready"
assert snapshot["alert_availability"] == "ready"
assert snapshot["conditions"] == []
assert snapshot["alerts"] == [{
"id": "backend-alert-alarm-1",
"condition_id": None,
"source": persisted_alert["source"],
"title": "北区设备通信告警",
"description": "由 SCADA 数据诊断任务明确推送。",
"occurred_at": NOW.replace(minute=29),
"severity": "warning",
}]
assert snapshot["work_orders"][0]["code"] == "WO-20260917-001"