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
|
```bash
|
||||||
conda run -n server python -m pytest tests -q
|
conda run -n server python -m pytest tests -q
|
||||||
conda run -n server python scripts/run_server.py
|
conda run -n server python scripts/run_server.py
|
||||||
@@ -87,6 +90,16 @@ docker compose -f infra/docker/docker-compose.yml config
|
|||||||
|
|
||||||
使用模板复制或临时方案库的模拟功能时,`biz_data` 账号必须具备数据库创建和删除权限;只有显式删除项目时才会终止该项目的现有数据库会话,普通复制不会主动中断复制源会话。
|
使用模板复制或临时方案库的模拟功能时,`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 uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
|
||||||
from app.auth.project_dependencies import get_project_pg_connection
|
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()
|
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),
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
):
|
):
|
||||||
"""获取数据库连接"""
|
normalized_layer_key = layer_key.strip()
|
||||||
yield conn
|
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="获取分析运行列表")
|
@router.get("/analysis/runs", summary="获取分析运行列表")
|
||||||
async def get_analysis_runs(
|
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:
|
try:
|
||||||
runs = await AnalysisRepository.list_runs(conn)
|
runs = await AnalysisRepository.list_runs(conn, limit=limit, offset=offset)
|
||||||
return {"success": True, "data": runs, "count": len(runs)}
|
return {
|
||||||
except Exception as e:
|
"success": True,
|
||||||
raise HTTPException(status_code=500, detail=f"查询分析运行时发生错误: {str(e)}")
|
"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="获取分析运行")
|
@router.get("/analysis/runs/{run_id}", summary="获取分析运行")
|
||||||
async def get_analysis_run(
|
async def get_analysis_run(
|
||||||
run_id: UUID,
|
run_id: UUID,
|
||||||
conn: AsyncConnection = Depends(get_database_connection),
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
):
|
):
|
||||||
"""
|
"""返回当前项目的一条持久分析运行。"""
|
||||||
获取所有爆管定位结果
|
|
||||||
|
|
||||||
返回项目中所有的爆管定位分析结果
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
run = await AnalysisRepository.get_run(conn, run_id)
|
run = await AnalysisRepository.get_run(conn, run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
@@ -49,28 +118,31 @@ async def get_analysis_run(
|
|||||||
return run
|
return run
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
logger.warning("Analysis run is unavailable", exc_info=True)
|
||||||
status_code=500, detail=f"查询分析运行时发生错误: {str(e)}"
|
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/analysis/runs/{run_id}/results", summary="获取分析结果")
|
@router.get("/analysis/runs/{run_id}/results", summary="获取分析结果")
|
||||||
async def get_analysis_results(
|
async def get_analysis_results(
|
||||||
run_id: UUID,
|
run_id: UUID,
|
||||||
result_type: str | None = Query(default=None, description="结果类型"),
|
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:
|
try:
|
||||||
return await AnalysisRepository.list_results(conn, run_id, result_type)
|
return await AnalysisRepository.list_results(
|
||||||
except Exception as e:
|
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(
|
raise HTTPException(
|
||||||
status_code=500,
|
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,
|
sensor_count=payload.sensor_count,
|
||||||
min_diameter=payload.min_diameter,
|
min_diameter=payload.min_diameter,
|
||||||
created_by=current_user.username,
|
created_by=current_user.username,
|
||||||
|
sensor_type=payload.sensor_type,
|
||||||
)
|
)
|
||||||
run = get_sensor_placement_run(network, created["run_id"])
|
run = get_sensor_placement_run(network, created["run_id"])
|
||||||
return {**run, "can_edit": True}
|
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,
|
sensor_placement,
|
||||||
simulation,
|
simulation,
|
||||||
web_search,
|
web_search,
|
||||||
|
workbench_runtime,
|
||||||
)
|
)
|
||||||
from app.api.v1.endpoints.components import (
|
from app.api.v1.endpoints.components import (
|
||||||
controls,
|
controls,
|
||||||
@@ -85,6 +86,11 @@ burst_run_access = Depends(require_permission(BURST_RUN))
|
|||||||
optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN))
|
optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN))
|
||||||
|
|
||||||
# Core services
|
# 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(access.router, tags=["Access Control"])
|
||||||
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ class Settings(BaseSettings):
|
|||||||
ENVIRONMENT: str = "production"
|
ENVIRONMENT: str = "production"
|
||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
NETWORK_NAME: str = "default_network"
|
NETWORK_NAME: str = "default_network"
|
||||||
|
|
||||||
# 敏感配置加密密钥 (Fernet)
|
# 敏感配置加密密钥 (Fernet)
|
||||||
DATABASE_ENCRYPTION_KEY: str = ""
|
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]+$",
|
pattern=r"^[^/\\\x00]+$",
|
||||||
)
|
)
|
||||||
run_name: str = Field(..., min_length=1, max_length=64)
|
run_name: str = Field(..., min_length=1, max_length=64)
|
||||||
sensor_type: Literal["pressure"]
|
sensor_type: Literal["pressure", "water_quality"]
|
||||||
method: Literal["sensitivity", "kmeans"]
|
method: Literal["sensitivity", "kmeans"]
|
||||||
sensor_count: int = Field(..., gt=0, le=200)
|
sensor_count: int = Field(..., gt=0, le=200)
|
||||||
min_diameter: int = Field(default=0, ge=0)
|
min_diameter: int = Field(default=0, ge=0)
|
||||||
@@ -89,6 +89,8 @@ class SensorPlacementSchemeResponse(BaseModel):
|
|||||||
created_by: str
|
created_by: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
status: str
|
status: str
|
||||||
|
sensor_type: Literal["pressure", "water_quality"] = "pressure"
|
||||||
|
method: Literal["sensitivity", "kmeans"] | None = None
|
||||||
sensor_locations: list[str]
|
sensor_locations: list[str]
|
||||||
sensor_points: list[SensorPointResponse]
|
sensor_points: list[SensorPointResponse]
|
||||||
can_edit: bool = False
|
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()
|
return cur.fetchone()
|
||||||
|
|
||||||
@staticmethod
|
@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:
|
async with conn.cursor() as cur:
|
||||||
await cur.execute(
|
await cur.execute(
|
||||||
"""
|
"""
|
||||||
@@ -109,7 +113,52 @@ class AnalysisRepository:
|
|||||||
started_at, status, parameters
|
started_at, status, parameters
|
||||||
FROM analysis.runs
|
FROM analysis.runs
|
||||||
ORDER BY created_at DESC, run_id
|
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()
|
return await cur.fetchall()
|
||||||
|
|
||||||
@@ -129,20 +178,75 @@ class AnalysisRepository:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def list_results(
|
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]:
|
) -> list[dict]:
|
||||||
|
if not 1 <= limit <= 500 or offset < 0:
|
||||||
|
raise ValueError("analysis result page is out of range")
|
||||||
query = """
|
query = """
|
||||||
SELECT result_id, run_id, result_type, node_id, link_id,
|
SELECT result_id, run_id, result_type, node_id, link_id,
|
||||||
payload, created_at
|
payload, created_at
|
||||||
FROM analysis.results
|
FROM analysis.results
|
||||||
WHERE run_id = %s
|
WHERE run_id = %s
|
||||||
"""
|
"""
|
||||||
params: tuple[UUID] | tuple[UUID, str] = (run_id,)
|
params: tuple = (run_id,)
|
||||||
if result_type is not None:
|
if result_type is not None:
|
||||||
query += " AND result_type = %s"
|
query += " AND result_type = %s"
|
||||||
params = (run_id, result_type)
|
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:
|
async with conn.cursor() as cur:
|
||||||
await cur.execute(query, params)
|
await cur.execute(query, params)
|
||||||
return await cur.fetchall()
|
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_by": row["created_by"],
|
||||||
"created_at": row["created_at"],
|
"created_at": row["created_at"],
|
||||||
"status": row["status"],
|
"status": row["status"],
|
||||||
|
"sensor_type": payload.get("sensor_type", "pressure"),
|
||||||
|
"method": payload.get("method"),
|
||||||
"sensor_locations": locations,
|
"sensor_locations": locations,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +59,15 @@ def create_sensor_placement(
|
|||||||
min_diameter: int,
|
min_diameter: int,
|
||||||
created_by: str,
|
created_by: str,
|
||||||
sensor_locations: list[str],
|
sensor_locations: list[str],
|
||||||
|
sensor_type: str = "pressure",
|
||||||
|
method: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
run_id = uuid4()
|
run_id = uuid4()
|
||||||
payload = {
|
payload = {
|
||||||
"sensor_number": len(sensor_locations),
|
"sensor_number": len(sensor_locations),
|
||||||
"minimum_diameter": min_diameter,
|
"minimum_diameter": min_diameter,
|
||||||
|
"sensor_type": sensor_type,
|
||||||
|
"method": method,
|
||||||
"sensor_locations": sensor_locations,
|
"sensor_locations": sensor_locations,
|
||||||
}
|
}
|
||||||
with project_connection(name) as conn, conn.transaction():
|
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
|
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(
|
def create_project_workspace(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
+9
-8
@@ -28,13 +28,14 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("TJWater CloudService is starting...")
|
logger.info("TJWater CloudService is starting...")
|
||||||
logger.info("**********************************************************")
|
logger.info("**********************************************************")
|
||||||
|
|
||||||
yield
|
try:
|
||||||
# 清理资源
|
yield
|
||||||
await project_connection_manager.close_all()
|
finally:
|
||||||
close_all_timescale_pools()
|
await project_connection_manager.close_all()
|
||||||
close_all_project_pools()
|
close_all_timescale_pools()
|
||||||
await close_metadata_engine()
|
close_all_project_pools()
|
||||||
logger.info("Database connections closed")
|
await close_metadata_engine()
|
||||||
|
logger.info("Database connections closed")
|
||||||
|
|
||||||
|
|
||||||
# 根据环境配置决定是否启用文档
|
# 根据环境配置决定是否启用文档
|
||||||
@@ -55,7 +56,7 @@ app = FastAPI(
|
|||||||
# Include Routers
|
# Include Routers
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
install_problem_details_handlers(app)
|
install_problem_details_handlers(app)
|
||||||
# Legcy Routers without version prefix
|
# Legacy routers without version prefix
|
||||||
# app.include_router(api_router)
|
# 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,
|
min_diameter: int,
|
||||||
created_by: str,
|
created_by: str,
|
||||||
sensor_locations: list[str],
|
sensor_locations: list[str],
|
||||||
|
sensor_type: str = "pressure",
|
||||||
|
method: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
validate_sensor_placement_nodes(project_code, sensor_locations)
|
validate_sensor_placement_nodes(project_code, sensor_locations)
|
||||||
return sensor_placement_repository.create_sensor_placement(
|
return sensor_placement_repository.create_sensor_placement(
|
||||||
@@ -88,6 +90,8 @@ def _create_validated_placement(
|
|||||||
min_diameter=min_diameter,
|
min_diameter=min_diameter,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
sensor_locations=sensor_locations,
|
sensor_locations=sensor_locations,
|
||||||
|
sensor_type=sensor_type,
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +101,7 @@ def optimize_sensor_placement_by_sensitivity(
|
|||||||
sensor_count: int,
|
sensor_count: int,
|
||||||
min_diameter: int,
|
min_diameter: int,
|
||||||
created_by: str,
|
created_by: str,
|
||||||
|
sensor_type: str = "pressure",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run sensitivity placement and persist the validated result."""
|
"""Run sensitivity placement and persist the validated result."""
|
||||||
|
|
||||||
@@ -112,6 +117,8 @@ def optimize_sensor_placement_by_sensitivity(
|
|||||||
min_diameter=min_diameter,
|
min_diameter=min_diameter,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
sensor_locations=sensor_locations,
|
sensor_locations=sensor_locations,
|
||||||
|
sensor_type=sensor_type,
|
||||||
|
method="sensitivity",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,6 +128,7 @@ def optimize_sensor_placement_by_kmeans(
|
|||||||
sensor_count: int,
|
sensor_count: int,
|
||||||
min_diameter: int,
|
min_diameter: int,
|
||||||
created_by: str,
|
created_by: str,
|
||||||
|
sensor_type: str = "pressure",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Export the model, run K-means placement, and persist the result."""
|
"""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,
|
min_diameter=min_diameter,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
sensor_locations=sensor_locations,
|
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
|
reactivex==4.0.4
|
||||||
referencing==0.36.2
|
referencing==0.36.2
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
|
rfc8785==0.1.4
|
||||||
rich==14.2.0
|
rich==14.2.0
|
||||||
rich-rst==1.3.2
|
rich-rst==1.3.2
|
||||||
rpds-py==0.30.0
|
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 layers == geoserver_client.PROJECT_LAYER_NAMES
|
||||||
assert cache_updates == ["2592000"] * len(layers)
|
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]
|
query, params = conn.cursor_instance.calls[0]
|
||||||
assert "result_type = %s" in query
|
assert "result_type = %s" in query
|
||||||
assert "%s IS NULL" not 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():
|
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]
|
query, params = conn.cursor_instance.calls[0]
|
||||||
assert "result_type = %s" not in query
|
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,
|
min_diameter=300,
|
||||||
created_by="alice",
|
created_by="alice",
|
||||||
sensor_locations=["J1", "J2"],
|
sensor_locations=["J1", "J2"],
|
||||||
|
sensor_type="water_quality",
|
||||||
|
method="sensitivity",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert created["sensor_count"] == 2
|
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]
|
statements = [call.args[0] for call in cursor.execute.call_args_list]
|
||||||
assert "INSERT INTO analysis.runs" in statements[0]
|
assert "INSERT INTO analysis.runs" in statements[0]
|
||||||
assert "INSERT INTO analysis.results" in statements[1]
|
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