feat(workbench): add runtime GIS and sample integrations
This commit is contained in:
@@ -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 个水质监测点图层。
|
||||
|
||||
## 测试与发布
|
||||
|
||||
提交前根据改动范围运行最小有效测试:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Read-only access to imported source GIS attributes."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
|
||||
class GisAttributeRepository:
|
||||
@staticmethod
|
||||
async def list_element_groups(
|
||||
conn: AsyncConnection,
|
||||
element_type: str,
|
||||
element_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT to_regclass('source_gis.element_links') AS relation"
|
||||
)
|
||||
availability = await cur.fetchone()
|
||||
if not availability or availability["relation"] is None:
|
||||
return []
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
dataset.dataset_key,
|
||||
dataset.title AS dataset_title,
|
||||
dataset.source_date,
|
||||
layer.layer_key,
|
||||
layer.display_name AS layer_name,
|
||||
feature.source_fid,
|
||||
link.match_method,
|
||||
link.distance_m AS match_distance_m,
|
||||
feature.attributes
|
||||
FROM source_gis.element_links AS link
|
||||
JOIN source_gis.features AS feature
|
||||
ON feature.layer_key = link.layer_key
|
||||
AND feature.source_fid = link.source_fid
|
||||
JOIN source_gis.layers AS layer
|
||||
ON layer.layer_key = feature.layer_key
|
||||
JOIN source_gis.datasets AS dataset
|
||||
ON dataset.dataset_key = layer.dataset_key
|
||||
WHERE link.element_type = %s
|
||||
AND link.element_id = %s
|
||||
ORDER BY dataset.source_date DESC, layer.display_name, feature.source_fid
|
||||
""",
|
||||
(element_type, element_id),
|
||||
)
|
||||
return [dict(row) for row in await cur.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
async def get_source_feature_group(
|
||||
conn: AsyncConnection,
|
||||
layer_key: str,
|
||||
source_fid: int,
|
||||
) -> dict[str, Any] | None:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
dataset.dataset_key,
|
||||
dataset.title AS dataset_title,
|
||||
dataset.source_date,
|
||||
layer.layer_key,
|
||||
layer.display_name AS layer_name,
|
||||
feature.source_fid,
|
||||
'source' AS match_method,
|
||||
NULL::double precision AS match_distance_m,
|
||||
feature.attributes
|
||||
FROM source_gis.features AS feature
|
||||
JOIN source_gis.layers AS layer
|
||||
ON layer.layer_key = feature.layer_key
|
||||
JOIN source_gis.datasets AS dataset
|
||||
ON dataset.dataset_key = layer.dataset_key
|
||||
WHERE feature.layer_key = %s
|
||||
AND feature.source_fid = %s
|
||||
""",
|
||||
(layer_key, source_fid),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -24,6 +24,8 @@ def _placement_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"status": row["status"],
|
||||
"sensor_type": payload.get("sensor_type", "pressure"),
|
||||
"method": payload.get("method"),
|
||||
"sensor_locations": locations,
|
||||
}
|
||||
|
||||
@@ -57,11 +59,15 @@ def create_sensor_placement(
|
||||
min_diameter: int,
|
||||
created_by: str,
|
||||
sensor_locations: list[str],
|
||||
sensor_type: str = "pressure",
|
||||
method: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
run_id = uuid4()
|
||||
payload = {
|
||||
"sensor_number": len(sensor_locations),
|
||||
"minimum_diameter": min_diameter,
|
||||
"sensor_type": sensor_type,
|
||||
"method": method,
|
||||
"sensor_locations": sensor_locations,
|
||||
}
|
||||
with project_connection(name) as conn, conn.transaction():
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""PostgreSQL persistence for task-produced alert events and current state."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.domain.domain_errors import DomainError
|
||||
|
||||
|
||||
class TaskAlertRepository:
|
||||
def __init__(self, connection: AsyncConnection, project_id):
|
||||
self.connection = connection
|
||||
self.project_id = str(project_id)
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
try:
|
||||
async with self.connection.transaction():
|
||||
yield self
|
||||
except UndefinedTable as exc:
|
||||
raise DomainError(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"Project alert storage is not provisioned",
|
||||
status=503,
|
||||
) from exc
|
||||
|
||||
async def lock_alert(self, alert_id: str) -> None:
|
||||
await self.connection.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
|
||||
(f"tjwater-alert:{self.project_id}:{alert_id}",),
|
||||
)
|
||||
|
||||
async def get_event(self, producer_id: str, event_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.alert_events
|
||||
WHERE project_id=%s AND producer_id=%s AND event_id=%s""",
|
||||
(self.project_id, producer_id, event_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def get_alert(self, alert_id: str) -> tuple[int, dict] | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT revision,document FROM workbench.alerts
|
||||
WHERE project_id=%s AND alert_id=%s""",
|
||||
(self.project_id, alert_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return (row["revision"], row["document"]) if row else None
|
||||
|
||||
async def save_event(self, event: dict, resulting_state: dict) -> None:
|
||||
document = event | {"resulting_state": resulting_state}
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.alert_events
|
||||
(project_id,producer_id,event_id,alert_id,event_type,occurred_at,document)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(
|
||||
self.project_id,
|
||||
event["producer_id"],
|
||||
event["event_id"],
|
||||
event["alert_id"],
|
||||
event["event_type"],
|
||||
event["occurred_at"],
|
||||
Jsonb(document),
|
||||
),
|
||||
)
|
||||
|
||||
async def save_alert(self, state: dict) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.alerts
|
||||
(project_id,alert_id,revision,status,occurred_at,updated_at,document)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(project_id,alert_id) DO UPDATE SET
|
||||
revision=excluded.revision,status=excluded.status,
|
||||
updated_at=excluded.updated_at,document=excluded.document""",
|
||||
(
|
||||
self.project_id,
|
||||
state["alert_id"],
|
||||
state["revision"],
|
||||
state["status"],
|
||||
state["occurred_at"],
|
||||
state["updated_at"],
|
||||
Jsonb({key: value for key, value in state.items() if key != "revision"}),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Optional project-local schedule, work-order and alert projections."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkbenchOperationsAvailability:
|
||||
work_orders: bool
|
||||
alerts: bool
|
||||
schedules: bool = False
|
||||
|
||||
|
||||
class WorkbenchOperationsRepository:
|
||||
"""Read provisioned workbench records without creating or mutating storage."""
|
||||
|
||||
@staticmethod
|
||||
async def availability(
|
||||
conn: AsyncConnection,
|
||||
) -> WorkbenchOperationsAvailability:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
to_regclass('workbench.work_orders') IS NOT NULL AS work_orders,
|
||||
to_regclass('workbench.alerts') IS NOT NULL AS alerts
|
||||
"""
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return WorkbenchOperationsAvailability(
|
||||
work_orders=bool(row and row["work_orders"]),
|
||||
alerts=bool(row and row["alerts"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def list_work_orders(
|
||||
conn: AsyncConnection, project_id: str, *, limit: int = 100
|
||||
) -> list[dict[str, Any]]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT document
|
||||
FROM workbench.work_orders
|
||||
WHERE project_id = %s
|
||||
ORDER BY updated_at DESC, work_order_id
|
||||
LIMIT %s
|
||||
""",
|
||||
(project_id, limit),
|
||||
)
|
||||
return [row["document"] for row in await cur.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
async def list_alerts(
|
||||
conn: AsyncConnection, project_id: str, *, limit: int = 100
|
||||
) -> list[dict[str, Any]]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT document
|
||||
FROM workbench.alerts
|
||||
WHERE project_id = %s
|
||||
AND status = 'active'
|
||||
ORDER BY occurred_at DESC, alert_id
|
||||
LIMIT %s
|
||||
""",
|
||||
(project_id, limit),
|
||||
)
|
||||
return [row["document"] for row in await cur.fetchall()]
|
||||
@@ -94,6 +94,89 @@ class GeoServerAdminClient:
|
||||
)
|
||||
return True
|
||||
|
||||
def feature_type_exists(
|
||||
self,
|
||||
*,
|
||||
workspace: str,
|
||||
datastore: str,
|
||||
layer: str,
|
||||
) -> bool:
|
||||
response = self._client.get(
|
||||
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}.json"
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return False
|
||||
if response.is_error:
|
||||
raise GeoServerProvisioningError(
|
||||
f"GeoServer feature type check failed with HTTP {response.status_code}"
|
||||
)
|
||||
return True
|
||||
|
||||
def publish_feature_type(
|
||||
self,
|
||||
*,
|
||||
workspace: str,
|
||||
datastore: str,
|
||||
layer: str,
|
||||
title: str,
|
||||
map_bbox: tuple[float, float, float, float],
|
||||
) -> None:
|
||||
minx, miny, maxx, maxy = map_bbox
|
||||
payload = {
|
||||
"featureType": {
|
||||
"name": layer,
|
||||
"nativeName": layer,
|
||||
"title": title,
|
||||
"srs": "EPSG:3857",
|
||||
"projectionPolicy": "FORCE_DECLARED",
|
||||
"enabled": True,
|
||||
"advertised": True,
|
||||
"nativeBoundingBox": {
|
||||
"minx": minx,
|
||||
"miny": miny,
|
||||
"maxx": maxx,
|
||||
"maxy": maxy,
|
||||
"crs": "EPSG:3857",
|
||||
},
|
||||
"latLonBoundingBox": {
|
||||
"minx": _longitude(minx),
|
||||
"miny": _latitude(miny),
|
||||
"maxx": _longitude(maxx),
|
||||
"maxy": _latitude(maxy),
|
||||
"crs": "EPSG:4326",
|
||||
},
|
||||
}
|
||||
}
|
||||
if self.feature_type_exists(
|
||||
workspace=workspace,
|
||||
datastore=datastore,
|
||||
layer=layer,
|
||||
):
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}",
|
||||
json=payload,
|
||||
)
|
||||
else:
|
||||
self._request(
|
||||
"POST",
|
||||
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes",
|
||||
json=payload,
|
||||
)
|
||||
self._set_client_cache(workspace, layer)
|
||||
|
||||
def truncate_layer_cache(self, workspace: str, layer: str) -> None:
|
||||
self._request(
|
||||
"POST",
|
||||
"/gwc/rest/masstruncate",
|
||||
content=(
|
||||
"<truncateLayer><layerName>"
|
||||
f"{workspace}:{layer}"
|
||||
"</layerName></truncateLayer>"
|
||||
),
|
||||
headers={"Content-Type": "text/xml"},
|
||||
)
|
||||
|
||||
def create_project_workspace(
|
||||
self,
|
||||
*,
|
||||
|
||||
+3
-2
@@ -28,8 +28,9 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("TJWater CloudService is starting...")
|
||||
logger.info("**********************************************************")
|
||||
|
||||
try:
|
||||
yield
|
||||
# 清理资源
|
||||
finally:
|
||||
await project_connection_manager.close_all()
|
||||
close_all_timescale_pools()
|
||||
close_all_project_pools()
|
||||
@@ -55,7 +56,7 @@ app = FastAPI(
|
||||
# Include Routers
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
install_problem_details_handlers(app)
|
||||
# Legcy Routers without version prefix
|
||||
# Legacy routers without version prefix
|
||||
# app.include_router(api_router)
|
||||
|
||||
# 配置中间件
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,64 @@
|
||||
# 外围子系统合成样本
|
||||
|
||||
仅供演示,所有数据标为 `synthetic`,所有对象使用 `DEMO-` 前缀。它们不映射真实资产,不写入 `asset/network/scada`,不生成正式工单,不控制设备。
|
||||
|
||||
## 内容与复现
|
||||
|
||||
纯函数 `samples/peripheral/demo_data.py::build_demo_content` 接收种子和带时区的固定起点,返回结构化数据。相同生成器版本、种子、归一化后的 UTC 起点产生相同记录、UUID 和 JCS SHA-256;不依赖当前时间、进程随机状态或数据库内容。生成器升级必须换版本,不能原地悄改 v1 的行为。
|
||||
|
||||
默认版本 `water-demo@2`,种子 `tjwater-peripheral-demo-20260917`;北京时间 2026-09-17 08:00 至 20:00,15 分钟一条,共 48 个时点。
|
||||
|
||||
| 内容 | 数量及情节 |
|
||||
| --- | --- |
|
||||
| 测点 | 3 个压力点、1 个流量点、1 个液位点 |
|
||||
| 读数 | 240 条;含 4 条缺测 null、4 条真实零值,保留各自质量标记 |
|
||||
| 设备状态 | 9 条变化;通信离线与恢复、泵运行/停止、阀门开/关 |
|
||||
| 告警 | 2 个预置事件,含发生与解除时间;不是从真实数据推断出的故障 |
|
||||
| 运行记录 | 4 条泵阀历史事件;不是可执行命令或真实设备回执 |
|
||||
|
||||
目录 `status=unknown` 不代表设备当前在线。历史状态必须按 `device_states.observed_at` 解读,不能将固定演示时点当成当前现场状态。
|
||||
|
||||
本数据压力单位为 m,不自动转换或送入专业分析。
|
||||
|
||||
## 独立 PG 存储
|
||||
|
||||
用户已授权外围样板使用独立 schema。仅在业务端 `tjwater_v2` 的已有 `peripheral_sample` 中新增一张表,不改其他表。由外部供给流程在事务中执行一次;应用没有建表或生成接口,也没有自动 seed。
|
||||
|
||||
```sql
|
||||
CREATE TABLE peripheral_sample.demo_datasets (
|
||||
project_id uuid NOT NULL,
|
||||
dataset_id text NOT NULL,
|
||||
digest text NOT NULL CHECK (digest ~ '^sha256:[0-9a-f]{64}$'),
|
||||
document jsonb NOT NULL,
|
||||
PRIMARY KEY (project_id,dataset_id),
|
||||
CHECK ((jsonb_typeof(document) = 'object' AND document->>'origin' = 'synthetic'
|
||||
AND document->>'generator_version' = 'water-demo@2') IS TRUE)
|
||||
);
|
||||
```
|
||||
|
||||
登记 `dataset_id=water-demo-v1` 时,先生成并验证 DemoContent、计算规范化摘要。已有同编号记录时只允许核对相同摘要,不覆盖;改变种子或生成器应使用新版本/新编号并另行调整读取配置。样本本身永久保存,读取不会重新生成或写入。
|
||||
|
||||
经明确授权需要为某个开发项目填充外围样板时,可运行:
|
||||
|
||||
```bash
|
||||
conda run -n server python scripts/seed_peripheral_sample.py --project-id <project-uuid>
|
||||
```
|
||||
|
||||
脚本使用固定 UUIDv5 请求号和专用 synthetic actor,重复执行会读取原幂等回执,不重复创建。它核验或补充 `water-demo-v1`,并创建 3 张测试工单和 12 条带 `DEMO-` 前缀的可编辑测试数据;不创建缺少合法 Action 来源的外围核查任务,不写正式工单、资产或监测表。
|
||||
|
||||
## 演示历史窗口配置
|
||||
|
||||
外围面板的“历史监测回放”读取现有项目历史监测源,与上述 `water-demo-v1` 合成数据集是两个独立区域。前端可通过 `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE=demo` 默认使用已知有数据的固定窗口,并用 `TJWATER_PERIPHERAL_SAMPLE_DEMO_START`、`TJWATER_PERIPHERAL_SAMPLE_DEMO_END` 配置开始和结束时间;切到 `recent` 时按 `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` 查询最近时段。界面仍允许在运行时切换演示、最近和自定义窗口。
|
||||
|
||||
本地默认演示窗口为 UTC 2026-04-30 08:00 至 16:00。它只控制历史监测查询,不改变 `water-demo-v1` 自身的 UTC 2026-09-17 00:00 至 12:00 固定内容。
|
||||
|
||||
## 接口与页面
|
||||
|
||||
`GET /api/sample/v1/demo-data` 复用 Next 项目身份和读取权限,按项目查 PG 后核验摘要与模型,返回 project_id、source_id、source_revision、固定时间窗和样本。无记录返回 SOURCE_NOT_CONFIGURED,不回退到浏览器造数据;服务不可用演练仍按既有场景拒绝读取。
|
||||
|
||||
原历史监测接口不变,真实告警源仍然是 `not_configured`;合成告警只出现在单独的合成样本区域,不能宣称真实告警接口已经接通。
|
||||
|
||||
前端在“外围样板”中单独读取合成样本,展示版本、种子和摘要,以及读数、设备状态、告警和运行历史。加载失败不会清空或阻止既有核查任务及历史回放。
|
||||
|
||||
核查任务、反馈仍需用户依据已保存来源显式提交。生成器不会伪造 Next 来源、现场反馈或业务审批。
|
||||
已审核反馈现在可经人工确认[导入独立新证据](field-feedback-import.md),再另行确认建立[人工复核草稿修订](feedback-review-revisions.md)。专业重分析不在本次已确认的样板反馈范围内;生成器不会自动导入或修订成果。
|
||||
@@ -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-` 前缀。
|
||||
@@ -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`。
|
||||
@@ -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。
|
||||
@@ -126,6 +126,7 @@ pyzmq==26.2.1
|
||||
reactivex==4.0.4
|
||||
referencing==0.36.2
|
||||
requests==2.32.3
|
||||
rfc8785==0.1.4
|
||||
rich==14.2.0
|
||||
rich-rst==1.3.2
|
||||
rpds-py==0.30.0
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Append-only task alert events plus a monotonic current-state projection.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS workbench;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.alert_events (
|
||||
project_id uuid NOT NULL,
|
||||
producer_id text NOT NULL CHECK (length(producer_id) > 0),
|
||||
event_id uuid NOT NULL,
|
||||
alert_id text NOT NULL CHECK (length(alert_id) > 0),
|
||||
event_type text NOT NULL CHECK (event_type IN ('raised','acknowledged','resolved')),
|
||||
occurred_at timestamptz NOT NULL,
|
||||
ingested_at timestamptz NOT NULL DEFAULT now(),
|
||||
document jsonb NOT NULL,
|
||||
PRIMARY KEY (project_id,producer_id,event_id),
|
||||
CHECK ((jsonb_typeof(document)='object'
|
||||
AND document->>'producer_id'=producer_id
|
||||
AND document->>'event_id'=event_id::text
|
||||
AND document->>'alert_id'=alert_id
|
||||
AND document->>'event_type'=event_type
|
||||
AND (document->>'occurred_at')::timestamptz=occurred_at) IS TRUE)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS alert_events_alert_idx
|
||||
ON workbench.alert_events(project_id,alert_id,occurred_at,event_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.alerts (
|
||||
project_id uuid NOT NULL,
|
||||
alert_id text NOT NULL CHECK (length(alert_id) > 0),
|
||||
revision bigint NOT NULL CHECK (revision >= 1),
|
||||
status text NOT NULL CHECK (status IN ('active','acknowledged','resolved')),
|
||||
occurred_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
document jsonb NOT NULL,
|
||||
PRIMARY KEY (project_id,alert_id),
|
||||
CHECK ((jsonb_typeof(document)='object'
|
||||
AND document->>'alert_id'=alert_id
|
||||
AND document->>'status'=status
|
||||
AND (document->>'occurred_at')::timestamptz=occurred_at) IS TRUE)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS alerts_active_recent_idx
|
||||
ON workbench.alerts(project_id,occurred_at DESC,alert_id)
|
||||
WHERE status='active';
|
||||
|
||||
REVOKE ALL ON ALL TABLES IN SCHEMA workbench FROM PUBLIC;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Bound and index the persisted analysis reads used by the workbench and API.
|
||||
-- Safe to re-run after analysis.runs and analysis.results are provisioned.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS runs_created_at_run_id_idx
|
||||
ON analysis.runs(created_at DESC, run_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS results_run_created_at_result_id_idx
|
||||
ON analysis.results(run_id, created_at DESC, result_id DESC);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,74 @@
|
||||
CREATE SCHEMA IF NOT EXISTS source_gis;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS source_gis.datasets (
|
||||
dataset_key TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
source_date DATE NOT NULL,
|
||||
declared_srid INTEGER,
|
||||
effective_srid INTEGER NOT NULL,
|
||||
coordinate_note TEXT NOT NULL,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS source_gis.layers (
|
||||
layer_key TEXT PRIMARY KEY,
|
||||
dataset_key TEXT NOT NULL REFERENCES source_gis.datasets(dataset_key) ON DELETE CASCADE,
|
||||
display_name TEXT NOT NULL,
|
||||
source_file TEXT NOT NULL,
|
||||
feature_kind TEXT NOT NULL,
|
||||
geometry_type TEXT NOT NULL,
|
||||
feature_count BIGINT NOT NULL CHECK (feature_count >= 0),
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS source_gis.features (
|
||||
layer_key TEXT NOT NULL REFERENCES source_gis.layers(layer_key) ON DELETE CASCADE,
|
||||
source_fid BIGINT NOT NULL,
|
||||
source_object_id TEXT,
|
||||
source_keyword TEXT,
|
||||
source_asset_id TEXT,
|
||||
source_diameter DOUBLE PRECISION,
|
||||
from_node_id TEXT,
|
||||
to_node_id TEXT,
|
||||
attributes JSONB NOT NULL CHECK (jsonb_typeof(attributes) = 'object'),
|
||||
geom geometry(Geometry, 900914) NOT NULL,
|
||||
PRIMARY KEY (layer_key, source_fid)
|
||||
);
|
||||
|
||||
ALTER TABLE source_gis.features
|
||||
ADD COLUMN IF NOT EXISTS source_diameter DOUBLE PRECISION;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS source_gis.element_links (
|
||||
element_type TEXT NOT NULL,
|
||||
element_id TEXT NOT NULL,
|
||||
layer_key TEXT NOT NULL,
|
||||
source_fid BIGINT NOT NULL,
|
||||
match_method TEXT NOT NULL,
|
||||
distance_m DOUBLE PRECISION,
|
||||
PRIMARY KEY (element_type, element_id, layer_key),
|
||||
FOREIGN KEY (layer_key, source_fid)
|
||||
REFERENCES source_gis.features(layer_key, source_fid)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_source_gis_layers_dataset
|
||||
ON source_gis.layers(dataset_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_source_gis_features_object_id
|
||||
ON source_gis.features(layer_key, source_object_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_source_gis_features_nodes
|
||||
ON source_gis.features(layer_key, from_node_id, to_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_source_gis_features_nodes_reverse
|
||||
ON source_gis.features(layer_key, to_node_id, from_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_source_gis_features_geom
|
||||
ON source_gis.features USING GIST (geom);
|
||||
CREATE INDEX IF NOT EXISTS idx_source_gis_element_links_lookup
|
||||
ON source_gis.element_links(element_type, element_id);
|
||||
|
||||
COMMENT ON SCHEMA source_gis IS
|
||||
'Imported source GIS inventories retained separately from the hydraulic model.';
|
||||
COMMENT ON COLUMN source_gis.datasets.declared_srid IS
|
||||
'SRID declared by the source file, which may be incorrect.';
|
||||
COMMENT ON COLUMN source_gis.datasets.effective_srid IS
|
||||
'SRID assigned after validating source coordinates against the project model.';
|
||||
COMMENT ON COLUMN source_gis.features.attributes IS
|
||||
'Original non-geometry GIS fields, including null values, preserved as JSONB.';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Read-only work-order projection synchronized by an external work-order source.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS workbench;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.work_orders (
|
||||
project_id uuid NOT NULL,
|
||||
work_order_id text NOT NULL CHECK (length(work_order_id) > 0),
|
||||
scheduled_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
document jsonb NOT NULL,
|
||||
PRIMARY KEY (project_id, work_order_id),
|
||||
CHECK ((jsonb_typeof(document) = 'object'
|
||||
AND document->>'id' = work_order_id
|
||||
AND (document->>'scheduled_at')::timestamptz = scheduled_at
|
||||
AND (document->>'updated_at')::timestamptz = updated_at) IS TRUE)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS work_orders_project_schedule_idx
|
||||
ON workbench.work_orders(project_id, scheduled_at, work_order_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS work_orders_project_updated_idx
|
||||
ON workbench.work_orders(project_id, updated_at DESC, work_order_id);
|
||||
|
||||
REVOKE ALL ON workbench.work_orders FROM PUBLIC;
|
||||
|
||||
COMMIT;
|
||||
@@ -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;
|
||||
@@ -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.';
|
||||
@@ -0,0 +1 @@
|
||||
"""Independent, explicitly simulated peripheral service; not a production workflow engine."""
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"]}
|
||||
@@ -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())
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Generate the independent peripheral sample contract without opening a DB."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from samples.peripheral.api import app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
target = ROOT / "contracts/peripheral-sample.openapi.json"
|
||||
target.write_text(json.dumps(app.openapi(), ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(f"Exported {len(app.openapi()['paths'])} sample paths")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Generate one reproducible Fengxian water-quality monitoring-point dataset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.native.wndb.core.connection import project_connection
|
||||
from app.services.sensor_placement import optimize_sensor_placement_by_sensitivity
|
||||
|
||||
|
||||
def find_existing_run(
|
||||
project: str,
|
||||
*,
|
||||
sensor_count: int,
|
||||
min_diameter: int,
|
||||
) -> dict[str, object] | None:
|
||||
with project_connection(project) as conn:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT run.run_id::text AS run_id, run.name, run.created_at,
|
||||
result.payload
|
||||
FROM analysis.runs AS run
|
||||
JOIN LATERAL (
|
||||
SELECT stored.payload
|
||||
FROM analysis.results AS stored
|
||||
WHERE stored.run_id = run.run_id
|
||||
AND stored.result_type = 'sensor_placement'
|
||||
ORDER BY stored.created_at DESC, stored.result_id DESC
|
||||
LIMIT 1
|
||||
) AS result ON true
|
||||
WHERE run.run_type = 'sensor_placement'
|
||||
AND run.status = 'completed'
|
||||
AND result.payload->>'sensor_type' = 'water_quality'
|
||||
AND result.payload->>'method' = 'sensitivity'
|
||||
AND (result.payload->>'sensor_number')::integer = %s
|
||||
AND (result.payload->>'minimum_diameter')::integer = %s
|
||||
ORDER BY run.created_at DESC, run.run_id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(sensor_count, min_diameter),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project", default="tjwater_v2")
|
||||
parser.add_argument("--sensor-count", type=int, default=50)
|
||||
parser.add_argument("--min-diameter", type=int, default=100)
|
||||
parser.add_argument("--run-name", default="奉贤水质监测点灵敏度分析(50点)")
|
||||
parser.add_argument("--created-by", default="tjwater")
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
existing = find_existing_run(
|
||||
args.project,
|
||||
sensor_count=args.sensor_count,
|
||||
min_diameter=args.min_diameter,
|
||||
)
|
||||
if existing is not None and not args.force:
|
||||
print(json.dumps(dict(existing), ensure_ascii=False, default=str, indent=2))
|
||||
return
|
||||
|
||||
created = optimize_sensor_placement_by_sensitivity(
|
||||
project_code=args.project,
|
||||
run_name=args.run_name,
|
||||
sensor_count=args.sensor_count,
|
||||
min_diameter=args.min_diameter,
|
||||
created_by=args.created_by,
|
||||
sensor_type="water_quality",
|
||||
)
|
||||
print(json.dumps(created, ensure_ascii=False, default=str, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,135 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1.endpoints import project_data
|
||||
|
||||
|
||||
def test_element_gis_properties_endpoint_returns_typed_groups(monkeypatch):
|
||||
async def list_element_groups(conn, element_type, element_id):
|
||||
assert conn is connection
|
||||
assert element_type == "pipes"
|
||||
assert element_id == "DEMO-P001"
|
||||
return [
|
||||
{
|
||||
"dataset_key": "fx_pipe_inventory_20200811",
|
||||
"dataset_title": "奉贤 DN100 及以上管网台账",
|
||||
"source_date": date(2020, 8, 11),
|
||||
"layer_key": "fx_20200811_dn100_pipes",
|
||||
"layer_name": "DN100 及以上管线台账",
|
||||
"source_fid": 117,
|
||||
"match_method": "node_pair",
|
||||
"match_distance_m": 0,
|
||||
"attributes": {"PIPE_D": 300},
|
||||
}
|
||||
]
|
||||
|
||||
connection = object()
|
||||
monkeypatch.setattr(
|
||||
project_data.GisAttributeRepository,
|
||||
"list_element_groups",
|
||||
list_element_groups,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
project_data.get_element_gis_properties(
|
||||
element_type="pipes",
|
||||
element_id=" DEMO-P001 ",
|
||||
conn=connection,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.element_id == "DEMO-P001"
|
||||
assert result.groups[0].attributes == {"PIPE_D": 300}
|
||||
|
||||
|
||||
def test_element_gis_properties_endpoint_rejects_blank_id():
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
asyncio.run(
|
||||
project_data.get_element_gis_properties(
|
||||
element_type="pipes",
|
||||
element_id=" ",
|
||||
conn=object(),
|
||||
)
|
||||
)
|
||||
|
||||
assert failure.value.status_code == 422
|
||||
|
||||
|
||||
def test_source_gis_feature_endpoint_returns_direct_source_group(monkeypatch):
|
||||
async def get_source_feature_group(conn, layer_key, source_fid):
|
||||
assert conn is connection
|
||||
assert layer_key == "fx_20200423_flow_meters"
|
||||
assert source_fid == 17
|
||||
return {
|
||||
"dataset_key": "fx_water_assets_20200423",
|
||||
"dataset_title": "奉贤全网除头桥 GIS 数据导出",
|
||||
"source_date": date(2020, 4, 23),
|
||||
"layer_key": layer_key,
|
||||
"layer_name": "流量仪",
|
||||
"source_fid": source_fid,
|
||||
"match_method": "source",
|
||||
"match_distance_m": None,
|
||||
"attributes": {"流量仪ID编码": "FM-17"},
|
||||
}
|
||||
|
||||
connection = object()
|
||||
monkeypatch.setattr(
|
||||
project_data.GisAttributeRepository,
|
||||
"get_source_feature_group",
|
||||
get_source_feature_group,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
project_data.get_source_gis_feature_properties(
|
||||
layer_key=" fx_20200423_flow_meters ",
|
||||
source_fid=17,
|
||||
conn=connection,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.layer_key == "fx_20200423_flow_meters"
|
||||
assert result.group.match_method == "source"
|
||||
assert result.group.attributes == {"流量仪ID编码": "FM-17"}
|
||||
|
||||
|
||||
def test_analysis_run_endpoint_forwards_page_bounds(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def list_runs(conn, *, limit, offset):
|
||||
captured.update(conn=conn, limit=limit, offset=offset)
|
||||
return [{"run_id": "run-1"}]
|
||||
|
||||
connection = object()
|
||||
monkeypatch.setattr(project_data.AnalysisRepository, "list_runs", list_runs)
|
||||
|
||||
result = asyncio.run(
|
||||
project_data.get_analysis_runs(limit=25, offset=50, conn=connection)
|
||||
)
|
||||
|
||||
assert captured == {"conn": connection, "limit": 25, "offset": 50}
|
||||
assert result == {
|
||||
"success": True,
|
||||
"data": [{"run_id": "run-1"}],
|
||||
"count": 1,
|
||||
"limit": 25,
|
||||
"offset": 50,
|
||||
}
|
||||
|
||||
|
||||
def test_analysis_endpoints_do_not_return_database_exception_text(monkeypatch):
|
||||
async def unavailable(*_args, **_kwargs):
|
||||
raise RuntimeError("postgresql://user:secret@database/internal")
|
||||
|
||||
monkeypatch.setattr(project_data.AnalysisRepository, "list_runs", unavailable)
|
||||
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
asyncio.run(
|
||||
project_data.get_analysis_runs(limit=100, offset=0, conn=object())
|
||||
)
|
||||
|
||||
assert failure.value.status_code == 500
|
||||
assert failure.value.detail == "查询分析运行失败"
|
||||
assert "secret" not in failure.value.detail
|
||||
@@ -0,0 +1,508 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import workbench_runtime
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.infra.db.postgresql.workbench_operations import (
|
||||
WorkbenchOperationsAvailability,
|
||||
)
|
||||
from tests.conftest import build_test_app
|
||||
|
||||
|
||||
PROJECT_CONTEXT = ProjectContext(
|
||||
project_id=uuid4(),
|
||||
project_code="test-project",
|
||||
user_id=uuid4(),
|
||||
project_role="viewer",
|
||||
)
|
||||
|
||||
|
||||
def configure_dependencies(app):
|
||||
app.dependency_overrides[workbench_runtime.get_project_context] = lambda: PROJECT_CONTEXT
|
||||
app.dependency_overrides[workbench_runtime.get_project_pg_connection] = lambda: object()
|
||||
|
||||
|
||||
def test_alert_ingress_binds_producer_to_authenticated_project_principal(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
member = ProjectContext(
|
||||
project_id=uuid4(),
|
||||
project_code="test-project",
|
||||
user_id=uuid4(),
|
||||
project_role="member",
|
||||
)
|
||||
app.dependency_overrides[workbench_runtime.get_project_context] = lambda: member
|
||||
app.dependency_overrides[workbench_runtime.get_project_pg_connection] = lambda: object()
|
||||
captured = {}
|
||||
|
||||
class CapturingAlertService:
|
||||
def __init__(self, _repository, context):
|
||||
assert context == member
|
||||
|
||||
async def push(self, event):
|
||||
captured["event"] = event
|
||||
return event.model_dump(exclude={"event_id", "event_type"}) | {
|
||||
"revision": 1,
|
||||
"status": "active",
|
||||
"updated_at": datetime(2026, 9, 18, 2, 30, tzinfo=timezone.utc),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(workbench_runtime, "TaskAlertService", CapturingAlertService)
|
||||
body = {
|
||||
"event_id": str(uuid4()),
|
||||
"alert_id": "schedule-1:occurrence-1",
|
||||
"event_type": "raised",
|
||||
"occurred_at": "2026-09-18T02:30:00Z",
|
||||
"condition_id": None,
|
||||
"source": {
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "schedule-1",
|
||||
"run_id": "occurrence-1",
|
||||
},
|
||||
"title": "定时任务失败",
|
||||
"description": "任务显式推送的告警。",
|
||||
"severity": "warning",
|
||||
}
|
||||
|
||||
response = TestClient(app).post("/api/v1/workbench/task-alert-events", json=body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["producer_id"] == f"principal:{member.user_id}"
|
||||
assert captured["event"].producer_id == f"principal:{member.user_id}"
|
||||
spoofed = TestClient(app).post(
|
||||
"/api/v1/workbench/task-alert-events",
|
||||
json=body | {"producer_id": "investigation-scheduler"},
|
||||
)
|
||||
assert spoofed.status_code == 422
|
||||
|
||||
|
||||
def test_runtime_endpoint_returns_typed_empty_projection(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
|
||||
async def empty_runs(_conn, *, limit):
|
||||
assert limit == 100
|
||||
return []
|
||||
|
||||
async def no_operations(_conn):
|
||||
return WorkbenchOperationsAvailability(
|
||||
schedules=False,
|
||||
work_orders=False,
|
||||
alerts=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.WorkbenchOperationsRepository,
|
||||
"availability",
|
||||
no_operations,
|
||||
)
|
||||
|
||||
response = TestClient(app).get("/api/v1/workbench/runtime")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"availability": "not_configured",
|
||||
"source": "none",
|
||||
"scenario_availability": "not_configured",
|
||||
"artifact_availability": "not_configured",
|
||||
"schedule_availability": "not_configured",
|
||||
"work_order_availability": "not_configured",
|
||||
"alert_availability": "not_configured",
|
||||
"generated_at": response.json()["generated_at"],
|
||||
"conditions": [],
|
||||
"alerts": [],
|
||||
"scenarios": [],
|
||||
"latest_analysis": None,
|
||||
"work_orders": [],
|
||||
}
|
||||
assert datetime.fromisoformat(response.json()["generated_at"]).tzinfo is timezone.utc
|
||||
|
||||
|
||||
def test_analysis_history_endpoint_returns_a_sanitized_bounded_page(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
run_ids = [uuid4(), uuid4(), uuid4()]
|
||||
|
||||
async def analysis_runs(_conn, *, limit, offset):
|
||||
assert (limit, offset) == (3, 20)
|
||||
return [
|
||||
{
|
||||
"run_id": run_ids[index],
|
||||
"name": f"分析运行 {index + 1}",
|
||||
"run_type": "sensor_placement",
|
||||
"created_by": "alice",
|
||||
"created_at": now,
|
||||
"started_at": now,
|
||||
"status": "completed",
|
||||
"has_results": index != 1,
|
||||
"parameters": {"internal_secret": "must-not-leak"},
|
||||
}
|
||||
for index in range(3)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.AnalysisRepository,
|
||||
"list_run_summaries",
|
||||
analysis_runs,
|
||||
)
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/api/v1/workbench/analysis-runs?limit=2&offset=20"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["next_offset"] == 22
|
||||
assert [item["run_id"] for item in payload["items"]] == [
|
||||
str(run_ids[0]),
|
||||
str(run_ids[1]),
|
||||
]
|
||||
assert payload["items"][0]["run_type_label"] == "传感器布点"
|
||||
assert payload["items"][1]["has_results"] is False
|
||||
assert "internal_secret" not in response.text
|
||||
|
||||
|
||||
def test_runtime_endpoint_keeps_analysis_projection_out_of_conditions(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
run_id = uuid4()
|
||||
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
|
||||
async def analysis_runs(_conn, *, limit):
|
||||
assert limit == 100
|
||||
return [{
|
||||
"run_id": run_id,
|
||||
"name": "北区爆管定位",
|
||||
"run_type": "burst_location",
|
||||
"created_by": "alice",
|
||||
"created_at": now,
|
||||
"started_at": now,
|
||||
"status": "completed",
|
||||
"parameters": {},
|
||||
}]
|
||||
|
||||
async def analysis_results(_conn, requested_run_id, *, limit):
|
||||
assert requested_run_id == run_id
|
||||
assert limit == 100
|
||||
return [{"result_type": "burst_location", "created_at": now}]
|
||||
|
||||
async def no_operations(_conn):
|
||||
return WorkbenchOperationsAvailability(
|
||||
schedules=False,
|
||||
work_orders=False,
|
||||
alerts=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", analysis_runs)
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_results", analysis_results)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.WorkbenchOperationsRepository,
|
||||
"availability",
|
||||
no_operations,
|
||||
)
|
||||
|
||||
response = TestClient(app).get("/api/v1/workbench/runtime")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["availability"] == "not_configured"
|
||||
assert payload["source"] == "none"
|
||||
assert payload["conditions"] == []
|
||||
assert payload["scenarios"][0]["run_id"] == str(run_id)
|
||||
assert payload["latest_analysis"]["results"] == [{
|
||||
"result_type": "burst_location",
|
||||
"created_at": "2026-09-17T02:30:00Z",
|
||||
"facts": [],
|
||||
}]
|
||||
assert payload["latest_analysis"]["map_payload"] is None
|
||||
|
||||
|
||||
def test_analysis_run_endpoint_returns_the_requested_typed_projection(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
run_id = uuid4()
|
||||
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
|
||||
async def analysis_run(_conn, requested_run_id):
|
||||
assert requested_run_id == run_id
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"name": "历史爆管定位",
|
||||
"run_type": "burst_location",
|
||||
"created_by": "alice",
|
||||
"created_at": now,
|
||||
"started_at": now,
|
||||
"status": "completed",
|
||||
"parameters": {},
|
||||
}
|
||||
|
||||
async def analysis_results(_conn, requested_run_id, *, limit):
|
||||
assert requested_run_id == run_id
|
||||
assert limit == 100
|
||||
return [{
|
||||
"result_type": "burst_location",
|
||||
"link_id": "P-42",
|
||||
"created_at": now,
|
||||
"payload": {
|
||||
"drawing_payload": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [{
|
||||
"type": "Feature",
|
||||
"properties": {"label": "历史运行几何"},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [121.4, 30.8],
|
||||
},
|
||||
}],
|
||||
},
|
||||
"internal_debug": "must-not-leak",
|
||||
},
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", analysis_run)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.AnalysisRepository,
|
||||
"list_recent_results",
|
||||
analysis_results,
|
||||
)
|
||||
|
||||
response = TestClient(app).get(f"/api/v1/workbench/analysis-runs/{run_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["run_id"] == str(run_id)
|
||||
assert payload["name"] == "历史爆管定位"
|
||||
assert payload["results"][0]["facts"] == [
|
||||
{"label": "关联管段", "value": "P-42"}
|
||||
]
|
||||
assert payload["map_payload"]["features"][0]["properties"] == {
|
||||
"label": "历史运行几何"
|
||||
}
|
||||
assert "must-not-leak" not in response.text
|
||||
|
||||
|
||||
def test_analysis_run_endpoint_projects_sensor_locations_as_map_points(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
run_id = uuid4()
|
||||
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
|
||||
async def analysis_run(_conn, requested_run_id):
|
||||
assert requested_run_id == run_id
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"name": "北区传感器布点",
|
||||
"run_type": "sensor_placement",
|
||||
"created_by": "alice",
|
||||
"created_at": now,
|
||||
"started_at": now,
|
||||
"status": "completed",
|
||||
"parameters": {},
|
||||
}
|
||||
|
||||
async def analysis_results(_conn, requested_run_id, *, limit):
|
||||
assert requested_run_id == run_id
|
||||
assert limit == 100
|
||||
return [{
|
||||
"result_type": "sensor_placement",
|
||||
"created_at": now,
|
||||
"payload": {
|
||||
"sensor_number": 2,
|
||||
"minimum_diameter": 300,
|
||||
"sensor_locations": ["J-2", "J-1"],
|
||||
"internal_debug": "must-not-leak",
|
||||
},
|
||||
}]
|
||||
|
||||
async def junction_points(_conn, node_ids):
|
||||
assert node_ids == ["J-2", "J-1"]
|
||||
return [
|
||||
{"node_id": "J-1", "longitude": 121.4, "latitude": 30.8},
|
||||
{"node_id": "J-2", "longitude": 121.5, "latitude": 30.9},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", analysis_run)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.AnalysisRepository,
|
||||
"list_recent_results",
|
||||
analysis_results,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.AnalysisRepository,
|
||||
"list_junction_map_points",
|
||||
junction_points,
|
||||
)
|
||||
|
||||
response = TestClient(app).get(f"/api/v1/workbench/analysis-runs/{run_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert [
|
||||
feature["properties"]["node_id"]
|
||||
for feature in payload["map_payload"]["features"]
|
||||
] == ["J-2", "J-1"]
|
||||
assert payload["map_payload"]["features"][0] == {
|
||||
"type": "Feature",
|
||||
"id": "J-2",
|
||||
"properties": {
|
||||
"label": "监测点 1 · J-2",
|
||||
"node_id": "J-2",
|
||||
"result_type": "sensor_placement",
|
||||
"position": 1,
|
||||
},
|
||||
"geometry": {"type": "Point", "coordinates": [121.5, 30.9]},
|
||||
}
|
||||
assert "must-not-leak" not in response.text
|
||||
|
||||
|
||||
def test_analysis_run_endpoint_returns_404_for_an_unknown_run(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
|
||||
async def missing_run(_conn, _run_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", missing_run)
|
||||
|
||||
response = TestClient(app).get(
|
||||
f"/api/v1/workbench/analysis-runs/{uuid4()}"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_runtime_endpoint_reads_work_order_and_alert_storage(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
|
||||
async def empty_runs(_conn, *, limit):
|
||||
assert limit == 100
|
||||
return []
|
||||
|
||||
async def operations_available(_conn):
|
||||
return WorkbenchOperationsAvailability(
|
||||
schedules=False,
|
||||
work_orders=True,
|
||||
alerts=True,
|
||||
)
|
||||
|
||||
async def work_orders(_conn, project_id):
|
||||
assert project_id == str(PROJECT_CONTEXT.project_id)
|
||||
return [{
|
||||
"id": "wo-1",
|
||||
"code": "WO-1",
|
||||
"scheduled_at": now,
|
||||
"title": "阀门复核",
|
||||
"summary": "现场复核阀门。",
|
||||
"status": "completed",
|
||||
"risk_level": "normal",
|
||||
"updated_at": now,
|
||||
"detail": "正式工单同步记录。",
|
||||
"recommendation": None,
|
||||
"duration_minutes": 30,
|
||||
"source": "生产工单系统",
|
||||
"dispatcher": "调度中心",
|
||||
"priority": "normal",
|
||||
"subtasks": [{
|
||||
"id": "inspect-valve",
|
||||
"title": "阀门复核",
|
||||
"summary": "现场复核阀门。",
|
||||
"status": "completed",
|
||||
"scheduled_at": now,
|
||||
"location": "V-1",
|
||||
"assignee": "现场班组",
|
||||
"duration_minutes": 30,
|
||||
"reply_window_minutes": 15,
|
||||
"stages": [
|
||||
{"id": "dispatch", "sequence": 1, "title": "派发", "status": "done", "owner": "调度中心", "time_label": "10:30", "description": "已派发。"},
|
||||
{"id": "execution", "sequence": 2, "title": "执行", "status": "done", "owner": "现场班组", "time_label": "10:30—11:00", "description": "已执行。"},
|
||||
{"id": "reply", "sequence": 3, "title": "复令", "status": "done", "owner": "现场班组", "time_label": "11:00—11:15", "description": "已复令。"},
|
||||
],
|
||||
"reply_requirements": ["回传结果"],
|
||||
}],
|
||||
}]
|
||||
|
||||
async def alerts(_conn, project_id):
|
||||
assert project_id == str(PROJECT_CONTEXT.project_id)
|
||||
return [{
|
||||
"alert_id": "alarm-1",
|
||||
"condition_id": None,
|
||||
"source": {
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "diagnosis-5m",
|
||||
"run_id": "trigger-1",
|
||||
},
|
||||
"title": "泵站通信告警",
|
||||
"description": "由诊断任务明确推送。",
|
||||
"occurred_at": now,
|
||||
"severity": "warning",
|
||||
"status": "active",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs)
|
||||
monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "availability", operations_available)
|
||||
monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "list_work_orders", work_orders)
|
||||
monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "list_alerts", alerts)
|
||||
|
||||
response = TestClient(app).get("/api/v1/workbench/runtime")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["source"] == "none"
|
||||
assert payload["schedule_availability"] == "not_configured"
|
||||
assert payload["conditions"] == []
|
||||
assert payload["work_order_availability"] == "ready"
|
||||
assert payload["work_orders"][0]["code"] == "WO-1"
|
||||
assert payload["work_orders"][0]["status"] == "completed"
|
||||
assert payload["alert_availability"] == "ready"
|
||||
assert payload["alerts"][0]["title"] == "泵站通信告警"
|
||||
assert payload["alerts"][0]["source"] == {
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "diagnosis-5m",
|
||||
"run_id": "trigger-1",
|
||||
}
|
||||
|
||||
|
||||
def test_runtime_endpoint_isolates_invalid_work_order_documents(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
|
||||
async def empty_runs(_conn, *, limit):
|
||||
assert limit == 100
|
||||
return []
|
||||
|
||||
async def operations_available(_conn):
|
||||
return WorkbenchOperationsAvailability(
|
||||
schedules=False,
|
||||
work_orders=True,
|
||||
alerts=False,
|
||||
)
|
||||
|
||||
async def invalid_work_orders(_conn, _project_id):
|
||||
return [{"id": "wo-invalid", "subtasks": []}]
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.WorkbenchOperationsRepository,
|
||||
"availability",
|
||||
operations_available,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.WorkbenchOperationsRepository,
|
||||
"list_work_orders",
|
||||
invalid_work_orders,
|
||||
)
|
||||
|
||||
response = TestClient(app).get("/api/v1/workbench/runtime")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["source"] == "none"
|
||||
assert payload["schedule_availability"] == "not_configured"
|
||||
assert payload["work_order_availability"] == "degraded"
|
||||
assert payload["availability"] == "degraded"
|
||||
assert payload["work_orders"] == []
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Opt-in persistence checks for the isolated generated demo dataset."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from uuid import uuid4
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.content_digest import digest
|
||||
from samples.peripheral.auth import Identity
|
||||
from samples.peripheral.config import SampleSettings
|
||||
from samples.peripheral.demo_data import build_demo_content
|
||||
from samples.peripheral.errors import SampleFailure
|
||||
from samples.peripheral.store import PostgresStore
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.getenv("PERIPHERAL_SAMPLE_LIVE_TEST") != "1",
|
||||
reason="set PERIPHERAL_SAMPLE_LIVE_TEST=1 to use provisioned peripheral_sample storage",
|
||||
)
|
||||
|
||||
|
||||
def connection():
|
||||
return psycopg.connect(
|
||||
host=settings.DB_HOST, port=settings.DB_PORT, user=settings.DB_USER,
|
||||
password=settings.DB_PASSWORD, dbname="tjwater_v2", connect_timeout=3,
|
||||
options="-c statement_timeout=10000", row_factory=dict_row,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_demo_project():
|
||||
project_id = uuid4()
|
||||
content = build_demo_content("live-" + str(project_id))
|
||||
revision = digest(content)
|
||||
with connection() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO peripheral_sample.demo_datasets
|
||||
(project_id,dataset_id,digest,document) VALUES (%s,'water-demo-v1',%s,%s)""",
|
||||
(project_id, revision, Jsonb(content)),
|
||||
)
|
||||
try:
|
||||
yield project_id, content, revision
|
||||
finally:
|
||||
with connection() as conn:
|
||||
conn.execute("DELETE FROM peripheral_sample.demo_datasets WHERE project_id=%s", (project_id,))
|
||||
conn.execute("DELETE FROM peripheral_sample.scenarios WHERE project_id=%s", (project_id,))
|
||||
|
||||
|
||||
def test_persisted_demo_is_read_from_fresh_connection_and_process(live_demo_project):
|
||||
project_id, content, revision = live_demo_project
|
||||
store = PostgresStore(SampleSettings(db_name="tjwater_v2"))
|
||||
result = store.demo_data(Identity(str(project_id), str(uuid4()), False))
|
||||
assert result["source_revision"] == revision
|
||||
assert result["observations"] == content["observations"]
|
||||
|
||||
script = """
|
||||
import sys
|
||||
from samples.peripheral.auth import Identity
|
||||
from samples.peripheral.config import SampleSettings
|
||||
from samples.peripheral.store import PostgresStore
|
||||
result = PostgresStore(SampleSettings(db_name='tjwater_v2')).demo_data(Identity(sys.argv[1], sys.argv[2], False))
|
||||
assert result['source_revision'] == sys.argv[3] and len(result['observations']) == 240
|
||||
print('ok')
|
||||
"""
|
||||
child = subprocess.run(
|
||||
[sys.executable, "-c", script, str(project_id), str(uuid4()), revision],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
assert child.stdout.strip() == "ok"
|
||||
|
||||
|
||||
def test_demo_read_is_project_isolated_and_never_calls_generator(live_demo_project, monkeypatch):
|
||||
project_id, _, _ = live_demo_project
|
||||
monkeypatch.setattr(
|
||||
"samples.peripheral.demo_data.build_demo_content",
|
||||
lambda *args, **kwargs: pytest.fail("persisted reads must not invoke the generator"),
|
||||
)
|
||||
store = PostgresStore(SampleSettings(db_name="tjwater_v2"))
|
||||
assert store.demo_data(Identity(str(project_id), str(uuid4()), False))["project_id"] == str(project_id)
|
||||
with pytest.raises(SampleFailure) as missing:
|
||||
store.demo_data(Identity(str(uuid4()), str(uuid4()), False))
|
||||
assert missing.value.code == "SOURCE_NOT_CONFIGURED"
|
||||
|
||||
|
||||
def test_tampered_document_is_rejected_and_cleanup_remains_scoped(live_demo_project):
|
||||
project_id, _, _ = live_demo_project
|
||||
with connection() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE peripheral_sample.demo_datasets
|
||||
SET document=jsonb_set(document,'{seed}',to_jsonb(%s::text))
|
||||
WHERE project_id=%s AND dataset_id='water-demo-v1'""",
|
||||
("tampered", project_id),
|
||||
)
|
||||
store = PostgresStore(SampleSettings(db_name="tjwater_v2"))
|
||||
with pytest.raises(SampleFailure) as rejected:
|
||||
store.demo_data(Identity(str(project_id), str(uuid4()), False))
|
||||
assert rejected.value.code == "INTEGRITY_MISMATCH"
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Opt-in checks against provisioned sample tables, never business-table writes."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from samples.peripheral import schemas as peripheral_schemas
|
||||
from samples.peripheral.api import create_app
|
||||
from samples.peripheral.auth import Identity, identity
|
||||
from samples.peripheral.config import SampleSettings
|
||||
from samples.peripheral.errors import SampleFailure
|
||||
from samples.peripheral.schemas import (
|
||||
AddFeedback,
|
||||
CreateTask,
|
||||
CreateTestDataRecord,
|
||||
CreateWorkOrder,
|
||||
DeleteResource,
|
||||
Observations,
|
||||
ScenarioCommand,
|
||||
Task,
|
||||
Transition,
|
||||
UpdateTestDataRecord,
|
||||
UpdateWorkOrder,
|
||||
WorkOrder,
|
||||
)
|
||||
from samples.peripheral.store import PostgresStore
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.getenv("PERIPHERAL_SAMPLE_LIVE_TEST") != "1", reason="explicit live sample opt-in required")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample():
|
||||
store = PostgresStore(SampleSettings(enable_faults=True))
|
||||
who = Identity(str(uuid4()), str(uuid4()), True)
|
||||
yield store, who
|
||||
# Only this test's unguessable project key; tables created by the authorized setup.
|
||||
with store.connection() as conn:
|
||||
for table in (
|
||||
"work_order_revisions", "test_data_record_revisions", "work_orders",
|
||||
"test_data_records", "feedback", "requests", "revisions", "tasks", "scenarios",
|
||||
):
|
||||
from psycopg import sql
|
||||
exists = conn.execute(
|
||||
"SELECT to_regclass(%s) AS name", (f"peripheral_sample.{table}",)
|
||||
).fetchone()["name"]
|
||||
if exists is not None:
|
||||
conn.execute(
|
||||
sql.SQL("DELETE FROM peripheral_sample.{} WHERE project_id=%s").format(
|
||||
sql.Identifier(table)
|
||||
),
|
||||
(who.project_id,),
|
||||
)
|
||||
|
||||
|
||||
def command():
|
||||
return CreateTask.model_validate({
|
||||
"request_id": str(uuid4()), "title": "AUTOTEST isolated sample", "summary": "not a production source",
|
||||
"source": {"system": "next", "task_id": str(uuid4()), "object_type": "validation", "object_id": str(uuid4()), "revision": 1},
|
||||
"asset_ids": ["test-pressure"], "analysis_window": {"start": "2026-04-30T08:00:00Z", "end": "2026-04-30T09:00:00Z"},
|
||||
"evidence_refs": [], "requested_check": "verification only",
|
||||
})
|
||||
|
||||
|
||||
def move(store, who, task, status):
|
||||
return store.transition(who, task["task_id"], Transition(request_id=uuid4(), expected_revision=task["revision"], target_status=status, reason="AUTOTEST"))
|
||||
|
||||
|
||||
def feedback(task):
|
||||
return AddFeedback.model_validate({"request_id": str(uuid4()), "expected_revision": task["revision"], "feedback_id": str(uuid4()),
|
||||
"occurred_at": "2026-04-30T08:30:00Z", "applicable_time": task["analysis_window"], "asset_ids": task["asset_ids"],
|
||||
"finding": "instrument_fault", "notes": "AUTOTEST synthetic only", "attachment_refs": []})
|
||||
|
||||
|
||||
def scenario(store, who, name):
|
||||
return store.set_scenario(who, ScenarioCommand(request_id=uuid4(), scenario=name))
|
||||
|
||||
|
||||
def test_persistence_transitions_feedback_idempotency_and_isolation(sample):
|
||||
store, who = sample
|
||||
cmd = command()
|
||||
task = store.create_task(who, cmd)
|
||||
Task.model_validate(task)
|
||||
fresh = PostgresStore(store.settings)
|
||||
assert fresh.get_task(who, task["task_id"]) == task
|
||||
# A new interpreter has no service memory or open DB connections to reuse.
|
||||
probe = subprocess.run([sys.executable, "-c",
|
||||
"import sys; from samples.peripheral.store import PostgresStore; "
|
||||
"from samples.peripheral.config import SampleSettings; from samples.peripheral.auth import Identity; "
|
||||
"print(PostgresStore(SampleSettings()).get_task(Identity(sys.argv[1],sys.argv[2],True),sys.argv[3])['revision'])",
|
||||
who.project_id, who.user_id, task["task_id"]], capture_output=True, text=True, timeout=15, check=True)
|
||||
assert probe.stdout.strip() == "1"
|
||||
assert fresh.create_task(who, cmd) == task
|
||||
with pytest.raises(SampleFailure, match="同一 request_id"):
|
||||
store.create_task(who, cmd.model_copy(update={"title": "changed"}))
|
||||
outsider = Identity(str(uuid4()), who.user_id, True)
|
||||
with pytest.raises(SampleFailure) as exc:
|
||||
store.get_task(outsider, task["task_id"])
|
||||
assert exc.value.code == "TASK_NOT_FOUND"
|
||||
with pytest.raises(SampleFailure):
|
||||
store.receipt(Identity(who.project_id, str(uuid4()), True), cmd.request_id)
|
||||
with pytest.raises(SampleFailure):
|
||||
store.create_task(Identity(who.project_id, who.user_id, False), command())
|
||||
with pytest.raises(SampleFailure):
|
||||
move(store, who, task, "completed")
|
||||
task = move(store, who, task, "processing")
|
||||
stale = task.copy()
|
||||
cmd_feedback = feedback(task)
|
||||
task = store.add_feedback(who, task["task_id"], cmd_feedback)
|
||||
repeated = cmd_feedback.model_copy(update={"request_id": uuid4()})
|
||||
assert store.add_feedback(who, task["task_id"], repeated) == task
|
||||
with pytest.raises(SampleFailure) as exc:
|
||||
store.add_feedback(who, task["task_id"], repeated.model_copy(update={"request_id": uuid4(), "notes": "different"}))
|
||||
assert exc.value.code == "FEEDBACK_CONFLICT"
|
||||
with pytest.raises(SampleFailure) as exc:
|
||||
move(store, who, stale, "pending_review")
|
||||
assert exc.value.code == "REVISION_CONFLICT"
|
||||
task = move(store, who, task, "pending_review")
|
||||
task = move(store, who, task, "processing")
|
||||
task = move(store, who, task, "pending_review")
|
||||
task = move(store, who, task, "completed")
|
||||
assert len(task["feedback"]) == 1
|
||||
assert len(task["history"]) == 6
|
||||
with store.connection(readonly=True) as conn:
|
||||
assert conn.execute("SELECT count(*) AS n FROM peripheral_sample.revisions WHERE project_id=%s", (who.project_id,)).fetchone()["n"] == task["revision"]
|
||||
|
||||
|
||||
def test_concurrent_same_request_and_revision_conflict(sample):
|
||||
store, who = sample
|
||||
cmd = command()
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(lambda _: store.create_task(who, cmd), range(4)))
|
||||
assert len({r["task_id"] for r in results}) == 1
|
||||
task = results[0]
|
||||
def race(_):
|
||||
try:
|
||||
return move(store, who, task, "processing")["revision"]
|
||||
except SampleFailure as exc:
|
||||
return exc.code
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
outcomes = list(pool.map(race, range(2)))
|
||||
assert sorted(map(str, outcomes)) == ["2", "REVISION_CONFLICT"]
|
||||
|
||||
|
||||
def test_fault_receipt_recovery_duplicate_conflict_and_late_state(sample):
|
||||
store, who = sample
|
||||
scenario(store, who, "response_lost")
|
||||
cmd = command()
|
||||
with pytest.raises(SampleFailure) as exc:
|
||||
store.create_task(who, cmd)
|
||||
assert exc.value.effect == "unknown"
|
||||
task = store.receipt(who, cmd.request_id)["result"]
|
||||
assert store.create_task(who, cmd) == task
|
||||
scenario(store, who, "unavailable")
|
||||
with pytest.raises(SampleFailure):
|
||||
store.get_task(who, task["task_id"])
|
||||
assert store.receipt(who, cmd.request_id)["task_id"] == task["task_id"]
|
||||
scenario(store, who, "conflicting_feedback")
|
||||
task = move(store, who, task, "processing")
|
||||
task = store.add_feedback(who, task["task_id"], feedback(task))
|
||||
assert {f["finding"] for f in task["feedback"]} == {"instrument_fault", "no_instrument_fault"}
|
||||
scenario(store, who, "duplicate_feedback")
|
||||
assert len(store.get_task(who, task["task_id"])["feedback"]) == 3
|
||||
scenario(store, who, "late_state")
|
||||
assert store.get_task(who, task["task_id"])["revision"] == task["revision"] - 1
|
||||
scenario(store, who, "normal")
|
||||
assert store.get_task(who, task["task_id"]) == task
|
||||
disabled = PostgresStore(SampleSettings(enable_faults=False))
|
||||
with pytest.raises(SampleFailure) as exc:
|
||||
scenario(disabled, who, "missing_data")
|
||||
assert exc.value.code == "FAULTS_DISABLED"
|
||||
|
||||
|
||||
def test_historical_monitoring_readonly_mapping_and_missing_data(sample):
|
||||
store, who = sample
|
||||
points = store.points(who, limit=500)
|
||||
assert points["origin"] == "historical_replay"
|
||||
assert points["items"]
|
||||
point = points["items"][0]
|
||||
kwargs = {"point_id": point["point_id"], "start": "2026-04-30T08:00:00Z", "end": "2026-04-30T16:00:00Z"}
|
||||
normal = store.observations(who, **kwargs)
|
||||
Observations.model_validate(normal)
|
||||
assert normal["items"]
|
||||
assert {x["unit"] for x in normal["items"]} == {point["unit"]}
|
||||
with store.connection(readonly=True, timeseries=True) as conn:
|
||||
assert conn.execute("SHOW transaction_read_only").fetchone()["transaction_read_only"] == "on"
|
||||
scenario(store, who, "missing_data")
|
||||
missing = store.observations(who, **kwargs)
|
||||
assert missing["origin"] == "synthetic"
|
||||
assert all(item["value"] is None for item in missing["items"][::2])
|
||||
assert store.alerts(who)["availability"] == "not_configured"
|
||||
|
||||
|
||||
def test_managed_work_orders_and_test_data_persist_revision_and_isolation(sample):
|
||||
store, who = sample
|
||||
capabilities = store.managed_resource_capabilities()
|
||||
if capabilities != {"work_orders": "available", "test_data": "available"}:
|
||||
pytest.skip("managed peripheral resource tables are not provisioned")
|
||||
|
||||
order_command = CreateWorkOrder(
|
||||
request_id=uuid4(),
|
||||
title="演示 live test work order",
|
||||
summary="AUTOTEST isolated synthetic resource",
|
||||
status="open",
|
||||
priority="high",
|
||||
assignee="AUTOTEST",
|
||||
scheduled_at="2026-09-18T01:00:00Z",
|
||||
)
|
||||
order = store.create_work_order(who, order_command)
|
||||
WorkOrder.model_validate(order)
|
||||
assert PostgresStore(store.settings).get_work_order(who, order["work_order_id"]) == order
|
||||
assert store.create_work_order(who, order_command) == order
|
||||
order = store.update_work_order(who, order["work_order_id"], UpdateWorkOrder(
|
||||
request_id=uuid4(), expected_revision=1, status="in_progress", priority="urgent",
|
||||
))
|
||||
assert order["revision"] == 2 and order["status"] == "in_progress"
|
||||
with pytest.raises(SampleFailure) as stale_order:
|
||||
store.update_work_order(who, order["work_order_id"], UpdateWorkOrder(
|
||||
request_id=uuid4(), expected_revision=1, status="completed",
|
||||
))
|
||||
assert stale_order.value.code == "REVISION_CONFLICT"
|
||||
|
||||
data_command = CreateTestDataRecord(
|
||||
request_id=uuid4(),
|
||||
label="演示 live pressure",
|
||||
point_id="演示",
|
||||
observed_at="2026-09-18T00:00:00Z",
|
||||
value=31.2,
|
||||
unit="m",
|
||||
quality="valid",
|
||||
notes="AUTOTEST synthetic only",
|
||||
)
|
||||
record = store.create_test_data(who, data_command)
|
||||
peripheral_schemas.TestDataRecord.model_validate(record)
|
||||
assert PostgresStore(store.settings).get_test_data(who, record["record_id"]) == record
|
||||
assert store.create_test_data(who, data_command) == record
|
||||
record = store.update_test_data(who, record["record_id"], UpdateTestDataRecord(
|
||||
request_id=uuid4(), expected_revision=1, value=None, clear_value=True, quality="missing",
|
||||
))
|
||||
assert record["revision"] == 2 and record["value"] is None
|
||||
|
||||
outsider = Identity(str(uuid4()), who.user_id, True)
|
||||
assert store.list_work_orders(outsider, 20)["items"] == []
|
||||
assert store.list_test_data(outsider, 20)["items"] == []
|
||||
assert [item["work_order_id"] for item in store.list_work_orders(who, 20)["items"]] == [
|
||||
order["work_order_id"]
|
||||
]
|
||||
assert [item["record_id"] for item in store.list_test_data(who, 20)["items"]] == [
|
||||
record["record_id"]
|
||||
]
|
||||
|
||||
deleted_order = store.delete_work_order(who, order["work_order_id"], DeleteResource(
|
||||
request_id=uuid4(), expected_revision=2,
|
||||
))
|
||||
deleted_data = store.delete_test_data(who, record["record_id"], DeleteResource(
|
||||
request_id=uuid4(), expected_revision=2,
|
||||
))
|
||||
assert deleted_order["revision"] == 3 and deleted_data["revision"] == 3
|
||||
assert store.list_work_orders(who, 20)["items"] == []
|
||||
assert store.list_test_data(who, 20)["items"] == []
|
||||
with store.connection(readonly=True) as conn:
|
||||
order_revisions = conn.execute(
|
||||
"""SELECT count(*) AS n FROM peripheral_sample.work_order_revisions
|
||||
WHERE project_id=%s AND work_order_id=%s""",
|
||||
(who.project_id, order["work_order_id"]),
|
||||
).fetchone()["n"]
|
||||
data_revisions = conn.execute(
|
||||
"""SELECT count(*) AS n FROM peripheral_sample.test_data_record_revisions
|
||||
WHERE project_id=%s AND record_id=%s""",
|
||||
(who.project_id, record["record_id"]),
|
||||
).fetchone()["n"]
|
||||
assert order_revisions == 3 and data_revisions == 3
|
||||
|
||||
|
||||
def test_managed_resource_http_routes_use_provisioned_storage(sample):
|
||||
store, who = sample
|
||||
capabilities = store.managed_resource_capabilities()
|
||||
if capabilities != {"work_orders": "available", "test_data": "available"}:
|
||||
pytest.skip("managed peripheral resource tables are not provisioned")
|
||||
|
||||
app = create_app(store.settings)
|
||||
app.dependency_overrides[identity] = lambda: who
|
||||
headers = {
|
||||
"Authorization": "Bearer TEST-FIXTURE-ONLY",
|
||||
"X-Project-Id": who.project_id,
|
||||
}
|
||||
with TestClient(app) as client:
|
||||
order = client.post("/api/sample/v1/work-orders", headers=headers, json={
|
||||
"request_id": str(uuid4()),
|
||||
"title": "演示 HTTP test work order",
|
||||
"summary": "AUTOTEST isolated synthetic resource",
|
||||
"status": "open",
|
||||
"priority": "normal",
|
||||
"assignee": None,
|
||||
"scheduled_at": None,
|
||||
})
|
||||
assert order.status_code == 201, order.text
|
||||
order_document = order.json()
|
||||
listed_orders = client.get("/api/sample/v1/work-orders", headers=headers)
|
||||
assert [item["work_order_id"] for item in listed_orders.json()["items"]] == [
|
||||
order_document["work_order_id"]
|
||||
]
|
||||
changed_order = client.patch(
|
||||
f"/api/sample/v1/work-orders/{order_document['work_order_id']}",
|
||||
headers=headers,
|
||||
json={
|
||||
"request_id": str(uuid4()), "expected_revision": 1,
|
||||
"status": "in_progress",
|
||||
},
|
||||
)
|
||||
assert changed_order.status_code == 200 and changed_order.json()["revision"] == 2
|
||||
|
||||
record = client.post("/api/sample/v1/test-data", headers=headers, json={
|
||||
"request_id": str(uuid4()),
|
||||
"label": "演示 HTTP pressure",
|
||||
"point_id": "演示",
|
||||
"observed_at": "2026-09-18T00:00:00Z",
|
||||
"value": 31.2,
|
||||
"unit": "m",
|
||||
"quality": "valid",
|
||||
"notes": "AUTOTEST synthetic only",
|
||||
})
|
||||
assert record.status_code == 201, record.text
|
||||
record_document = record.json()
|
||||
listed_data = client.get("/api/sample/v1/test-data", headers=headers)
|
||||
assert [item["record_id"] for item in listed_data.json()["items"]] == [
|
||||
record_document["record_id"]
|
||||
]
|
||||
changed_data = client.patch(
|
||||
f"/api/sample/v1/test-data/{record_document['record_id']}",
|
||||
headers=headers,
|
||||
json={
|
||||
"request_id": str(uuid4()), "expected_revision": 1,
|
||||
"value": 30.8,
|
||||
},
|
||||
)
|
||||
assert changed_data.status_code == 200 and changed_data.json()["revision"] == 2
|
||||
|
||||
deleted_order = client.request(
|
||||
"DELETE",
|
||||
f"/api/sample/v1/work-orders/{order_document['work_order_id']}",
|
||||
headers=headers,
|
||||
json={"request_id": str(uuid4()), "expected_revision": 2},
|
||||
)
|
||||
deleted_data = client.request(
|
||||
"DELETE",
|
||||
f"/api/sample/v1/test-data/{record_document['record_id']}",
|
||||
headers=headers,
|
||||
json={"request_id": str(uuid4()), "expected_revision": 2},
|
||||
)
|
||||
assert deleted_order.status_code == 200 and deleted_order.json()["revision"] == 3
|
||||
assert deleted_data.status_code == 200 and deleted_data.json()["revision"] == 3
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Opt-in PostgreSQL checks for the task alert event state machine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.core.config import settings
|
||||
from app.domain.schemas.task_alert import TaskAlertEvent
|
||||
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
||||
from app.services.task_alerts import TaskAlertService
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(
|
||||
os.getenv("TASK_ALERT_LIVE_TEST") != "1",
|
||||
reason="set TASK_ALERT_LIVE_TEST=1 to use provisioned workbench alert tables",
|
||||
),
|
||||
pytest.mark.anyio,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_alert_event_round_trip_and_idempotency():
|
||||
project_id = uuid4()
|
||||
now = datetime(2026, 9, 18, 3, tzinfo=timezone.utc)
|
||||
conn = await AsyncConnection.connect(
|
||||
host=settings.DB_HOST,
|
||||
port=settings.DB_PORT,
|
||||
user=settings.DB_USER,
|
||||
password=settings.DB_PASSWORD,
|
||||
dbname="tjwater_v2",
|
||||
connect_timeout=3,
|
||||
options="-c statement_timeout=10000",
|
||||
row_factory=dict_row,
|
||||
)
|
||||
try:
|
||||
service = TaskAlertService(
|
||||
TaskAlertRepository(conn, project_id),
|
||||
None,
|
||||
clock=lambda: now,
|
||||
internal_producer_id="AUTOTEST-scheduler",
|
||||
)
|
||||
event = TaskAlertEvent(
|
||||
event_id=uuid4(),
|
||||
producer_id="AUTOTEST-scheduler",
|
||||
alert_id=f"AUTOTEST-{uuid4()}",
|
||||
event_type="raised",
|
||||
occurred_at=now,
|
||||
condition_id=None,
|
||||
source={
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "AUTOTEST-schedule",
|
||||
"run_id": "AUTOTEST-occurrence",
|
||||
},
|
||||
title="AUTOTEST alert",
|
||||
description="Synthetic integration record",
|
||||
severity="warning",
|
||||
)
|
||||
first = await service.push(event)
|
||||
repeated = await service.push(event)
|
||||
assert first == repeated
|
||||
row = await conn.execute(
|
||||
"""SELECT revision,status,document FROM workbench.alerts
|
||||
WHERE project_id=%s AND alert_id=%s""",
|
||||
(project_id, event.alert_id),
|
||||
)
|
||||
stored = await row.fetchone()
|
||||
assert stored["revision"] == 1 and stored["status"] == "active"
|
||||
assert stored["document"]["producer_id"] == "AUTOTEST-scheduler"
|
||||
finally:
|
||||
# Assertion reads open an implicit transaction. End it before starting the
|
||||
# cleanup transaction, otherwise closing the connection rolls the nested
|
||||
# cleanup savepoint back together with that read transaction.
|
||||
await conn.rollback()
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"DELETE FROM workbench.alert_events WHERE project_id=%s", (project_id,)
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM workbench.alerts WHERE project_id=%s", (project_id,)
|
||||
)
|
||||
await conn.close()
|
||||
@@ -44,3 +44,34 @@ def test_project_workspace_sets_30_day_client_cache(monkeypatch):
|
||||
|
||||
assert layers == geoserver_client.PROJECT_LAYER_NAMES
|
||||
assert cache_updates == ["2592000"] * len(layers)
|
||||
|
||||
|
||||
def test_publish_feature_type_updates_existing_layer_and_cache(monkeypatch):
|
||||
requests: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.method == "GET" and request.url.path.endswith("/featuretypes/demo_layer.json"):
|
||||
return httpx.Response(200, json={"featureType": {"name": "demo_layer"}})
|
||||
if request.method == "GET" and "/gwc/rest/layers/" in request.url.path:
|
||||
return httpx.Response(200, content=b"<GeoServerLayer><name>demo</name></GeoServerLayer>")
|
||||
return httpx.Response(200)
|
||||
|
||||
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_USERNAME", "admin")
|
||||
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_PASSWORD", "secret")
|
||||
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_CLIENT_CACHE_SECONDS", 2_592_000)
|
||||
http_client = httpx.Client(
|
||||
base_url="http://geoserver.example/geoserver",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
geoserver_client.GeoServerAdminClient(client=http_client).publish_feature_type(
|
||||
workspace="demo",
|
||||
datastore="demo",
|
||||
layer="demo_layer",
|
||||
title="Demo layer",
|
||||
map_bbox=(1.0, 2.0, 3.0, 4.0),
|
||||
)
|
||||
|
||||
assert ("PUT", "/geoserver/rest/workspaces/demo/datastores/demo/featuretypes/demo_layer") in requests
|
||||
assert ("PUT", "/geoserver/gwc/rest/layers/demo:demo_layer.xml") in requests
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
|
||||
from tests.conftest import FakeConnection, FakeCursor
|
||||
|
||||
|
||||
def test_list_element_groups_returns_empty_when_source_gis_is_not_provisioned():
|
||||
cursor = FakeCursor(fetchone_results=[{"relation": None}])
|
||||
|
||||
result = asyncio.run(
|
||||
GisAttributeRepository.list_element_groups(
|
||||
FakeConnection(cursor), "pipes", "P-1"
|
||||
)
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert len(cursor.executed) == 1
|
||||
|
||||
|
||||
def test_list_element_groups_reads_linked_attributes():
|
||||
row = {
|
||||
"dataset_key": "fx_pipe_inventory_20200811",
|
||||
"dataset_title": "奉贤 DN100 及以上管网台账",
|
||||
"source_date": date(2020, 8, 11),
|
||||
"layer_key": "fx_20200811_dn100_pipes",
|
||||
"layer_name": "DN100 及以上管线台账",
|
||||
"source_fid": 42,
|
||||
"match_method": "geometry",
|
||||
"match_distance_m": 0.12,
|
||||
"attributes": {"PIPE_D": 300, "MATERIAL": "DI"},
|
||||
}
|
||||
cursor = FakeCursor(
|
||||
fetchone_results=[{"relation": "source_gis.element_links"}],
|
||||
fetchall_results=[[row]],
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
GisAttributeRepository.list_element_groups(
|
||||
FakeConnection(cursor), "pipes", "P-42"
|
||||
)
|
||||
)
|
||||
|
||||
assert result == [row]
|
||||
assert cursor.executed[1][1] == ("pipes", "P-42")
|
||||
@@ -0,0 +1,154 @@
|
||||
import json
|
||||
import inspect
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.services.content_digest import digest
|
||||
from samples.peripheral import auth
|
||||
from samples.peripheral.api import create_app, store
|
||||
from samples.peripheral.config import SampleSettings
|
||||
from samples.peripheral.demo_data import VERSION, build_demo_content
|
||||
from samples.peripheral.errors import SampleFailure
|
||||
from samples.peripheral.store import PostgresStore
|
||||
|
||||
|
||||
START = datetime(2026, 9, 17, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_same_seed_start_and_version_are_stable_across_processes():
|
||||
content = build_demo_content("repeatable", START)
|
||||
assert content == build_demo_content("repeatable", START)
|
||||
assert content["generator_version"] == VERSION
|
||||
expected_digest = digest(content)
|
||||
script = """
|
||||
import json
|
||||
from datetime import datetime
|
||||
from app.services.content_digest import digest
|
||||
from samples.peripheral.demo_data import build_demo_content
|
||||
content = build_demo_content('repeatable', datetime.fromisoformat('2026-09-17T00:00:00+00:00'))
|
||||
print(json.dumps({'digest': digest(content), 'ids': [x['record_id'] for x in content['observations'][:3]]}))
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script], check=True, capture_output=True, text=True,
|
||||
)
|
||||
child = json.loads(result.stdout)
|
||||
assert child["digest"] == expected_digest
|
||||
assert child["ids"] == [item["record_id"] for item in content["observations"][:3]]
|
||||
assert all(UUID(item["record_id"]).version == 5 for item in content["observations"])
|
||||
|
||||
|
||||
def test_default_canonical_digest_is_versioned_regression_anchor():
|
||||
content = build_demo_content()
|
||||
assert digest(content) == "sha256:c630f66ad0247f70a4afe79cc5f95f4be35d54acfe85fbfe39f3166ee09a57c5"
|
||||
assert (len(content["points"]), len(content["observations"]), len(content["device_states"])) == (5, 240, 9)
|
||||
|
||||
|
||||
def test_seed_changes_content_and_timezone_is_normalized():
|
||||
base = build_demo_content("seed-a", START)
|
||||
assert digest(base) != digest(build_demo_content("seed-b", START))
|
||||
same_in_china = build_demo_content("seed-a", START.astimezone(timezone(timedelta(hours=8))))
|
||||
assert same_in_china == base
|
||||
assert base["window"]["start"] == "2026-09-17T00:00:00Z"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", ["", "x" * 129, None, 7])
|
||||
def test_invalid_seed_is_rejected(seed):
|
||||
with pytest.raises(ValueError, match="seed"):
|
||||
build_demo_content(seed, START)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start", [
|
||||
datetime(2026, 9, 17),
|
||||
datetime(2026, 9, 17, 0, 1, tzinfo=timezone.utc),
|
||||
datetime(2026, 9, 17, 0, 0, 1, tzinfo=timezone.utc),
|
||||
])
|
||||
def test_invalid_start_is_rejected(start):
|
||||
with pytest.raises(ValueError, match="timezone|aligned"):
|
||||
build_demo_content("seed", start)
|
||||
|
||||
|
||||
def test_story_counts_null_zero_and_temporal_links_are_explicit():
|
||||
content = build_demo_content("story", START)
|
||||
assert len(content["points"]) == 5
|
||||
assert len(content["observations"]) == 240
|
||||
assert len(content["device_states"]) == 9
|
||||
assert len(content["alerts"]) == 2
|
||||
assert len(content["operations"]) == 4
|
||||
|
||||
missing = [item for item in content["observations"] if item["value"] is None]
|
||||
zero = [item for item in content["observations"] if item["value"] == 0.0]
|
||||
assert len(missing) == 4 and all(item["quality"] == "missing" for item in missing)
|
||||
assert len(zero) == 4 and all(item["quality"] == "valid" for item in zero)
|
||||
|
||||
states = {(item["asset_id"], item["state"]): item["observed_at"] for item in content["device_states"]}
|
||||
communication = next(item for item in content["alerts"] if item["code"] == "communication_lost")
|
||||
assert communication["asset_id"] == "DEMO-P001"
|
||||
assert communication["occurred_at"] == states[("DEMO-P001", "offline")]
|
||||
assert communication["cleared_at"] == states[("DEMO-P001", "online")]
|
||||
operations = {(item["asset_id"], item["operation"]): item["occurred_at"] for item in content["operations"]}
|
||||
assert operations[("DEMO-PUMP-01", "pump_stop")] == states[("DEMO-PUMP-01", "stopped")]
|
||||
assert operations[("DEMO-VALVE-01", "valve_close")] == states[("DEMO-VALVE-01", "closed")]
|
||||
assert len({item["record_id"] for item in content["observations"] + content["device_states"] + content["operations"]}) == 253
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, result=None, failure=None):
|
||||
self.result = result
|
||||
self.failure = failure
|
||||
self.reads = 0
|
||||
|
||||
def demo_data(self, who):
|
||||
self.reads += 1
|
||||
if self.failure:
|
||||
raise self.failure
|
||||
return {**self.result, "project_id": who.project_id,
|
||||
"source_id": "test:demo", "source_revision": digest(self.result)}
|
||||
|
||||
|
||||
def test_demo_endpoint_uses_next_auth_and_allows_read_only_permission(monkeypatch):
|
||||
project_id, user_id = uuid4(), uuid4()
|
||||
fake = FakeStore(build_demo_content("api", START))
|
||||
app = create_app(SampleSettings(project_code="tjwater_v2"))
|
||||
app.dependency_overrides[store] = lambda: fake
|
||||
monkeypatch.setattr(auth, "next_get", lambda request, path, params=None: {
|
||||
"project_id": str(project_id), "user_id": str(user_id), "network": "tjwater_v2",
|
||||
"permissions": ["simulation.view"],
|
||||
})
|
||||
response = TestClient(app).get("/api/sample/v1/demo-data", headers={
|
||||
"Authorization": "Bearer TEST", "X-Project-Id": str(project_id),
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["project_id"] == str(project_id)
|
||||
assert fake.reads == 1
|
||||
|
||||
|
||||
def test_demo_endpoint_requires_auth_and_reports_missing_storage_without_seeding():
|
||||
fake = FakeStore(failure=SampleFailure(
|
||||
"STORAGE_NOT_CONFIGURED", "样板存储表尚未配置", 503,
|
||||
))
|
||||
app = create_app(SampleSettings(project_code="tjwater_v2"))
|
||||
app.dependency_overrides[store] = lambda: fake
|
||||
client = TestClient(app)
|
||||
unauthorized = client.get("/api/sample/v1/demo-data")
|
||||
assert unauthorized.status_code == 401
|
||||
assert fake.reads == 0
|
||||
# Authentication is deliberately isolated here so this assertion tests the
|
||||
# missing-table behavior: the API reports it and never invokes a generator.
|
||||
from samples.peripheral.api import identity
|
||||
from samples.peripheral.auth import Identity
|
||||
app.dependency_overrides[identity] = lambda: Identity(str(uuid4()), str(uuid4()), False)
|
||||
unavailable = client.get("/api/sample/v1/demo-data")
|
||||
assert unavailable.status_code == 503
|
||||
assert unavailable.json()["code"] == "STORAGE_NOT_CONFIGURED"
|
||||
assert fake.reads == 1
|
||||
|
||||
|
||||
def test_demo_read_path_contains_no_schema_or_seed_writes():
|
||||
source = inspect.getsource(PostgresStore.demo_data).upper()
|
||||
assert "SELECT " in source
|
||||
assert all(keyword not in source for keyword in ("CREATE ", "ALTER ", "INSERT ", "UPDATE ", "DELETE "))
|
||||
@@ -0,0 +1,129 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from samples.peripheral.api import create_app, store
|
||||
from samples.peripheral.auth import Identity, identity
|
||||
from samples.peripheral.schemas import CreateTestDataRecord, UpdateTestDataRecord, UpdateWorkOrder
|
||||
|
||||
|
||||
PROJECT_ID = uuid4()
|
||||
USER_ID = uuid4()
|
||||
|
||||
|
||||
class ManagedStore:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def list_work_orders(self, who, limit, cursor):
|
||||
self.calls.append(("list_work_orders", who, limit, cursor))
|
||||
return {"items": [], "next_cursor": None}
|
||||
|
||||
def create_work_order(self, who, command):
|
||||
self.calls.append(("create_work_order", who, command))
|
||||
now = "2026-09-18T00:00:00Z"
|
||||
return command.model_dump(mode="json", exclude={"request_id"}) | {
|
||||
"work_order_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1,
|
||||
"origin": "synthetic", "created_at": now, "updated_at": now,
|
||||
"created_by": str(USER_ID), "updated_by": str(USER_ID),
|
||||
}
|
||||
|
||||
def update_work_order(self, who, work_order_id, command):
|
||||
self.calls.append(("update_work_order", who, work_order_id, command))
|
||||
return self.create_work_order(who, type("Command", (), {"model_dump": lambda self, **kwargs: {
|
||||
"title": command.title or "updated", "summary": command.summary or "summary",
|
||||
"status": command.status or "open", "priority": command.priority or "normal",
|
||||
"assignee": None, "scheduled_at": None}})()) | {"revision": command.expected_revision + 1,
|
||||
"work_order_id": str(work_order_id)}
|
||||
|
||||
def delete_work_order(self, who, work_order_id, command):
|
||||
self.calls.append(("delete_work_order", who, work_order_id, command))
|
||||
return {"resource_type": "work_order", "resource_id": str(work_order_id),
|
||||
"revision": command.expected_revision + 1, "deleted": True}
|
||||
|
||||
def list_test_data(self, who, limit, cursor):
|
||||
self.calls.append(("list_test_data", who, limit, cursor))
|
||||
return {"items": [], "next_cursor": None}
|
||||
|
||||
def create_test_data(self, who, command):
|
||||
self.calls.append(("create_test_data", who, command))
|
||||
now = "2026-09-18T00:00:00Z"
|
||||
return command.model_dump(mode="json", exclude={"request_id"}) | {
|
||||
"record_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1,
|
||||
"origin": "synthetic", "created_at": now, "updated_at": now,
|
||||
"created_by": str(USER_ID), "updated_by": str(USER_ID),
|
||||
}
|
||||
|
||||
def update_test_data(self, who, record_id, command):
|
||||
self.calls.append(("update_test_data", who, record_id, command))
|
||||
return self.create_test_data(who, CreateTestDataRecord(
|
||||
request_id=command.request_id, label=command.label or "updated", point_id=command.point_id or "演示",
|
||||
observed_at=command.observed_at or "2026-09-18T00:00:00Z", value=command.value,
|
||||
unit=command.unit or "m", quality=command.quality or "valid", notes=command.notes or "",
|
||||
)) | {"revision": command.expected_revision + 1, "record_id": str(record_id)}
|
||||
|
||||
def delete_test_data(self, who, record_id, command):
|
||||
self.calls.append(("delete_test_data", who, record_id, command))
|
||||
return {"resource_type": "test_data_record", "resource_id": str(record_id),
|
||||
"revision": command.expected_revision + 1, "deleted": True}
|
||||
|
||||
|
||||
def client_with(fake):
|
||||
app = create_app()
|
||||
app.dependency_overrides[store] = lambda: fake
|
||||
app.dependency_overrides[identity] = lambda: Identity(str(PROJECT_ID), str(USER_ID), True)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_managed_work_order_and_data_routes_are_typed_crud():
|
||||
fake = ManagedStore()
|
||||
with client_with(fake) as client:
|
||||
assert client.get("/api/sample/v1/work-orders").json()["items"] == []
|
||||
created = client.post("/api/sample/v1/work-orders", json={
|
||||
"request_id": str(uuid4()), "title": "演示 测试工单", "summary": "外围联调",
|
||||
"status": "open", "priority": "high", "assignee": None, "scheduled_at": None,
|
||||
})
|
||||
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
|
||||
work_order_id = created.json()["work_order_id"]
|
||||
changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={
|
||||
"request_id": str(uuid4()), "expected_revision": 1, "status": "in_progress",
|
||||
"clear_assignee": False, "clear_scheduled_at": False,
|
||||
})
|
||||
assert changed.status_code == 200 and changed.json()["revision"] == 2
|
||||
deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={
|
||||
"request_id": str(uuid4()), "expected_revision": 2,
|
||||
})
|
||||
assert deleted.json()["resource_type"] == "work_order"
|
||||
|
||||
assert client.get("/api/sample/v1/test-data").json()["items"] == []
|
||||
data = client.post("/api/sample/v1/test-data", json={
|
||||
"request_id": str(uuid4()), "label": "演示 压力", "point_id": "演示",
|
||||
"observed_at": "2026-09-18T00:00:00Z", "value": 31.2, "unit": "m",
|
||||
"quality": "valid", "notes": "联调",
|
||||
})
|
||||
assert data.status_code == 201 and data.json()["point_id"] == "演示"
|
||||
record_id = data.json()["record_id"]
|
||||
changed_data = client.patch(f"/api/sample/v1/test-data/{record_id}", json={
|
||||
"request_id": str(uuid4()), "expected_revision": 1, "value": 30.8, "clear_value": False,
|
||||
})
|
||||
assert changed_data.status_code == 200 and changed_data.json()["revision"] == 2
|
||||
deleted_data = client.request("DELETE", f"/api/sample/v1/test-data/{record_id}", json={
|
||||
"request_id": str(uuid4()), "expected_revision": 2,
|
||||
})
|
||||
assert deleted_data.json()["resource_type"] == "test_data_record"
|
||||
|
||||
|
||||
def test_managed_update_models_reject_empty_or_conflicting_changes():
|
||||
request_id = uuid4()
|
||||
for model, payload in (
|
||||
(UpdateWorkOrder, {"request_id": request_id, "expected_revision": 1}),
|
||||
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1}),
|
||||
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1,
|
||||
"value": 1.0, "clear_value": True}),
|
||||
):
|
||||
try:
|
||||
model.model_validate(payload)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("invalid partial update was accepted")
|
||||
@@ -40,7 +40,7 @@ def test_list_results_uses_typed_comparison_when_result_type_is_present():
|
||||
query, params = conn.cursor_instance.calls[0]
|
||||
assert "result_type = %s" in query
|
||||
assert "%s IS NULL" not in query
|
||||
assert params == (run_id, "leakage_identification")
|
||||
assert params == (run_id, "leakage_identification", 100, 0)
|
||||
|
||||
|
||||
def test_list_results_omits_result_type_filter_when_not_requested():
|
||||
@@ -51,4 +51,56 @@ def test_list_results_omits_result_type_filter_when_not_requested():
|
||||
|
||||
query, params = conn.cursor_instance.calls[0]
|
||||
assert "result_type = %s" not in query
|
||||
assert params == (run_id,)
|
||||
assert params == (run_id, 100, 0)
|
||||
|
||||
|
||||
def test_list_runs_and_results_apply_explicit_page_bounds():
|
||||
conn = _FakeConnection()
|
||||
run_id = uuid4()
|
||||
|
||||
asyncio.run(AnalysisRepository.list_runs(conn, limit=25, offset=50))
|
||||
asyncio.run(
|
||||
AnalysisRepository.list_results(conn, run_id, limit=40, offset=80)
|
||||
)
|
||||
|
||||
run_query, run_params = conn.cursor_instance.calls[0]
|
||||
result_query, result_params = conn.cursor_instance.calls[1]
|
||||
assert "LIMIT %s OFFSET %s" in run_query
|
||||
assert run_params == (25, 50)
|
||||
assert "LIMIT %s OFFSET %s" in result_query
|
||||
assert result_params == (run_id, 40, 80)
|
||||
|
||||
|
||||
def test_list_recent_runs_is_bounded_and_orders_newest_first():
|
||||
conn = _FakeConnection()
|
||||
|
||||
asyncio.run(AnalysisRepository.list_recent_runs(conn, limit=25))
|
||||
|
||||
query, params = conn.cursor_instance.calls[0]
|
||||
assert "ORDER BY created_at DESC, run_id" in query
|
||||
assert "LIMIT %s" in query
|
||||
assert params == (25,)
|
||||
|
||||
|
||||
def test_list_recent_results_bounds_latest_rows_then_restores_display_order():
|
||||
conn = _FakeConnection()
|
||||
run_id = uuid4()
|
||||
|
||||
asyncio.run(AnalysisRepository.list_recent_results(conn, run_id, limit=25))
|
||||
|
||||
query, params = conn.cursor_instance.calls[0]
|
||||
assert "ORDER BY created_at DESC, result_id DESC" in query
|
||||
assert "ORDER BY created_at, result_id" in query
|
||||
assert params == (run_id, 25)
|
||||
|
||||
|
||||
def test_list_run_summaries_is_bounded_and_checks_for_results():
|
||||
conn = _FakeConnection()
|
||||
|
||||
asyncio.run(AnalysisRepository.list_run_summaries(conn, limit=11, offset=20))
|
||||
|
||||
query, params = conn.cursor_instance.calls[0]
|
||||
assert "EXISTS" in query
|
||||
assert "analysis.results" in query
|
||||
assert "ORDER BY r.created_at DESC, r.run_id" in query
|
||||
assert params == (11, 20)
|
||||
|
||||
@@ -159,9 +159,13 @@ def test_create_sensor_placement_writes_run_and_result_atomically(monkeypatch):
|
||||
min_diameter=300,
|
||||
created_by="alice",
|
||||
sensor_locations=["J1", "J2"],
|
||||
sensor_type="water_quality",
|
||||
method="sensitivity",
|
||||
)
|
||||
|
||||
assert created["sensor_count"] == 2
|
||||
assert created["sensor_type"] == "water_quality"
|
||||
assert created["method"] == "sensitivity"
|
||||
statements = [call.args[0] for call in cursor.execute.call_args_list]
|
||||
assert "INSERT INTO analysis.runs" in statements[0]
|
||||
assert "INSERT INTO analysis.results" in statements[1]
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user