feat(workbench): add runtime GIS and sample integrations
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user