diff --git a/README.md b/README.md
index 0a109a2..4d24012 100644
--- a/README.md
+++ b/README.md
@@ -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 个水质监测点图层。
+
## 测试与发布
提交前根据改动范围运行最小有效测试:
diff --git a/app/api/v1/endpoints/project_data.py b/app/api/v1/endpoints/project_data.py
index 02fa3ce..ba56231 100644
--- a/app/api/v1/endpoints/project_data.py
+++ b/app/api/v1/endpoints/project_data.py
@@ -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
diff --git a/app/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py
index c65ef6f..dfc8cf0 100644
--- a/app/api/v1/endpoints/sensor_placement.py
+++ b/app/api/v1/endpoints/sensor_placement.py
@@ -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}
diff --git a/app/api/v1/endpoints/workbench_runtime.py b/app/api/v1/endpoints/workbench_runtime.py
new file mode 100644
index 0000000..0055812
--- /dev/null
+++ b/app/api/v1/endpoints/workbench_runtime.py
@@ -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
diff --git a/app/api/v1/router.py b/app/api/v1/router.py
index 1cd7a6d..e620ba2 100644
--- a/app/api/v1/router.py
+++ b/app/api/v1/router.py
@@ -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(
diff --git a/app/core/config.py b/app/core/config.py
index 273e612..89e3acc 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -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 = ""
diff --git a/app/domain/domain_errors.py b/app/domain/domain_errors.py
new file mode 100644
index 0000000..bf19de8
--- /dev/null
+++ b/app/domain/domain_errors.py
@@ -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)
diff --git a/app/domain/schemas/gis_attributes.py b/app/domain/schemas/gis_attributes.py
new file mode 100644
index 0000000..c1431c9
--- /dev/null
+++ b/app/domain/schemas/gis_attributes.py
@@ -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
diff --git a/app/domain/schemas/sensor_placement.py b/app/domain/schemas/sensor_placement.py
index 3852811..dd019e3 100644
--- a/app/domain/schemas/sensor_placement.py
+++ b/app/domain/schemas/sensor_placement.py
@@ -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
diff --git a/app/domain/schemas/shared.py b/app/domain/schemas/shared.py
new file mode 100644
index 0000000..be67f76
--- /dev/null
+++ b/app/domain/schemas/shared.py
@@ -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
diff --git a/app/domain/schemas/task_alert.py b/app/domain/schemas/task_alert.py
new file mode 100644
index 0000000..3b8a5b4
--- /dev/null
+++ b/app/domain/schemas/task_alert.py
@@ -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
diff --git a/app/domain/schemas/workbench_runtime.py b/app/domain/schemas/workbench_runtime.py
new file mode 100644
index 0000000..3b084e5
--- /dev/null
+++ b/app/domain/schemas/workbench_runtime.py
@@ -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]
diff --git a/app/infra/db/postgresql/analysis.py b/app/infra/db/postgresql/analysis.py
index 03da960..682805f 100644
--- a/app/infra/db/postgresql/analysis.py
+++ b/app/infra/db/postgresql/analysis.py
@@ -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()
diff --git a/app/infra/db/postgresql/gis_attributes.py b/app/infra/db/postgresql/gis_attributes.py
new file mode 100644
index 0000000..5fe1a5a
--- /dev/null
+++ b/app/infra/db/postgresql/gis_attributes.py
@@ -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
diff --git a/app/infra/db/postgresql/sensor_placement.py b/app/infra/db/postgresql/sensor_placement.py
index 31903ae..9aa704f 100644
--- a/app/infra/db/postgresql/sensor_placement.py
+++ b/app/infra/db/postgresql/sensor_placement.py
@@ -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():
diff --git a/app/infra/db/postgresql/task_alerts.py b/app/infra/db/postgresql/task_alerts.py
new file mode 100644
index 0000000..40aa438
--- /dev/null
+++ b/app/infra/db/postgresql/task_alerts.py
@@ -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"}),
+ ),
+ )
diff --git a/app/infra/db/postgresql/workbench_operations.py b/app/infra/db/postgresql/workbench_operations.py
new file mode 100644
index 0000000..aeb1ac2
--- /dev/null
+++ b/app/infra/db/postgresql/workbench_operations.py
@@ -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()]
diff --git a/app/infra/geoserver/client.py b/app/infra/geoserver/client.py
index 0efaff6..6438edb 100644
--- a/app/infra/geoserver/client.py
+++ b/app/infra/geoserver/client.py
@@ -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=(
+ ""
+ f"{workspace}:{layer}"
+ ""
+ ),
+ headers={"Content-Type": "text/xml"},
+ )
+
def create_project_workspace(
self,
*,
diff --git a/app/main.py b/app/main.py
index a71bd87..1b31c0b 100644
--- a/app/main.py
+++ b/app/main.py
@@ -28,13 +28,14 @@ async def lifespan(app: FastAPI):
logger.info("TJWater CloudService is starting...")
logger.info("**********************************************************")
- yield
- # 清理资源
- await project_connection_manager.close_all()
- close_all_timescale_pools()
- close_all_project_pools()
- await close_metadata_engine()
- logger.info("Database connections closed")
+ try:
+ yield
+ finally:
+ await project_connection_manager.close_all()
+ close_all_timescale_pools()
+ close_all_project_pools()
+ await close_metadata_engine()
+ logger.info("Database connections closed")
# 根据环境配置决定是否启用文档
@@ -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)
# 配置中间件
diff --git a/app/services/content_digest.py b/app/services/content_digest.py
new file mode 100644
index 0000000..1893b05
--- /dev/null
+++ b/app/services/content_digest.py
@@ -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()
diff --git a/app/services/sensor_placement.py b/app/services/sensor_placement.py
index 9472431..74397a5 100644
--- a/app/services/sensor_placement.py
+++ b/app/services/sensor_placement.py
@@ -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",
)
diff --git a/app/services/task_alerts.py b/app/services/task_alerts.py
new file mode 100644
index 0000000..f1ccb27
--- /dev/null
+++ b/app/services/task_alerts.py
@@ -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,
+ )
diff --git a/app/services/workbench_runtime.py b/app/services/workbench_runtime.py
new file mode 100644
index 0000000..0b166a6
--- /dev/null
+++ b/app/services/workbench_runtime.py
@@ -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()
diff --git a/docs/peripheral-generated-data.md b/docs/peripheral-generated-data.md
new file mode 100644
index 0000000..143a813
--- /dev/null
+++ b/docs/peripheral-generated-data.md
@@ -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
+```
+
+脚本使用固定 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)。专业重分析不在本次已确认的样板反馈范围内;生成器不会自动导入或修订成果。
diff --git a/docs/peripheral-sample.md b/docs/peripheral-sample.md
new file mode 100644
index 0000000..08eb49c
--- /dev/null
+++ b/docs/peripheral-sample.md
@@ -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-` 前缀。
diff --git a/docs/workbench-operations-storage.md b/docs/workbench-operations-storage.md
new file mode 100644
index 0000000..1172f16
--- /dev/null
+++ b/docs/workbench-operations-storage.md
@@ -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`。
diff --git a/docs/workbench-runtime.md b/docs/workbench-runtime.md
new file mode 100644
index 0000000..56f6058
--- /dev/null
+++ b/docs/workbench-runtime.md
@@ -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。
diff --git a/requirements.txt b/requirements.txt
index 95bf401..0f0fd59 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/resources/sql/008_workbench_alert_events.sql b/resources/sql/008_workbench_alert_events.sql
new file mode 100644
index 0000000..347cda5
--- /dev/null
+++ b/resources/sql/008_workbench_alert_events.sql
@@ -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;
diff --git a/resources/sql/009_analysis_runtime_query_indexes.sql b/resources/sql/009_analysis_runtime_query_indexes.sql
new file mode 100644
index 0000000..5033091
--- /dev/null
+++ b/resources/sql/009_analysis_runtime_query_indexes.sql
@@ -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;
diff --git a/resources/sql/010_source_gis_inventory.sql b/resources/sql/010_source_gis_inventory.sql
new file mode 100644
index 0000000..a6578da
--- /dev/null
+++ b/resources/sql/010_source_gis_inventory.sql
@@ -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.';
diff --git a/resources/sql/011_workbench_work_orders.sql b/resources/sql/011_workbench_work_orders.sql
new file mode 100644
index 0000000..ced42ca
--- /dev/null
+++ b/resources/sql/011_workbench_work_orders.sql
@@ -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;
diff --git a/resources/sql/012_peripheral_demo_v2.sql b/resources/sql/012_peripheral_demo_v2.sql
new file mode 100644
index 0000000..366039b
--- /dev/null
+++ b/resources/sql/012_peripheral_demo_v2.sql
@@ -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;
diff --git a/resources/sql/013_fengxian_map_layers.sql b/resources/sql/013_fengxian_map_layers.sql
new file mode 100644
index 0000000..2da2664
--- /dev/null
+++ b/resources/sql/013_fengxian_map_layers.sql
@@ -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.';
diff --git a/samples/peripheral/__init__.py b/samples/peripheral/__init__.py
new file mode 100644
index 0000000..becebc3
--- /dev/null
+++ b/samples/peripheral/__init__.py
@@ -0,0 +1 @@
+"""Independent, explicitly simulated peripheral service; not a production workflow engine."""
diff --git a/samples/peripheral/api.py b/samples/peripheral/api.py
new file mode 100644
index 0000000..175d681
--- /dev/null
+++ b/samples/peripheral/api.py
@@ -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()
diff --git a/samples/peripheral/auth.py b/samples/peripheral/auth.py
new file mode 100644
index 0000000..0f1fd67
--- /dev/null
+++ b/samples/peripheral/auth.py
@@ -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)
diff --git a/samples/peripheral/config.py b/samples/peripheral/config.py
new file mode 100644
index 0000000..65c6e32
--- /dev/null
+++ b/samples/peripheral/config.py
@@ -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",
+ )
diff --git a/samples/peripheral/demo_data.py b/samples/peripheral/demo_data.py
new file mode 100644
index 0000000..74f9014
--- /dev/null
+++ b/samples/peripheral/demo_data.py
@@ -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")
diff --git a/samples/peripheral/errors.py b/samples/peripheral/errors.py
new file mode 100644
index 0000000..85e8c25
--- /dev/null
+++ b/samples/peripheral/errors.py
@@ -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)
diff --git a/samples/peripheral/schemas.py b/samples/peripheral/schemas.py
new file mode 100644
index 0000000..a21f4cc
--- /dev/null
+++ b/samples/peripheral/schemas.py
@@ -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()
diff --git a/samples/peripheral/store.py b/samples/peripheral/store.py
new file mode 100644
index 0000000..fd685f4
--- /dev/null
+++ b/samples/peripheral/store.py
@@ -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"]}
diff --git a/scripts/check_test_resource_storage.py b/scripts/check_test_resource_storage.py
new file mode 100644
index 0000000..58d7e61
--- /dev/null
+++ b/scripts/check_test_resource_storage.py
@@ -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())
diff --git a/scripts/export_peripheral_sample.py b/scripts/export_peripheral_sample.py
new file mode 100644
index 0000000..3093127
--- /dev/null
+++ b/scripts/export_peripheral_sample.py
@@ -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")
diff --git a/scripts/generate_fengxian_water_quality_points.py b/scripts/generate_fengxian_water_quality_points.py
new file mode 100644
index 0000000..c1e549c
--- /dev/null
+++ b/scripts/generate_fengxian_water_quality_points.py
@@ -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()
diff --git a/scripts/import_fengxian_gis.py b/scripts/import_fengxian_gis.py
new file mode 100644
index 0000000..56dd0a2
--- /dev/null
+++ b/scripts/import_fengxian_gis.py
@@ -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()
diff --git a/scripts/publish_fengxian_map_layers.py b/scripts/publish_fengxian_map_layers.py
new file mode 100644
index 0000000..4fcf08b
--- /dev/null
+++ b/scripts/publish_fengxian_map_layers.py
@@ -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()
diff --git a/scripts/seed_peripheral_sample.py b/scripts/seed_peripheral_sample.py
new file mode 100644
index 0000000..808c2fc
--- /dev/null
+++ b/scripts/seed_peripheral_sample.py
@@ -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())
diff --git a/scripts/seed_workbench_work_orders.py b/scripts/seed_workbench_work_orders.py
new file mode 100644
index 0000000..97cfb9f
--- /dev/null
+++ b/scripts/seed_workbench_work_orders.py
@@ -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())
diff --git a/tests/api/test_project_data_endpoints.py b/tests/api/test_project_data_endpoints.py
new file mode 100644
index 0000000..9f5f578
--- /dev/null
+++ b/tests/api/test_project_data_endpoints.py
@@ -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
diff --git a/tests/api/test_workbench_runtime_endpoints.py b/tests/api/test_workbench_runtime_endpoints.py
new file mode 100644
index 0000000..0cf14df
--- /dev/null
+++ b/tests/api/test_workbench_runtime_endpoints.py
@@ -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"] == []
diff --git a/tests/integration/test_peripheral_demo_data_live.py b/tests/integration/test_peripheral_demo_data_live.py
new file mode 100644
index 0000000..fd0bc4f
--- /dev/null
+++ b/tests/integration/test_peripheral_demo_data_live.py
@@ -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"
diff --git a/tests/integration/test_peripheral_sample_live.py b/tests/integration/test_peripheral_sample_live.py
new file mode 100644
index 0000000..0dd1472
--- /dev/null
+++ b/tests/integration/test_peripheral_sample_live.py
@@ -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
diff --git a/tests/integration/test_task_alerts_live.py b/tests/integration/test_task_alerts_live.py
new file mode 100644
index 0000000..57ba8c0
--- /dev/null
+++ b/tests/integration/test_task_alerts_live.py
@@ -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()
diff --git a/tests/unit/test_geoserver_client.py b/tests/unit/test_geoserver_client.py
index 2cf7791..f6496d7 100644
--- a/tests/unit/test_geoserver_client.py
+++ b/tests/unit/test_geoserver_client.py
@@ -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"demo")
+ 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
diff --git a/tests/unit/test_gis_attributes_repository.py b/tests/unit/test_gis_attributes_repository.py
new file mode 100644
index 0000000..9f2658c
--- /dev/null
+++ b/tests/unit/test_gis_attributes_repository.py
@@ -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")
diff --git a/tests/unit/test_peripheral_demo_data.py b/tests/unit/test_peripheral_demo_data.py
new file mode 100644
index 0000000..50ff027
--- /dev/null
+++ b/tests/unit/test_peripheral_demo_data.py
@@ -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 "))
diff --git a/tests/unit/test_peripheral_managed_resources.py b/tests/unit/test_peripheral_managed_resources.py
new file mode 100644
index 0000000..d926ecd
--- /dev/null
+++ b/tests/unit/test_peripheral_managed_resources.py
@@ -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")
diff --git a/tests/unit/test_postgresql_analysis_repository.py b/tests/unit/test_postgresql_analysis_repository.py
index 110ec89..5ba4d61 100644
--- a/tests/unit/test_postgresql_analysis_repository.py
+++ b/tests/unit/test_postgresql_analysis_repository.py
@@ -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)
diff --git a/tests/unit/test_sensor_placement_service.py b/tests/unit/test_sensor_placement_service.py
index 7ddcf59..fd2c39a 100644
--- a/tests/unit/test_sensor_placement_service.py
+++ b/tests/unit/test_sensor_placement_service.py
@@ -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]
diff --git a/tests/unit/test_task_alerts.py b/tests/unit/test_task_alerts.py
new file mode 100644
index 0000000..09bce9f
--- /dev/null
+++ b/tests/unit/test_task_alerts.py
@@ -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"
diff --git a/tests/unit/test_workbench_runtime.py b/tests/unit/test_workbench_runtime.py
new file mode 100644
index 0000000..6b7a29f
--- /dev/null
+++ b/tests/unit/test_workbench_runtime.py
@@ -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"