feat(workbench): add runtime GIS and sample integrations

This commit is contained in:
2026-09-22 10:44:31 +08:00
parent a3b4e75317
commit 7ab9b36a5e
62 changed files with 6685 additions and 51 deletions
+253
View File
@@ -0,0 +1,253 @@
"""Read-only readiness check for workbench query indexes and test resources."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import psycopg
from psycopg.rows import dict_row
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.core.config import settings
from samples.peripheral.config import SampleSettings
RESOURCES = {
"analysis_runtime_queries": (
"analysis.runs",
"analysis.runs_created_at_run_id_idx",
"analysis.results",
"analysis.results_run_created_at_result_id_idx",
),
"task_alerts": (
"workbench.alert_events",
"workbench.alert_events_alert_idx",
"workbench.alerts",
"workbench.alerts_active_recent_idx",
),
"peripheral_work_orders": (
"peripheral_sample.requests",
"peripheral_sample.scenarios",
"peripheral_sample.work_orders",
"peripheral_sample.work_order_revisions",
),
"peripheral_test_data": (
"peripheral_sample.test_data_records",
"peripheral_sample.test_data_record_revisions",
),
}
COLUMN_REQUIREMENTS = {
"analysis_runtime_queries": {
"analysis.runs": {"run_id", "created_at"},
"analysis.results": {"result_id", "run_id", "created_at"},
},
"task_alerts": {
"workbench.alert_events": {
"project_id", "producer_id", "event_id", "alert_id", "event_type",
"occurred_at", "ingested_at", "document",
},
"workbench.alerts": {
"project_id", "alert_id", "revision", "status", "occurred_at",
"updated_at", "document",
},
},
}
INDEX_REQUIREMENTS = {
"analysis.runs_created_at_run_id_idx": (
"on analysis.runs",
"(created_at desc, run_id)",
),
"analysis.results_run_created_at_result_id_idx": (
"on analysis.results",
"(run_id, created_at desc, result_id desc)",
),
"workbench.alert_events_alert_idx": (
"(project_id, alert_id, occurred_at, event_id)",
),
"workbench.alerts_active_recent_idx": (
"(project_id, occurred_at desc, alert_id)",
"status = 'active'",
),
}
RUNTIME_PRIVILEGES = {
"analysis.runs": ("SELECT",),
"analysis.results": ("SELECT",),
"workbench.alert_events": ("SELECT", "INSERT"),
"workbench.alerts": ("SELECT", "INSERT", "UPDATE"),
"peripheral_sample.requests": ("SELECT", "INSERT"),
"peripheral_sample.scenarios": ("SELECT", "INSERT", "UPDATE"),
"peripheral_sample.tasks": ("SELECT", "INSERT", "UPDATE"),
"peripheral_sample.revisions": ("SELECT", "INSERT"),
"peripheral_sample.feedback": ("SELECT", "INSERT"),
"peripheral_sample.demo_datasets": ("SELECT",),
"peripheral_sample.work_orders": ("SELECT", "INSERT", "UPDATE"),
"peripheral_sample.work_order_revisions": ("SELECT", "INSERT"),
"peripheral_sample.test_data_records": ("SELECT", "INSERT", "UPDATE"),
"peripheral_sample.test_data_record_revisions": ("SELECT", "INSERT"),
}
PROHIBITED_PRIVILEGES = {
relation: ("DELETE", "TRUNCATE") for relation in RUNTIME_PRIVILEGES
} | {
"workbench.alert_events": ("UPDATE", "DELETE", "TRUNCATE"),
}
def main() -> int:
sample_settings = SampleSettings()
with psycopg.connect(
host=settings.DB_HOST,
port=settings.DB_PORT,
user=settings.DB_USER,
password=settings.DB_PASSWORD,
dbname=sample_settings.db_name,
connect_timeout=5,
options="-c statement_timeout=5000",
row_factory=dict_row,
) as connection:
connection.execute("SET TRANSACTION READ ONLY")
database = connection.execute("SELECT current_database() AS name").fetchone()["name"]
availability: dict[str, dict[str, object]] = {}
for resource, relations in RESOURCES.items():
rows = connection.execute(
"SELECT value,to_regclass(value)::text AS relation FROM unnest(%s::text[]) value",
(list(relations),),
).fetchall()
missing = [row["value"] for row in rows if row["relation"] is None]
missing_columns: dict[str, list[str]] = {}
for relation, required in COLUMN_REQUIREMENTS.get(resource, {}).items():
schema_name, table_name = relation.split(".", 1)
columns = connection.execute(
"""SELECT column_name FROM information_schema.columns
WHERE table_schema=%s AND table_name=%s""",
(schema_name, table_name),
).fetchall()
present = {row["column_name"] for row in columns}
absent = sorted(required - present)
if absent:
missing_columns[relation] = absent
invalid_indexes: dict[str, list[str]] = {}
for relation in relations:
required_fragments = INDEX_REQUIREMENTS.get(relation)
if required_fragments is None or relation in missing:
continue
definition_row = connection.execute(
"SELECT pg_get_indexdef(to_regclass(%s)) AS definition",
(relation,),
).fetchone()
definition = " ".join(
str(definition_row["definition"] or "").lower().split()
)
absent_fragments = [
fragment
for fragment in required_fragments
if fragment not in definition
]
if absent_fragments:
invalid_indexes[relation] = absent_fragments
availability[resource] = {
"status": (
"available"
if not missing and not missing_columns and not invalid_indexes
else "not_configured"
),
"missing": missing,
"missing_columns": missing_columns,
"invalid_indexes": invalid_indexes,
}
role = connection.execute(
"""SELECT current_user AS name,rolsuper,rolcreatedb,rolcreaterole
FROM pg_roles WHERE rolname=current_user"""
).fetchone()
database_owner = connection.execute(
"""SELECT pg_get_userbyid(datdba) AS name
FROM pg_database WHERE datname=current_database()"""
).fetchone()["name"]
owned_relations = [
f"{row['schema_name']}.{row['relation_name']}"
for row in connection.execute(
"""SELECT namespace.nspname AS schema_name,
relation.relname AS relation_name
FROM pg_class relation
JOIN pg_namespace namespace ON namespace.oid=relation.relnamespace
WHERE pg_get_userbyid(relation.relowner)=current_user
AND namespace.nspname=ANY(%s::text[])
AND relation.relkind IN ('r','p')
ORDER BY namespace.nspname,relation.relname""",
(["analysis", "peripheral_sample", "workbench"],),
).fetchall()
]
missing_runtime_privileges: list[str] = []
prohibited_runtime_privileges: list[str] = []
for relation, privileges in RUNTIME_PRIVILEGES.items():
exists = connection.execute(
"SELECT to_regclass(%s) IS NOT NULL AS available", (relation,)
).fetchone()["available"]
if not exists:
missing_runtime_privileges.extend(
f"{relation}:{privilege}" for privilege in privileges
)
continue
for privilege in privileges:
allowed = connection.execute(
"SELECT has_table_privilege(current_user,%s,%s) AS allowed",
(relation, privilege),
).fetchone()["allowed"]
if not allowed:
missing_runtime_privileges.append(f"{relation}:{privilege}")
for relation, privileges in PROHIBITED_PRIVILEGES.items():
exists = connection.execute(
"SELECT to_regclass(%s) IS NOT NULL AS available", (relation,)
).fetchone()["available"]
if not exists:
continue
for privilege in privileges:
allowed = connection.execute(
"SELECT has_table_privilege(current_user,%s,%s) AS allowed",
(relation, privilege),
).fetchone()["allowed"]
if allowed:
prohibited_runtime_privileges.append(
f"{relation}:{privilege}"
)
production_safe_role = bool(
role
and not role["rolsuper"]
and not role["rolcreatedb"]
and not role["rolcreaterole"]
and role["name"] != database_owner
and not owned_relations
and not missing_runtime_privileges
and not prohibited_runtime_privileges
)
ready = all(item["status"] == "available" for item in availability.values())
print(json.dumps({
"database": database,
"ready": ready,
"production_ready": ready and production_safe_role,
"connection_role": {
"name": role["name"] if role else None,
"superuser": role["rolsuper"] if role else None,
"create_database": role["rolcreatedb"] if role else None,
"create_role": role["rolcreaterole"] if role else None,
"owns_database": role["name"] == database_owner if role else None,
"owned_relations": owned_relations,
"missing_runtime_privileges": missing_runtime_privileges,
"prohibited_runtime_privileges": prohibited_runtime_privileges,
"production_safe": production_safe_role,
},
"resources": availability,
}, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if ready else 2
if __name__ == "__main__":
raise SystemExit(main())
+15
View File
@@ -0,0 +1,15 @@
"""Generate the independent peripheral sample contract without opening a DB."""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from samples.peripheral.api import app
if __name__ == "__main__":
target = ROOT / "contracts/peripheral-sample.openapi.json"
target.write_text(json.dumps(app.openapi(), ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"Exported {len(app.openapi()['paths'])} sample paths")
@@ -0,0 +1,82 @@
"""Generate one reproducible Fengxian water-quality monitoring-point dataset."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.native.wndb.core.connection import project_connection
from app.services.sensor_placement import optimize_sensor_placement_by_sensitivity
def find_existing_run(
project: str,
*,
sensor_count: int,
min_diameter: int,
) -> dict[str, object] | None:
with project_connection(project) as conn:
return conn.execute(
"""
SELECT run.run_id::text AS run_id, run.name, run.created_at,
result.payload
FROM analysis.runs AS run
JOIN LATERAL (
SELECT stored.payload
FROM analysis.results AS stored
WHERE stored.run_id = run.run_id
AND stored.result_type = 'sensor_placement'
ORDER BY stored.created_at DESC, stored.result_id DESC
LIMIT 1
) AS result ON true
WHERE run.run_type = 'sensor_placement'
AND run.status = 'completed'
AND result.payload->>'sensor_type' = 'water_quality'
AND result.payload->>'method' = 'sensitivity'
AND (result.payload->>'sensor_number')::integer = %s
AND (result.payload->>'minimum_diameter')::integer = %s
ORDER BY run.created_at DESC, run.run_id DESC
LIMIT 1
""",
(sensor_count, min_diameter),
).fetchone()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", default="tjwater_v2")
parser.add_argument("--sensor-count", type=int, default=50)
parser.add_argument("--min-diameter", type=int, default=100)
parser.add_argument("--run-name", default="奉贤水质监测点灵敏度分析(50点)")
parser.add_argument("--created-by", default="tjwater")
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
existing = find_existing_run(
args.project,
sensor_count=args.sensor_count,
min_diameter=args.min_diameter,
)
if existing is not None and not args.force:
print(json.dumps(dict(existing), ensure_ascii=False, default=str, indent=2))
return
created = optimize_sensor_placement_by_sensitivity(
project_code=args.project,
run_name=args.run_name,
sensor_count=args.sensor_count,
min_diameter=args.min_diameter,
created_by=args.created_by,
sensor_type="water_quality",
)
print(json.dumps(created, ensure_ascii=False, default=str, indent=2))
if __name__ == "__main__":
main()
+475
View File
@@ -0,0 +1,475 @@
"""Import the two Fengxian GIS deliveries into one project BizDB.
The source PRJ files declare EPSG:21481, while their coordinate values match the
project's Shanghai local CRS (SRID 900914). The importer records both facts and
assigns the validated local CRS without changing coordinate values.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from psycopg import sql
from psycopg.conninfo import conninfo_to_dict
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from app.infra.db.project_routing import get_project_pgconn_string
from app.native.wndb.core.connection import project_connection, project_transaction
SCHEMA_SQL = PROJECT_ROOT / "resources/sql/010_source_gis_inventory.sql"
DEFAULT_APRIL_ROOT = Path(
"/mnt/c/Users/admin/Desktop/data/奉贤/20200423全网除头桥数据导出"
)
DEFAULT_AUGUST_ROOT = Path(
"/mnt/c/Users/admin/Desktop/data/奉贤/20200811奉贤100口径以上(含台账)(1)"
)
@dataclass(frozen=True)
class DatasetSpec:
key: str
title: str
source_date: date
@dataclass(frozen=True)
class LayerSpec:
key: str
dataset: DatasetSpec
display_name: str
source_file: Path
feature_kind: str
asset_id_candidates: tuple[str, ...] = ()
APRIL_DATASET = DatasetSpec(
key="fx_water_assets_20200423",
title="奉贤全网除头桥 GIS 数据导出",
source_date=date(2020, 4, 23),
)
AUGUST_DATASET = DatasetSpec(
key="fx_pipe_inventory_20200811",
title="奉贤 DN100 及以上管网台账",
source_date=date(2020, 8, 11),
)
def layer_specs(april_root: Path, august_root: Path) -> tuple[LayerSpec, ...]:
return (
LayerSpec("fx_20200423_drain_valves", APRIL_DATASET, "排水阀", april_root / "排水阀.shp", "drain_valve", ("排水阀ID编", "排水阀ID编码")),
LayerSpec("fx_20200423_service_connections", APRIL_DATASET, "接水点", april_root / "接水点.shp", "service_connection", ("接水点ID编", "接水点ID编码")),
LayerSpec("fx_20200423_flow_meters", APRIL_DATASET, "流量仪", april_root / "流量仪.shp", "flow_meter", ("流量仪ID编", "流量仪ID编码")),
LayerSpec("fx_20200423_hydrants", APRIL_DATASET, "消火栓", april_root / "消火栓.shp", "hydrant", ("消火栓ID编", "消火栓ID编码")),
LayerSpec("fx_20200423_customer_meters", APRIL_DATASET, "用户水表", april_root / "用户水表.shp", "customer_meter", ("水表ID编码", "用户水表ID")),
LayerSpec("fx_20200423_pipes", APRIL_DATASET, "管线", april_root / "管线.shp", "pipe", ("管线ID编码",)),
LayerSpec("fx_20200423_valves", APRIL_DATASET, "阀门", april_root / "阀门.shp", "valve", ("阀门ID编码",)),
LayerSpec("fx_20200423_non_residential_meters", APRIL_DATASET, "非居民用户水表", april_root / "非居民用户水表.shp", "non_residential_meter", ("水表ID编码", "用户水表ID")),
LayerSpec(
"fx_20200811_dn100_pipes",
AUGUST_DATASET,
"DN100 及以上管线台账",
august_root / "20200811奉贤管网100及以上(含台账).shp",
"pipe",
("PIPE_ID",),
),
)
def _postgres_environment(database_name: str) -> dict[str, str]:
parts = conninfo_to_dict(get_project_pgconn_string(db_name=database_name))
env = os.environ.copy()
for source, target in (
("host", "PGHOST"),
("port", "PGPORT"),
("user", "PGUSER"),
("password", "PGPASSWORD"),
("dbname", "PGDATABASE"),
):
value = parts.get(source)
if value:
env[target] = value
return env
def _load_staging_table(spec: LayerSpec, database_name: str) -> str:
stage_name = f"_stage_{spec.key}"
with project_connection(database_name) as conn:
conn.execute(
sql.SQL("DROP TABLE IF EXISTS {}.{} CASCADE").format(
sql.Identifier("source_gis"), sql.Identifier(stage_name)
)
)
command = [
"ogr2ogr",
"--config",
"PG_USE_COPY",
"YES",
"--config",
"OSR_USE_NON_DEPRECATED",
"NO",
"-f",
"PostgreSQL",
"PG:",
str(spec.source_file),
"-overwrite",
"-preserve_fid",
"-dim",
"XY",
"-mapFieldType",
"Date=String",
"-nln",
stage_name,
"-lco",
"SCHEMA=source_gis",
"-lco",
"FID=source_fid",
"-lco",
"GEOMETRY_NAME=geom",
"-lco",
"LAUNDER=NO",
]
subprocess.run(
command,
env=_postgres_environment(database_name),
check=True,
text=True,
)
return stage_name
def _first_column(columns: set[str], candidates: tuple[str, ...]) -> str | None:
return next((candidate for candidate in candidates if candidate in columns), None)
def _column_as_text(column: str | None) -> sql.Composable:
if column is None:
return sql.SQL("NULL::text")
return sql.SQL("NULLIF(BTRIM({}::text), '')").format(sql.Identifier(column))
def _column_as_float(column: str | None) -> sql.Composable:
if column is None:
return sql.SQL("NULL::double precision")
return sql.SQL("{}::double precision").format(sql.Identifier(column))
def _store_layer(database_name: str, spec: LayerSpec, stage_name: str) -> int:
with project_connection(database_name) as conn, conn.transaction(), conn.cursor() as cur:
cur.execute(
"""
SELECT a.attname
FROM pg_attribute a
WHERE a.attrelid = to_regclass(%s)
AND a.attnum > 0
AND NOT a.attisdropped
""",
(f"source_gis.{stage_name}",),
)
columns = {row["attname"] for row in cur.fetchall()}
if not {"source_fid", "geom"}.issubset(columns):
raise RuntimeError(f"Staging table for {spec.key} is incomplete")
cur.execute(
"""
INSERT INTO source_gis.datasets (
dataset_key, title, source_date, declared_srid, effective_srid,
coordinate_note, imported_at
) VALUES (%s, %s, %s, 21481, 900914, %s, NOW())
ON CONFLICT (dataset_key) DO UPDATE SET
title = EXCLUDED.title,
source_date = EXCLUDED.source_date,
declared_srid = EXCLUDED.declared_srid,
effective_srid = EXCLUDED.effective_srid,
coordinate_note = EXCLUDED.coordinate_note,
imported_at = NOW()
""",
(
spec.dataset.key,
spec.dataset.title,
spec.dataset.source_date,
"源 PRJ 声明 EPSG:21481,但坐标值与项目 SRID 900914 一致;原坐标未重投影。",
),
)
cur.execute(
sql.SQL("SELECT COUNT(*) AS count, GeometryType(geom) AS geometry_type FROM {}.{} GROUP BY GeometryType(geom)").format(
sql.Identifier("source_gis"), sql.Identifier(stage_name)
)
)
geometry_rows = cur.fetchall()
count = sum(int(row["count"]) for row in geometry_rows)
geometry_type = ",".join(sorted({row["geometry_type"] for row in geometry_rows}))
cur.execute(
"""
INSERT INTO source_gis.layers (
layer_key, dataset_key, display_name, source_file, feature_kind,
geometry_type, feature_count, imported_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (layer_key) DO UPDATE SET
dataset_key = EXCLUDED.dataset_key,
display_name = EXCLUDED.display_name,
source_file = EXCLUDED.source_file,
feature_kind = EXCLUDED.feature_kind,
geometry_type = EXCLUDED.geometry_type,
feature_count = EXCLUDED.feature_count,
imported_at = NOW()
""",
(
spec.key,
spec.dataset.key,
spec.display_name,
spec.source_file.name,
spec.feature_kind,
geometry_type,
count,
),
)
cur.execute("DELETE FROM source_gis.features WHERE layer_key = %s", (spec.key,))
object_id = _first_column(columns, ("OBJECTID", "ObjectID", "objectid"))
keyword = _first_column(columns, ("GJZ", "关键字"))
asset_id = _first_column(columns, spec.asset_id_candidates)
diameter = _first_column(columns, ("PIPE_D", "DIAMETER", "直径"))
from_node = _first_column(columns, ("FNODE", "起点编号", "起点ID"))
to_node = _first_column(columns, ("TNODE", "终点编号", "终点ID"))
insert_query = sql.SQL(
"""
INSERT INTO source_gis.features (
layer_key, source_fid, source_object_id, source_keyword,
source_asset_id, source_diameter, from_node_id, to_node_id,
attributes, geom
)
SELECT %s, source_fid, {}, {}, {}, {}, {}, {},
to_jsonb(stage) - 'source_fid' - 'geom',
ST_Force2D(ST_SetSRID(geom, 900914))
FROM {}.{} AS stage
"""
).format(
_column_as_text(object_id),
_column_as_text(keyword),
_column_as_text(asset_id),
_column_as_float(diameter),
_column_as_text(from_node),
_column_as_text(to_node),
sql.Identifier("source_gis"),
sql.Identifier(stage_name),
)
cur.execute(insert_query, (spec.key,))
cur.execute(
sql.SQL("DROP TABLE {}.{}").format(
sql.Identifier("source_gis"), sql.Identifier(stage_name)
)
)
return count
def _rebuild_element_links(database_name: str) -> dict[str, int]:
with project_connection(database_name) as conn, conn.transaction(), conn.cursor() as cur:
cur.execute(
"""
UPDATE source_gis.features AS april
SET source_diameter = august.source_diameter
FROM source_gis.features AS august
WHERE april.layer_key = 'fx_20200423_pipes'
AND august.layer_key = 'fx_20200811_dn100_pipes'
AND april.source_object_id = august.source_object_id
AND ST_Equals(april.geom, august.geom)
AND april.source_diameter IS NULL
"""
)
cur.execute("DELETE FROM source_gis.element_links")
cur.execute(
"""
INSERT INTO source_gis.element_links (
element_type, element_id, layer_key, source_fid,
match_method, distance_m
)
SELECT 'pipes', pipe.id, layer.layer_key, match.source_fid,
'node_pair', 0
FROM gis.pipes AS pipe
CROSS JOIN source_gis.layers AS layer
CROSS JOIN LATERAL (
SELECT feature.source_fid
FROM source_gis.features AS feature
WHERE feature.layer_key = layer.layer_key
AND (
(feature.from_node_id = pipe.start_node_id AND feature.to_node_id = pipe.end_node_id)
OR
(feature.from_node_id = pipe.end_node_id AND feature.to_node_id = pipe.start_node_id)
)
ORDER BY feature.source_fid
LIMIT 1
) AS match
WHERE layer.feature_kind = 'pipe'
"""
)
node_pair_count = cur.rowcount
cur.execute(
"""
INSERT INTO source_gis.element_links (
element_type, element_id, layer_key, source_fid,
match_method, distance_m
)
SELECT 'pipes', model.element_id, model.layer_key, match.source_fid,
'geometry', match.distance_m
FROM (
SELECT pipe.id AS element_id, layer.layer_key,
pipe.diameter,
ST_Transform(pipe.geom, 900914) AS geom
FROM gis.pipes AS pipe
CROSS JOIN source_gis.layers AS layer
WHERE layer.feature_kind = 'pipe'
AND NOT EXISTS (
SELECT 1 FROM source_gis.element_links AS existing
WHERE existing.element_type = 'pipes'
AND existing.element_id = pipe.id
AND existing.layer_key = layer.layer_key
)
AND ST_Length(ST_Transform(pipe.geom, 900914)) >= 0.1
) AS model
CROSS JOIN LATERAL (
SELECT candidate.source_fid, candidate.distance_m
FROM (
SELECT feature.source_fid,
ST_HausdorffDistance(feature.geom, model.geom) AS distance_m
FROM source_gis.features AS feature
WHERE feature.layer_key = model.layer_key
AND feature.geom && ST_Expand(model.geom, 1.0)
AND (
feature.source_diameter IS NULL
OR model.diameter IS NULL
OR ABS(feature.source_diameter - model.diameter)
<= GREATEST(1.0, model.diameter * 0.02)
)
ORDER BY feature.geom <-> model.geom
LIMIT 8
) AS candidate
ORDER BY candidate.distance_m, candidate.source_fid
LIMIT 1
) AS match
WHERE match.distance_m <= 1.0
ON CONFLICT (element_type, element_id, layer_key) DO NOTHING
"""
)
geometry_count = cur.rowcount
cur.execute(
"""
WITH point_matches AS (
SELECT feature.layer_key, feature.source_fid,
nearest.node_id AS element_id,
nearest.distance_m,
ROW_NUMBER() OVER (
PARTITION BY nearest.node_id, feature.layer_key
ORDER BY nearest.distance_m, feature.source_fid
) AS match_rank
FROM source_gis.features AS feature
JOIN source_gis.layers AS layer
ON layer.layer_key = feature.layer_key
CROSS JOIN LATERAL (
SELECT node.node_id,
ST_Distance(node.geom, feature.geom) AS distance_m
FROM gis.node_geometries AS node
ORDER BY node.geom <-> feature.geom
LIMIT 1
) AS nearest
JOIN gis.junctions AS junction
ON junction.id = nearest.node_id
WHERE layer.feature_kind <> 'pipe'
AND nearest.distance_m <= 0.1
)
INSERT INTO source_gis.element_links (
element_type, element_id, layer_key, source_fid,
match_method, distance_m
)
SELECT 'junctions', element_id, layer_key, source_fid,
'geometry', distance_m
FROM point_matches
WHERE match_rank = 1
"""
)
junction_count = cur.rowcount
cur.execute(
"""
INSERT INTO source_gis.element_links (
element_type, element_id, layer_key, source_fid,
match_method, distance_m
)
SELECT 'valves', valve.id, layer.layer_key, match.source_fid,
'geometry', match.distance_m
FROM gis.valves AS valve
CROSS JOIN source_gis.layers AS layer
CROSS JOIN LATERAL (
SELECT feature.source_fid,
ST_Distance(feature.geom, ST_Transform(valve.geom, 900914)) AS distance_m
FROM source_gis.features AS feature
WHERE feature.layer_key = layer.layer_key
ORDER BY feature.geom <-> ST_Transform(valve.geom, 900914)
LIMIT 1
) AS match
WHERE layer.feature_kind = 'valve'
AND match.distance_m <= 2.0
"""
)
valve_count = cur.rowcount
return {
"node_pair": node_pair_count,
"pipe_geometry": geometry_count,
"junction_geometry": junction_count,
"valve_geometry": valve_count,
}
def import_sources(database_name: str, specs: tuple[LayerSpec, ...]) -> None:
missing = [str(spec.source_file) for spec in specs if not spec.source_file.is_file()]
if missing:
raise FileNotFoundError("Missing GIS source files: " + ", ".join(missing))
with project_connection(database_name) as conn:
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
staged_layers: list[tuple[LayerSpec, str]] = []
for spec in specs:
print(f"Loading {spec.display_name} ({spec.key})")
staged_layers.append((spec, _load_staging_table(spec, database_name)))
total = 0
with project_transaction(database_name) as conn:
conn.execute(
"TRUNCATE TABLE source_gis.element_links, source_gis.features"
)
for spec, stage_name in staged_layers:
count = _store_layer(database_name, spec, stage_name)
total += count
print(f" stored {count:,} features")
links = _rebuild_element_links(database_name)
print(f"Imported {total:,} features into {database_name}")
print(
"Element links: "
f"{links['node_pair']:,} node-pair, "
f"{links['pipe_geometry']:,} pipe geometry, "
f"{links['junction_geometry']:,} junction geometry, "
f"{links['valve_geometry']:,} valve geometry"
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", default="tjwater_v2")
parser.add_argument("--april-root", type=Path, default=DEFAULT_APRIL_ROOT)
parser.add_argument("--august-root", type=Path, default=DEFAULT_AUGUST_ROOT)
args = parser.parse_args()
import_sources(args.database, layer_specs(args.april_root, args.august_root))
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
"""Create and publish Fengxian source GIS and water-quality monitoring layers."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from psycopg import sql
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.infra.db.postgresql.project_spatial import get_project_map_bbox
from app.infra.geoserver.client import GeoServerAdminClient
from app.native.wndb.core.connection import project_connection
SCHEMA_SQL = ROOT / "resources/sql/013_fengxian_map_layers.sql"
SOURCE_MATERIALIZED_LAYERS = (
("fx_20200423_drain_valves", "奉贤排水阀"),
("fx_20200423_service_connections", "奉贤接水点"),
("fx_20200423_flow_meters", "奉贤流量仪"),
("fx_20200423_hydrants", "奉贤消火栓"),
("fx_20200423_customer_meters", "奉贤用户水表"),
("fx_20200423_pipes", "奉贤全网管线"),
("fx_20200423_valves", "奉贤阀门"),
("fx_20200423_non_residential_meters", "奉贤非居民用户水表"),
("fx_20200811_dn100_pipes", "奉贤 DN100 及以上管线"),
)
PUBLISHED_LAYERS = SOURCE_MATERIALIZED_LAYERS + (
("water_quality_monitoring_points", "奉贤水质监测点"),
)
def prepare_layers(database: str) -> dict[str, int]:
with project_connection(database) as conn, conn.transaction():
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
for layer, _title in SOURCE_MATERIALIZED_LAYERS:
conn.execute(
sql.SQL("REFRESH MATERIALIZED VIEW gis.{}").format(
sql.Identifier(layer)
)
)
counts: dict[str, int] = {}
for layer, _title in PUBLISHED_LAYERS:
row = conn.execute(
sql.SQL("SELECT count(*) AS count FROM gis.{}").format(
sql.Identifier(layer)
)
).fetchone()
counts[layer] = int(row["count"])
return counts
def publish_layers(database: str, workspace: str) -> dict[str, int]:
counts = prepare_layers(database)
map_bbox = get_project_map_bbox(database)
with GeoServerAdminClient() as geoserver:
geoserver.check_ready()
for layer, title in PUBLISHED_LAYERS:
geoserver.publish_feature_type(
workspace=workspace,
datastore=workspace,
layer=layer,
title=title,
map_bbox=map_bbox,
)
geoserver.truncate_layer_cache(workspace, layer)
return counts
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", default="tjwater_v2")
parser.add_argument("--workspace", default="tjwater_next")
args = parser.parse_args()
counts = publish_layers(args.database, args.workspace)
for layer, count in counts.items():
print(f"{layer}: {count:,}")
if __name__ == "__main__":
main()
+153
View File
@@ -0,0 +1,153 @@
"""Idempotently seed synthetic peripheral demo resources for one explicit project."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from uuid import NAMESPACE_URL, UUID, uuid5
from psycopg.types.json import Jsonb
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.services.content_digest import digest
from samples.peripheral.auth import Identity
from samples.peripheral.config import SampleSettings
from samples.peripheral.demo_data import build_demo_content
from samples.peripheral.schemas import CreateTestDataRecord, CreateWorkOrder
from samples.peripheral.store import PostgresStore
SEED_NAMESPACE = uuid5(NAMESPACE_URL, "tjwater/peripheral-sample/seed/v1")
ACTOR_ID = uuid5(SEED_NAMESPACE, "actor")
def request_id(kind: str) -> UUID:
return uuid5(SEED_NAMESPACE, kind)
WORK_ORDERS = (
{
"key": "work-order/demo-communication",
"title": "合成通信核查",
"summary": "检查 DEMO-P001 的预置通信中断情节,仅用于外围样板联调。",
"status": "open",
"priority": "high",
"assignee": "外围样板班组",
"scheduled_at": "2026-09-17T03:00:00Z",
},
{
"key": "work-order/demo-pressure",
"title": "合成压力偏差复核",
"summary": "复核 DEMO-P001、DEMO-P002 相邻测试点的固定合成读数,不代表现场异常。",
"status": "in_progress",
"priority": "urgent",
"assignee": "演示核查班组",
"scheduled_at": "2026-09-17T04:00:00Z",
},
{
"key": "work-order/flow-zero",
"title": "合成零流量样本确认",
"summary": "确认 DEMO-F001 中零值与缺测值可被界面明确区分。",
"status": "completed",
"priority": "normal",
"assignee": "外围样板班组",
"scheduled_at": "2026-09-17T06:00:00Z",
},
)
TEST_DATA = (
("DEMO-P001", "合成压力 1 · 00:00", "2026-09-17T00:00:00Z", 24.37, "m", "valid"),
("DEMO-P001", "合成压力 1 · 00:15", "2026-09-17T00:15:00Z", 24.18, "m", "valid"),
("DEMO-P001", "合成压力 1 · 00:30 缺测", "2026-09-17T00:30:00Z", None, "m", "missing"),
("DEMO-P001", "合成压力 1 · 00:45", "2026-09-17T00:45:00Z", 24.42, "m", "valid"),
("DEMO-P002", "合成压力 2 · 00:00", "2026-09-17T00:00:00Z", 31.26, "m", "valid"),
("DEMO-P002", "合成压力 2 · 00:15", "2026-09-17T00:15:00Z", 31.31, "m", "valid"),
("DEMO-P002", "合成压力 2 · 00:30", "2026-09-17T00:30:00Z", 31.18, "m", "valid"),
("DEMO-P002", "合成压力 2 · 00:45", "2026-09-17T00:45:00Z", 31.36, "m", "valid"),
("DEMO-F001", "合成流量 00:00", "2026-09-17T00:00:00Z", 186.43, "m3/h", "valid"),
("DEMO-F001", "合成流量 00:15", "2026-09-17T00:15:00Z", 185.90, "m3/h", "valid"),
("DEMO-F001", "合成零流量 00:30", "2026-09-17T00:30:00Z", 0.0, "m3/h", "valid"),
("DEMO-F001", "合成流量 00:45", "2026-09-17T00:45:00Z", 187.12, "m3/h", "valid"),
)
def ensure_demo_dataset(store: PostgresStore, project_id: str) -> str:
content = build_demo_content()
content_digest = digest(content)
with store.connection() as connection:
row = connection.execute(
"""SELECT digest FROM peripheral_sample.demo_datasets
WHERE project_id=%s AND dataset_id='water-demo-v1'""",
(project_id,),
).fetchone()
if row is None:
connection.execute(
"""INSERT INTO peripheral_sample.demo_datasets
(project_id,dataset_id,digest,document)
VALUES (%s,'water-demo-v1',%s,%s)""",
(project_id, content_digest, Jsonb(content)),
)
elif row["digest"] != content_digest:
connection.execute(
"""UPDATE peripheral_sample.demo_datasets
SET digest=%s, document=%s
WHERE project_id=%s AND dataset_id='water-demo-v1'""",
(content_digest, Jsonb(content), project_id),
)
return content_digest
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--project-id", required=True, type=UUID)
args = parser.parse_args()
project_id = str(args.project_id)
store = PostgresStore(SampleSettings(db_name="tjwater_v2", enable_faults=False))
who = Identity(project_id, str(ACTOR_ID), True)
dataset_digest = ensure_demo_dataset(store, project_id)
order_ids = []
for item in WORK_ORDERS:
command = CreateWorkOrder(
request_id=request_id(item["key"]),
title=item["title"],
summary=item["summary"],
status=item["status"],
priority=item["priority"],
assignee=item["assignee"],
scheduled_at=item["scheduled_at"],
)
order_ids.append(store.create_work_order(who, command)["work_order_id"])
record_ids = []
for point_id, label, observed_at, value, unit, quality in TEST_DATA:
command = CreateTestDataRecord(
request_id=request_id(f"test-data/{point_id}/{observed_at}"),
label=label,
point_id=point_id,
observed_at=observed_at,
value=value,
unit=unit,
quality=quality,
notes="外围样板固定合成数据,不是现场监测记录。",
)
record_ids.append(store.create_test_data(who, command)["record_id"])
result = {
"project_id": project_id,
"actor_id": str(ACTOR_ID),
"origin": "synthetic",
"demo_dataset_digest": dataset_digest,
"work_orders": {"count": len(order_ids), "ids": order_ids},
"test_data_records": {"count": len(record_ids), "ids": record_ids},
}
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+254
View File
@@ -0,0 +1,254 @@
"""Project peripheral demo orders into the backend workbench order store."""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
from uuid import UUID
from zoneinfo import ZoneInfo
from psycopg.types.json import Jsonb
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.domain.schemas.workbench_runtime import WorkbenchRuntimeWorkOrder
from app.native.wndb.core.connection import project_connection
SCHEMA_SQL = ROOT / "resources/sql/011_workbench_work_orders.sql"
SHANGHAI = ZoneInfo("Asia/Shanghai")
PROJECTION_SOURCE = "合成数据预约工单"
DEMO_WORK_ORDER_ID = "demo-work-order-peripheral-inspection"
ORDER_DETAILS = {
"合成通信核查": {
"location": "DEMO-P001",
"duration_minutes": 30,
"reply_requirements": ["记录通信恢复时间", "确认缺口数据是否补传"],
},
"合成压力偏差复核": {
"location": "DEMO-P001 / DEMO-P002",
"duration_minutes": 45,
"reply_requirements": ["回填相邻测点对照结果", "确认合成样本复核结论"],
},
"合成零流量样本确认": {
"location": "DEMO-F001",
"duration_minutes": 20,
"reply_requirements": ["确认零值显示", "确认缺测值显示"],
},
}
def _parse_timestamp(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _time_label(value: datetime) -> str:
return value.astimezone(SHANGHAI).strftime("%H:%M")
def _time_range(start: datetime, end: datetime) -> str:
return f"{_time_label(start)}-{_time_label(end)}"
def _subtask_status(status: str) -> str:
mapped = {
"open": "pending",
"in_progress": "running",
"completed": "completed",
}.get(status)
if mapped is None:
raise ValueError(f"unsupported peripheral work-order status: {status}")
return mapped
def _stage_statuses(status: str) -> tuple[str, str, str]:
if status == "completed":
return "done", "done", "done"
if status == "running":
return "done", "current", "pending"
return "current", "pending", "pending"
def project_peripheral_subtask(document: dict) -> dict:
status = _subtask_status(document.get("status"))
scheduled_at = _parse_timestamp(document["scheduled_at"])
details = ORDER_DETAILS.get(document["title"], {})
duration_minutes = int(details.get("duration_minutes", 30))
execution_end = scheduled_at + timedelta(minutes=duration_minutes)
reply_end = execution_end + timedelta(minutes=15)
dispatch_status, execution_status, reply_status = _stage_statuses(status)
assignee = document.get("assignee") or "待分派班组"
return {
"id": document["work_order_id"],
"title": document["title"],
"summary": document["summary"],
"status": status,
"scheduled_at": document["scheduled_at"],
"location": details.get("location", "演示点位"),
"assignee": assignee,
"duration_minutes": duration_minutes,
"reply_window_minutes": 15,
"stages": [
{
"id": "dispatch",
"sequence": 1,
"title": "派发",
"status": dispatch_status,
"owner": "演示调度",
"time_label": _time_label(scheduled_at - timedelta(minutes=15)),
"description": "预约工单子任务已进入演示调度流程。",
},
{
"id": "execution",
"sequence": 2,
"title": "执行",
"status": execution_status,
"owner": assignee,
"time_label": _time_range(scheduled_at, execution_end),
"description": "按外围合成场景执行本项核查。",
},
{
"id": "reply",
"sequence": 3,
"title": "复令",
"status": reply_status,
"owner": assignee,
"time_label": _time_range(execution_end, reply_end),
"description": "回传本项核查结果并完成子任务闭环。",
},
],
"reply_requirements": details.get(
"reply_requirements", ["回传演示核查结果"]
),
}
def project_peripheral_work_order(source_documents: list[dict]) -> dict:
if not source_documents:
raise ValueError("source documents must not be empty")
subtasks = [project_peripheral_subtask(document) for document in source_documents]
scheduled_values = [_parse_timestamp(item["scheduled_at"]) for item in source_documents]
updated_values = [_parse_timestamp(item["updated_at"]) for item in source_documents]
end_values = [
scheduled + timedelta(minutes=subtask["duration_minutes"])
for scheduled, subtask in zip(scheduled_values, subtasks, strict=True)
]
statuses = {subtask["status"] for subtask in subtasks}
if statuses == {"completed"}:
status = "completed"
elif statuses == {"pending"}:
status = "pending"
else:
status = "running"
priority = "urgent" if any(
document.get("priority") in {"high", "urgent"}
for document in source_documents
) else "normal"
projected = {
"id": DEMO_WORK_ORDER_ID,
"code": "DEMO-WO-001",
"scheduled_at": min(scheduled_values),
"title": "外围合成数据核查",
"summary": "依次核查合成通信、压力偏差和零流量样本。",
"status": status,
"risk_level": "attention" if priority == "urgent" else "normal",
"updated_at": max(updated_values),
"detail": "外围样板中的三项 synthetic 核查已组合为一张预约工单,仅用于演示。",
"recommendation": "按子任务逐项核查并分别完成阶段回传。",
"duration_minutes": max(
1,
int((max(end_values) - min(scheduled_values)).total_seconds() // 60),
),
"source": PROJECTION_SOURCE,
"dispatcher": "演示调度",
"priority": priority,
"subtasks": subtasks,
}
return WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--database-name", default="tjwater_v2")
parser.add_argument("--project-id", required=True, type=UUID)
args = parser.parse_args()
with project_connection(args.database_name) as conn, conn.transaction():
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
source_rows = conn.execute(
"""
SELECT document
FROM peripheral_sample.work_orders
WHERE project_id = %s
AND deleted_at IS NULL
AND document->>'origin' = 'synthetic'
AND document->>'scheduled_at' IS NOT NULL
AND document->>'status' IN ('open', 'in_progress', 'completed')
ORDER BY (document->>'scheduled_at')::timestamptz, work_order_id
""",
(args.project_id,),
).fetchall()
source_documents = [row["document"] for row in source_rows]
if not source_documents:
raise RuntimeError("no eligible peripheral demo work orders found")
documents = [project_peripheral_work_order(source_documents)]
conn.execute(
"""
DELETE FROM workbench.work_orders
WHERE project_id = %s
AND (
work_order_id LIKE 'demo-work-order-%%'
OR document->>'source' = %s
OR document->>'source' = '外围样板预约工单'
)
""",
(args.project_id, PROJECTION_SOURCE),
)
for document in documents:
conn.execute(
"""
INSERT INTO workbench.work_orders
(project_id, work_order_id, scheduled_at, updated_at, document)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (project_id, work_order_id) DO UPDATE
SET scheduled_at = EXCLUDED.scheduled_at,
updated_at = EXCLUDED.updated_at,
document = EXCLUDED.document
""",
(
args.project_id,
document["id"],
document["scheduled_at"],
document["updated_at"],
Jsonb(document),
),
)
print(json.dumps({
"database": args.database_name,
"project_id": str(args.project_id),
"source": PROJECTION_SOURCE,
"work_orders": [
{
"id": document["id"],
"code": document["code"],
"title": document["title"],
"subtask_count": len(document["subtasks"]),
}
for document in documents
],
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())