970 lines
31 KiB
Go
970 lines
31 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"fire-safety-ymd/internal/domain"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const mcpSpatialSRID = 4326
|
|
|
|
// PostGISOptions configures the read-only PostgreSQL/PostGIS connection pool.
|
|
type PostGISOptions struct {
|
|
DSN string
|
|
MaxConns int32
|
|
ConnectTimeout time.Duration
|
|
QueryTimeout time.Duration
|
|
}
|
|
|
|
// TableAudit is a safe schema and geometry summary that contains no business rows.
|
|
type TableAudit struct {
|
|
Table string `json:"table"`
|
|
RowCount int64 `json:"row_count"`
|
|
NullGeometryCount int64 `json:"null_geometry_count"`
|
|
EmptyGeometryCount int64 `json:"empty_geometry_count"`
|
|
InvalidCount int64 `json:"invalid_geometry_count"`
|
|
OutOfBoundsCount int64 `json:"wgs84_out_of_bounds_count"`
|
|
GeometryTypes []string `json:"geometry_types"`
|
|
SRIDs []int `json:"srids"`
|
|
HasGISTIndex bool `json:"has_gist_geometry_index"`
|
|
}
|
|
|
|
// ReadinessReport contains non-sensitive PostGIS readiness information.
|
|
type ReadinessReport struct {
|
|
PostGISVersion string `json:"postgis_version"`
|
|
Tables []TableAudit `json:"tables"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
// PostGIS implements the fixed read-only spatial repository over pgxpool.
|
|
type PostGIS struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
type safeConnectionError struct {
|
|
message string
|
|
cause error
|
|
}
|
|
|
|
func (e *safeConnectionError) Error() string { return e.message }
|
|
func (e *safeConnectionError) Unwrap() error { return e.cause }
|
|
|
|
// OpenPostGIS opens and verifies a read-only PostgreSQL connection pool.
|
|
func OpenPostGIS(ctx context.Context, options PostGISOptions) (*PostGIS, error) {
|
|
if strings.TrimSpace(options.DSN) == "" || options.MaxConns <= 0 || options.ConnectTimeout <= 0 || options.QueryTimeout <= 0 {
|
|
return nil, errors.New("invalid PostGIS options")
|
|
}
|
|
poolConfig, err := pgxpool.ParseConfig(options.DSN)
|
|
if err != nil {
|
|
return nil, &safeConnectionError{message: "parse PostGIS configuration", cause: err}
|
|
}
|
|
poolConfig.MaxConns = options.MaxConns
|
|
poolConfig.ConnConfig.ConnectTimeout = options.ConnectTimeout
|
|
poolConfig.ConnConfig.RuntimeParams["application_name"] = "fire-safety-ymd-mcp"
|
|
poolConfig.ConnConfig.RuntimeParams["default_transaction_read_only"] = "on"
|
|
queryTimeoutMilliseconds := options.QueryTimeout.Milliseconds()
|
|
if queryTimeoutMilliseconds < 1 {
|
|
queryTimeoutMilliseconds = 1
|
|
}
|
|
poolConfig.ConnConfig.RuntimeParams["statement_timeout"] = strconv.FormatInt(queryTimeoutMilliseconds, 10)
|
|
poolConfig.ConnConfig.RuntimeParams["lock_timeout"] = strconv.FormatInt(queryTimeoutMilliseconds, 10)
|
|
|
|
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
|
if err != nil {
|
|
return nil, &safeConnectionError{message: "create PostGIS connection pool", cause: err}
|
|
}
|
|
connectCtx, cancel := context.WithTimeout(ctx, options.ConnectTimeout)
|
|
defer cancel()
|
|
if err := pool.Ping(connectCtx); err != nil {
|
|
pool.Close()
|
|
return nil, &safeConnectionError{message: "connect to PostGIS", cause: err}
|
|
}
|
|
return &PostGIS{pool: pool}, nil
|
|
}
|
|
|
|
// Close releases all PostgreSQL connections.
|
|
func (p *PostGIS) Close() {
|
|
if p != nil && p.pool != nil {
|
|
p.pool.Close()
|
|
}
|
|
}
|
|
|
|
// Audit inspects PostGIS and the allowlisted source tables without returning record values.
|
|
func (p *PostGIS) Audit(ctx context.Context) (ReadinessReport, error) {
|
|
var report ReadinessReport
|
|
if err := p.pool.QueryRow(ctx, `SELECT postgis_version()`).Scan(&report.PostGISVersion); err != nil {
|
|
return ReadinessReport{}, fmt.Errorf("query PostGIS version: %w", err)
|
|
}
|
|
|
|
for _, spec := range spatialTableSpecs {
|
|
audit, err := p.auditTable(ctx, spec.name)
|
|
if err != nil {
|
|
return ReadinessReport{}, fmt.Errorf("audit allowlisted table %s: %w", spec.name, err)
|
|
}
|
|
report.Tables = append(report.Tables, audit)
|
|
report.Warnings = append(report.Warnings, tableAuditWarnings(audit)...)
|
|
}
|
|
if report.Warnings == nil {
|
|
report.Warnings = []string{}
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
// ValidateForMCP rejects unsafe spatial metadata while allowing queries to exclude invalid records.
|
|
func (p *PostGIS) ValidateForMCP(ctx context.Context, expectedSRID int) (ReadinessReport, error) {
|
|
report, err := p.Audit(ctx)
|
|
if err != nil {
|
|
return ReadinessReport{}, err
|
|
}
|
|
if err := validateReadiness(report, expectedSRID); err != nil {
|
|
return report, err
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
func validateReadiness(report ReadinessReport, expectedSRID int) error {
|
|
if expectedSRID != mcpSpatialSRID {
|
|
return fmt.Errorf("unsupported MCP spatial SRID: %d", expectedSRID)
|
|
}
|
|
if len(report.Tables) != len(spatialTableSpecs) {
|
|
return fmt.Errorf("readiness report contains %d tables, want %d", len(report.Tables), len(spatialTableSpecs))
|
|
}
|
|
for index, audit := range report.Tables {
|
|
spec := spatialTableSpecs[index]
|
|
if audit.Table != spec.name {
|
|
return fmt.Errorf("readiness report table %d is %s, want %s", index, audit.Table, spec.name)
|
|
}
|
|
if audit.OutOfBoundsCount > 0 {
|
|
return fmt.Errorf("table %s contains coordinates outside WGS84 bounds", audit.Table)
|
|
}
|
|
for _, srid := range audit.SRIDs {
|
|
if srid != expectedSRID {
|
|
return fmt.Errorf("table %s contains unexpected SRID %d", audit.Table, srid)
|
|
}
|
|
}
|
|
for _, geometryType := range audit.GeometryTypes {
|
|
if !slices.Contains(spec.geometryTypes, geometryType) {
|
|
return fmt.Errorf("table %s contains unexpected geometry type %s", audit.Table, geometryType)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func tableAuditWarnings(audit TableAudit) []string {
|
|
warnings := make([]string, 0, 5)
|
|
if audit.RowCount == 0 {
|
|
warnings = append(warnings, audit.Table+":empty_table")
|
|
}
|
|
if audit.NullGeometryCount > 0 {
|
|
warnings = append(warnings, audit.Table+":null_geometries_excluded")
|
|
}
|
|
if audit.EmptyGeometryCount > 0 {
|
|
warnings = append(warnings, audit.Table+":empty_geometries_excluded")
|
|
}
|
|
if audit.InvalidCount > 0 {
|
|
warnings = append(warnings, audit.Table+":invalid_geometries_excluded")
|
|
}
|
|
if !audit.HasGISTIndex {
|
|
warnings = append(warnings, audit.Table+":missing_gist_geometry_index")
|
|
}
|
|
return warnings
|
|
}
|
|
|
|
func (p *PostGIS) auditTable(ctx context.Context, table string) (TableAudit, error) {
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
count(*)::bigint,
|
|
count(*) FILTER (WHERE geom IS NULL)::bigint,
|
|
count(*) FILTER (WHERE geom IS NOT NULL AND ST_IsEmpty(geom))::bigint,
|
|
count(*) FILTER (WHERE geom IS NOT NULL AND NOT ST_IsValid(geom))::bigint,
|
|
count(*) FILTER (
|
|
WHERE geom IS NOT NULL
|
|
AND NOT ST_IsEmpty(geom)
|
|
AND (
|
|
ST_XMin(Box3D(geom)) < -180 OR ST_XMax(Box3D(geom)) > 180
|
|
OR ST_YMin(Box3D(geom)) < -90 OR ST_YMax(Box3D(geom)) > 90
|
|
)
|
|
)::bigint,
|
|
COALESCE(string_agg(DISTINCT GeometryType(geom), ',' ORDER BY GeometryType(geom)) FILTER (WHERE geom IS NOT NULL), ''),
|
|
COALESCE(string_agg(DISTINCT ST_SRID(geom)::text, ',' ORDER BY ST_SRID(geom)::text) FILTER (WHERE geom IS NOT NULL), ''),
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM pg_indexes
|
|
WHERE schemaname = 'public'
|
|
AND tablename = $1
|
|
AND indexdef ILIKE '%%USING gist%%'
|
|
AND indexdef ILIKE '%%geom%%'
|
|
)
|
|
FROM public.%s`, quoteIdentifier(table))
|
|
|
|
var audit TableAudit
|
|
var geometryTypes string
|
|
var srids string
|
|
audit.Table = table
|
|
if err := p.pool.QueryRow(ctx, query, table).Scan(
|
|
&audit.RowCount,
|
|
&audit.NullGeometryCount,
|
|
&audit.EmptyGeometryCount,
|
|
&audit.InvalidCount,
|
|
&audit.OutOfBoundsCount,
|
|
&geometryTypes,
|
|
&srids,
|
|
&audit.HasGISTIndex,
|
|
); err != nil {
|
|
return TableAudit{}, err
|
|
}
|
|
audit.GeometryTypes = splitNonEmpty(geometryTypes)
|
|
parsedSRIDs, err := parseSRIDs(srids)
|
|
if err != nil {
|
|
return TableAudit{}, err
|
|
}
|
|
audit.SRIDs = parsedSRIDs
|
|
return audit, nil
|
|
}
|
|
|
|
// SearchPlaceCandidates returns bounded name matches from the fixed fire-safety source tables.
|
|
func (p *PostGIS) SearchPlaceCandidates(ctx context.Context, query domain.PlaceSearchQuery) ([]domain.PlaceCandidate, error) {
|
|
rows, err := p.pool.Query(ctx, placeCandidateSearchSQL,
|
|
query.PlaceName,
|
|
query.Scope.AllTowns,
|
|
query.Scope.AllowedTowns,
|
|
query.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]domain.PlaceCandidate, 0, query.Limit)
|
|
for rows.Next() {
|
|
var item domain.PlaceCandidate
|
|
if err := rows.Scan(
|
|
&item.PlaceType,
|
|
&item.SourceRecordID,
|
|
&item.Name,
|
|
&item.Town,
|
|
&item.Village,
|
|
&item.MatchedField,
|
|
&item.MatchedText,
|
|
&item.MatchKind,
|
|
&item.Location.Longitude,
|
|
&item.Location.Latitude,
|
|
&item.LocationKind,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
// ResolveIncidentContext returns fire grids that cover the supplied point.
|
|
func (p *PostGIS) ResolveIncidentContext(ctx context.Context, point domain.Coordinate, scope domain.SpatialScope) ([]domain.IncidentContext, error) {
|
|
rows, err := p.pool.Query(ctx, resolveIncidentContextSQL, point.Longitude, point.Latitude, scope.AllTowns, scope.AllowedTowns)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]domain.IncidentContext, 0)
|
|
for rows.Next() {
|
|
var item domain.IncidentContext
|
|
if err := rows.Scan(&item.GridID, &item.Town, &item.AreaLabel); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
// FindNearbyWaterSources returns water-source and storage-pool candidates.
|
|
func (p *PostGIS) FindNearbyWaterSources(ctx context.Context, query domain.NearbyQuery) ([]domain.WaterSource, error) {
|
|
rows, err := p.pool.Query(ctx, nearbyWaterSourcesSQL,
|
|
query.Point.Longitude,
|
|
query.Point.Latitude,
|
|
query.Scope.AllTowns,
|
|
query.Scope.AllowedTowns,
|
|
query.RadiusMeters,
|
|
query.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]domain.WaterSource, 0, query.Limit)
|
|
for rows.Next() {
|
|
var item domain.WaterSource
|
|
var name, village, resourceType, reportedStatus pgtype.Text
|
|
var capacity pgtype.Float8
|
|
var sourceTimestamp pgtype.Int8
|
|
if err := rows.Scan(
|
|
&item.Category,
|
|
&item.SourceRecordID,
|
|
&name,
|
|
&item.Town,
|
|
&village,
|
|
&item.Location.Longitude,
|
|
&item.Location.Latitude,
|
|
&item.DistanceMeters,
|
|
&capacity,
|
|
&resourceType,
|
|
&reportedStatus,
|
|
&sourceTimestamp,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
item.Name = optionalText(name)
|
|
item.Village = optionalText(village)
|
|
item.CapacityCubicMeters = optionalFloat64(capacity)
|
|
item.ResourceType = optionalText(resourceType)
|
|
item.ReportedStatus = optionalText(reportedStatus)
|
|
item.SourceTimestampRaw = optionalInt64(sourceTimestamp)
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
// FindCommandPostCandidates returns nearby check stations and lookout posts.
|
|
func (p *PostGIS) FindCommandPostCandidates(ctx context.Context, query domain.NearbyQuery) ([]domain.CommandPostCandidate, error) {
|
|
rows, err := p.pool.Query(ctx, commandPostCandidatesSQL,
|
|
query.Point.Longitude,
|
|
query.Point.Latitude,
|
|
query.Scope.AllTowns,
|
|
query.Scope.AllowedTowns,
|
|
query.RadiusMeters,
|
|
query.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]domain.CommandPostCandidate, 0, query.Limit)
|
|
for rows.Next() {
|
|
var item domain.CommandPostCandidate
|
|
var name, village, reportedStatus, managementUnit pgtype.Text
|
|
if err := rows.Scan(
|
|
&item.FacilityType,
|
|
&item.SourceRecordID,
|
|
&name,
|
|
&item.Town,
|
|
&village,
|
|
&item.Location.Longitude,
|
|
&item.Location.Latitude,
|
|
&item.DistanceMeters,
|
|
&reportedStatus,
|
|
&managementUnit,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
item.Name = optionalText(name)
|
|
item.Village = optionalText(village)
|
|
item.ReportedStatus = optionalText(reportedStatus)
|
|
item.ManagementUnit = optionalText(managementUnit)
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
// ListNearbyAccessLines returns nearby fire access lines and closest access points.
|
|
func (p *PostGIS) ListNearbyAccessLines(ctx context.Context, query domain.NearbyQuery) ([]domain.AccessLine, error) {
|
|
rows, err := p.pool.Query(ctx, nearbyAccessLinesSQL,
|
|
query.Point.Longitude,
|
|
query.Point.Latitude,
|
|
query.Scope.AllTowns,
|
|
query.Scope.AllowedTowns,
|
|
query.RadiusMeters,
|
|
query.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]domain.AccessLine, 0, query.Limit)
|
|
for rows.Next() {
|
|
var item domain.AccessLine
|
|
var name, sourceUpdated pgtype.Text
|
|
var length pgtype.Float8
|
|
if err := rows.Scan(
|
|
&item.SourceRecordID,
|
|
&item.Town,
|
|
&name,
|
|
&item.DistanceMeters,
|
|
&item.NearestPoint.Longitude,
|
|
&item.NearestPoint.Latitude,
|
|
&length,
|
|
&sourceUpdated,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
item.Name = optionalText(name)
|
|
item.LengthMeters = optionalFloat64(length)
|
|
item.SourceUpdatedRaw = optionalText(sourceUpdated)
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
// GetResponsibleUnits returns non-personal responsibility details from covering grids.
|
|
func (p *PostGIS) GetResponsibleUnits(ctx context.Context, point domain.Coordinate, scope domain.SpatialScope) ([]domain.ResponsibleUnit, error) {
|
|
rows, err := p.pool.Query(ctx, responsibleUnitsSQL, point.Longitude, point.Latitude, scope.AllTowns, scope.AllowedTowns)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]domain.ResponsibleUnit, 0)
|
|
for rows.Next() {
|
|
var item domain.ResponsibleUnit
|
|
if err := rows.Scan(&item.GridID, &item.Town, &item.AreaLabel, &item.FireTeam); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
// FindNearbyRiskAreas returns nearby cemetery and forest-enterprise polygons.
|
|
func (p *PostGIS) FindNearbyRiskAreas(ctx context.Context, query domain.NearbyQuery) ([]domain.RiskArea, error) {
|
|
rows, err := p.pool.Query(ctx, nearbyRiskAreasSQL,
|
|
query.Point.Longitude,
|
|
query.Point.Latitude,
|
|
query.Scope.AllTowns,
|
|
query.Scope.AllowedTowns,
|
|
query.RadiusMeters,
|
|
query.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]domain.RiskArea, 0, query.Limit)
|
|
for rows.Next() {
|
|
var item domain.RiskArea
|
|
var name, village, direction pgtype.Text
|
|
if err := rows.Scan(
|
|
&item.RiskType,
|
|
&item.SourceRecordID,
|
|
&name,
|
|
&item.Town,
|
|
&village,
|
|
&direction,
|
|
&item.CoversPoint,
|
|
&item.DistanceMeters,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
item.Name = optionalText(name)
|
|
item.Village = optionalText(village)
|
|
item.Direction = optionalText(direction)
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
type spatialTableSpec struct {
|
|
name string
|
|
geometryTypes []string
|
|
}
|
|
|
|
var spatialTableSpecs = []spatialTableSpec{
|
|
{name: "st_2_mpslfh_t_slfh_syd", geometryTypes: []string{"POINT"}},
|
|
{name: "st_2_xianyouxushuichiguan", geometryTypes: []string{"POINT"}},
|
|
{name: "st_2_xianyoufanghuotongdao", geometryTypes: []string{"LINESTRING", "MULTILINESTRING"}},
|
|
{name: "st_2_fanghuojianchazhan", geometryTypes: []string{"POINT"}},
|
|
{name: "st_2_fanghuoliaowangshao", geometryTypes: []string{"POINT"}},
|
|
{name: "st_2_fanghuowangge", geometryTypes: []string{"POLYGON", "MULTIPOLYGON"}},
|
|
{name: "st_2_linqugongkuangqiye", geometryTypes: []string{"POLYGON", "MULTIPOLYGON"}},
|
|
{name: "st_2_mudifenqu_mian", geometryTypes: []string{"POLYGON", "MULTIPOLYGON"}},
|
|
}
|
|
|
|
func quoteIdentifier(identifier string) string {
|
|
return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"`
|
|
}
|
|
|
|
func splitNonEmpty(value string) []string {
|
|
if value == "" {
|
|
return []string{}
|
|
}
|
|
return strings.Split(value, ",")
|
|
}
|
|
|
|
func parseSRIDs(value string) ([]int, error) {
|
|
parts := splitNonEmpty(value)
|
|
result := make([]int, 0, len(parts))
|
|
for _, part := range parts {
|
|
srid, err := strconv.Atoi(part)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse geometry SRID: %w", err)
|
|
}
|
|
result = append(result, srid)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func optionalText(value pgtype.Text) string {
|
|
if !value.Valid {
|
|
return ""
|
|
}
|
|
return value.String
|
|
}
|
|
|
|
func optionalFloat64(value pgtype.Float8) *float64 {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
result := value.Float64
|
|
return &result
|
|
}
|
|
|
|
func optionalInt64(value pgtype.Int8) *int64 {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
result := value.Int64
|
|
return &result
|
|
}
|
|
|
|
const placeCandidateSearchSQL = `
|
|
WITH candidates AS (
|
|
SELECT
|
|
'water_source'::text AS place_type,
|
|
w.objectid::text AS source_record_id,
|
|
NULLIF(w.name, '') AS name,
|
|
COALESCE(w.zj, '') AS town,
|
|
NULL::text AS village,
|
|
ST_Force2D(w.geom) AS location,
|
|
'recorded_point'::text AS location_kind
|
|
FROM public.st_2_mpslfh_t_slfh_syd AS w
|
|
WHERE ($2::boolean OR w.zj = ANY($3::text[]))
|
|
AND w.geom IS NOT NULL
|
|
AND ST_SRID(w.geom) = 4326
|
|
AND GeometryType(w.geom) = 'POINT'
|
|
AND ST_IsValid(w.geom)
|
|
AND NOT ST_IsEmpty(w.geom)
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'storage_pool'::text,
|
|
r.gid::text,
|
|
NULLIF(r.community, ''),
|
|
COALESCE(r.auth, ''),
|
|
NULLIF(r.community, ''),
|
|
ST_Force2D(r.geom),
|
|
'recorded_point'::text
|
|
FROM public.st_2_xianyouxushuichiguan AS r
|
|
WHERE ($2::boolean OR r.auth = ANY($3::text[]))
|
|
AND r.geom IS NOT NULL
|
|
AND ST_SRID(r.geom) = 4326
|
|
AND GeometryType(r.geom) = 'POINT'
|
|
AND ST_IsValid(r.geom)
|
|
AND NOT ST_IsEmpty(r.geom)
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'fire_access_line'::text,
|
|
a.gid::text,
|
|
NULLIF(a.name, ''),
|
|
COALESCE(a.auth, ''),
|
|
NULL::text,
|
|
ST_StartPoint(
|
|
CASE
|
|
WHEN GeometryType(a.geom) = 'LINESTRING' THEN ST_Force2D(a.geom)
|
|
ELSE ST_GeometryN(ST_Force2D(a.geom), 1)
|
|
END
|
|
),
|
|
'representative_point'::text
|
|
FROM public.st_2_xianyoufanghuotongdao AS a
|
|
WHERE ($2::boolean OR a.auth = ANY($3::text[]))
|
|
AND a.geom IS NOT NULL
|
|
AND ST_SRID(a.geom) = 4326
|
|
AND GeometryType(a.geom) IN ('LINESTRING', 'MULTILINESTRING')
|
|
AND ST_IsValid(a.geom)
|
|
AND NOT ST_IsEmpty(a.geom)
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'fire_check_station'::text,
|
|
s.gid::text,
|
|
NULLIF(s.jczmc, ''),
|
|
COALESCE(s.zjmc, ''),
|
|
NULLIF(s.cmc, ''),
|
|
ST_Force2D(s.geom),
|
|
'recorded_point'::text
|
|
FROM public.st_2_fanghuojianchazhan AS s
|
|
WHERE ($2::boolean OR s.zjmc = ANY($3::text[]))
|
|
AND s.geom IS NOT NULL
|
|
AND ST_SRID(s.geom) = 4326
|
|
AND GeometryType(s.geom) = 'POINT'
|
|
AND ST_IsValid(s.geom)
|
|
AND NOT ST_IsEmpty(s.geom)
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'fire_lookout'::text,
|
|
l.gid::text,
|
|
NULLIF(l.lwsmc, ''),
|
|
COALESCE(l.zjmc, ''),
|
|
NULLIF(l.cmc, ''),
|
|
ST_Force2D(l.geom),
|
|
'recorded_point'::text
|
|
FROM public.st_2_fanghuoliaowangshao AS l
|
|
WHERE ($2::boolean OR l.zjmc = ANY($3::text[]))
|
|
AND l.geom IS NOT NULL
|
|
AND ST_SRID(l.geom) = 4326
|
|
AND GeometryType(l.geom) = 'POINT'
|
|
AND ST_IsValid(l.geom)
|
|
AND NOT ST_IsEmpty(l.geom)
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'fire_grid'::text,
|
|
g.gid::text,
|
|
NULLIF(g.name, ''),
|
|
COALESCE(g.auth, ''),
|
|
NULL::text,
|
|
ST_PointOnSurface(ST_Force2D(g.geom)),
|
|
'representative_point'::text
|
|
FROM public.st_2_fanghuowangge AS g
|
|
WHERE ($2::boolean OR g.auth = ANY($3::text[]))
|
|
AND g.geom IS NOT NULL
|
|
AND ST_SRID(g.geom) = 4326
|
|
AND GeometryType(g.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
|
AND ST_IsValid(g.geom)
|
|
AND NOT ST_IsEmpty(g.geom)
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'forest_enterprise'::text,
|
|
e.gid::text,
|
|
NULLIF(e.lx, ''),
|
|
COALESCE(e.auth, ''),
|
|
NULLIF(e.c, ''),
|
|
ST_PointOnSurface(ST_Force2D(e.geom)),
|
|
'representative_point'::text
|
|
FROM public.st_2_linqugongkuangqiye AS e
|
|
WHERE ($2::boolean OR e.auth = ANY($3::text[]))
|
|
AND e.geom IS NOT NULL
|
|
AND ST_SRID(e.geom) = 4326
|
|
AND GeometryType(e.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
|
AND ST_IsValid(e.geom)
|
|
AND NOT ST_IsEmpty(e.geom)
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'cemetery_area'::text,
|
|
c.gid::text,
|
|
NULLIF(c.name, ''),
|
|
COALESCE(c.zj, ''),
|
|
NULLIF(c.cmc, ''),
|
|
ST_PointOnSurface(ST_Force2D(c.geom)),
|
|
'representative_point'::text
|
|
FROM public.st_2_mudifenqu_mian AS c
|
|
WHERE ($2::boolean OR c.zj = ANY($3::text[]))
|
|
AND c.geom IS NOT NULL
|
|
AND ST_SRID(c.geom) = 4326
|
|
AND GeometryType(c.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
|
AND ST_IsValid(c.geom)
|
|
AND NOT ST_IsEmpty(c.geom)
|
|
), matched AS (
|
|
SELECT
|
|
candidates.*,
|
|
CASE
|
|
WHEN lower(COALESCE(name, '')) = lower($1::text) THEN 'name'
|
|
WHEN lower(COALESCE(village, '')) = lower($1::text) THEN 'village'
|
|
WHEN lower(COALESCE(town, '')) = lower($1::text) THEN 'town'
|
|
WHEN strpos(lower(COALESCE(name, '')), lower($1::text)) = 1 THEN 'name'
|
|
WHEN strpos(lower(COALESCE(village, '')), lower($1::text)) = 1 THEN 'village'
|
|
WHEN strpos(lower(COALESCE(town, '')), lower($1::text)) = 1 THEN 'town'
|
|
WHEN strpos(lower(COALESCE(name, '')), lower($1::text)) > 0 THEN 'name'
|
|
WHEN strpos(lower(COALESCE(village, '')), lower($1::text)) > 0 THEN 'village'
|
|
ELSE 'town'
|
|
END AS matched_field,
|
|
CASE
|
|
WHEN lower(COALESCE(name, '')) = lower($1::text) THEN 0
|
|
WHEN lower(COALESCE(village, '')) = lower($1::text) THEN 1
|
|
WHEN lower(COALESCE(town, '')) = lower($1::text) THEN 2
|
|
WHEN strpos(lower(COALESCE(name, '')), lower($1::text)) = 1 THEN 3
|
|
WHEN strpos(lower(COALESCE(village, '')), lower($1::text)) = 1 THEN 4
|
|
WHEN strpos(lower(COALESCE(town, '')), lower($1::text)) = 1 THEN 5
|
|
WHEN strpos(lower(COALESCE(name, '')), lower($1::text)) > 0 THEN 6
|
|
WHEN strpos(lower(COALESCE(village, '')), lower($1::text)) > 0 THEN 7
|
|
ELSE 8
|
|
END AS match_rank
|
|
FROM candidates
|
|
WHERE strpos(lower(COALESCE(name, '')), lower($1::text)) > 0
|
|
OR strpos(lower(COALESCE(village, '')), lower($1::text)) > 0
|
|
OR strpos(lower(COALESCE(town, '')), lower($1::text)) > 0
|
|
)
|
|
SELECT
|
|
place_type,
|
|
source_record_id,
|
|
LEFT(COALESCE(name, ''), 255),
|
|
LEFT(COALESCE(town, ''), 100),
|
|
LEFT(COALESCE(village, ''), 255),
|
|
matched_field,
|
|
LEFT(CASE matched_field WHEN 'name' THEN name WHEN 'village' THEN village ELSE town END, 255),
|
|
CASE WHEN match_rank <= 2 THEN 'exact' ELSE 'partial' END,
|
|
round(ST_X(location)::numeric, 6)::float8,
|
|
round(ST_Y(location)::numeric, 6)::float8,
|
|
location_kind
|
|
FROM matched
|
|
ORDER BY match_rank, town, village, name, place_type, source_record_id
|
|
LIMIT $4`
|
|
|
|
const resolveIncidentContextSQL = `
|
|
WITH incident AS (
|
|
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
|
)
|
|
SELECT g.gid::text, LEFT(COALESCE(g.auth, ''), 100), LEFT(COALESCE(g.name, ''), 255)
|
|
FROM public.st_2_fanghuowangge AS g
|
|
CROSS JOIN incident AS i
|
|
WHERE ($3::boolean OR g.auth = ANY($4::text[]))
|
|
AND g.geom IS NOT NULL
|
|
AND ST_SRID(g.geom) = 4326
|
|
AND GeometryType(g.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
|
AND ST_IsValid(g.geom)
|
|
AND NOT ST_IsEmpty(g.geom)
|
|
AND ST_Covers(g.geom, i.geom)
|
|
ORDER BY ST_Area(g.geom::geography), g.gid
|
|
LIMIT 20`
|
|
|
|
const nearbyWaterSourcesSQL = `
|
|
WITH incident AS (
|
|
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
|
), candidates AS (
|
|
SELECT
|
|
'water_source'::text AS category,
|
|
w.objectid::text AS source_record_id,
|
|
LEFT(NULLIF(w.name, ''), 255) AS name,
|
|
LEFT(COALESCE(w.zj, ''), 100) AS town,
|
|
NULL::text AS village,
|
|
ST_X(w.geom) AS longitude,
|
|
ST_Y(w.geom) AS latitude,
|
|
ST_Distance(w.geom::geography, i.geom::geography) AS distance_meters,
|
|
w.xsrl::float8 AS capacity_cubic_meters,
|
|
LEFT(NULLIF(w.lx, ''), 100) AS resource_type,
|
|
LEFT(NULLIF(w.syzt, ''), 100) AS reported_status,
|
|
w.hc_datetime AS source_timestamp_raw
|
|
FROM public.st_2_mpslfh_t_slfh_syd AS w
|
|
CROSS JOIN incident AS i
|
|
WHERE ($3::boolean OR w.zj = ANY($4::text[]))
|
|
AND w.geom IS NOT NULL
|
|
AND ST_SRID(w.geom) = 4326
|
|
AND GeometryType(w.geom) = 'POINT'
|
|
AND ST_IsValid(w.geom)
|
|
AND NOT ST_IsEmpty(w.geom)
|
|
AND w.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
|
AND ST_DWithin(w.geom::geography, i.geom::geography, $5)
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'storage_pool'::text,
|
|
r.gid::text,
|
|
LEFT(NULLIF(r.community, ''), 255),
|
|
LEFT(COALESCE(r.auth, ''), 100),
|
|
LEFT(NULLIF(r.community, ''), 255),
|
|
ST_X(r.geom),
|
|
ST_Y(r.geom),
|
|
ST_Distance(r.geom::geography, i.geom::geography),
|
|
r.lfs::float8,
|
|
LEFT(NULLIF(r.lx, ''), 100),
|
|
NULL::text,
|
|
NULL::bigint
|
|
FROM public.st_2_xianyouxushuichiguan AS r
|
|
CROSS JOIN incident AS i
|
|
WHERE ($3::boolean OR r.auth = ANY($4::text[]))
|
|
AND r.geom IS NOT NULL
|
|
AND ST_SRID(r.geom) = 4326
|
|
AND GeometryType(r.geom) = 'POINT'
|
|
AND ST_IsValid(r.geom)
|
|
AND NOT ST_IsEmpty(r.geom)
|
|
AND r.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
|
AND ST_DWithin(r.geom::geography, i.geom::geography, $5)
|
|
)
|
|
SELECT category, source_record_id, name, town, village,
|
|
round(longitude::numeric, 6)::float8, round(latitude::numeric, 6)::float8,
|
|
round(distance_meters::numeric, 1)::float8, capacity_cubic_meters, resource_type, reported_status,
|
|
source_timestamp_raw
|
|
FROM candidates
|
|
ORDER BY distance_meters, category, source_record_id
|
|
LIMIT $6`
|
|
|
|
const commandPostCandidatesSQL = `
|
|
WITH incident AS (
|
|
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
|
), candidates AS (
|
|
SELECT
|
|
'fire_check_station'::text AS facility_type,
|
|
s.gid::text AS source_record_id,
|
|
LEFT(NULLIF(s.jczmc, ''), 255) AS name,
|
|
LEFT(COALESCE(s.zjmc, ''), 100) AS town,
|
|
LEFT(NULLIF(s.cmc, ''), 255) AS village,
|
|
ST_X(s.geom) AS longitude,
|
|
ST_Y(s.geom) AS latitude,
|
|
ST_Distance(s.geom::geography, i.geom::geography) AS distance_meters,
|
|
LEFT(NULLIF(s.syzt, ''), 100) AS reported_status,
|
|
LEFT(NULLIF(s.gldw, ''), 255) AS management_unit
|
|
FROM public.st_2_fanghuojianchazhan AS s
|
|
CROSS JOIN incident AS i
|
|
WHERE ($3::boolean OR s.zjmc = ANY($4::text[]))
|
|
AND s.geom IS NOT NULL
|
|
AND ST_SRID(s.geom) = 4326
|
|
AND GeometryType(s.geom) = 'POINT'
|
|
AND ST_IsValid(s.geom)
|
|
AND NOT ST_IsEmpty(s.geom)
|
|
AND s.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
|
AND ST_DWithin(s.geom::geography, i.geom::geography, $5)
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'fire_lookout'::text,
|
|
l.gid::text,
|
|
LEFT(NULLIF(l.lwsmc, ''), 255),
|
|
LEFT(COALESCE(l.zjmc, ''), 100),
|
|
LEFT(NULLIF(l.cmc, ''), 255),
|
|
ST_X(l.geom),
|
|
ST_Y(l.geom),
|
|
ST_Distance(l.geom::geography, i.geom::geography),
|
|
LEFT(NULLIF(l.syzt, ''), 100),
|
|
NULL::text
|
|
FROM public.st_2_fanghuoliaowangshao AS l
|
|
CROSS JOIN incident AS i
|
|
WHERE ($3::boolean OR l.zjmc = ANY($4::text[]))
|
|
AND l.geom IS NOT NULL
|
|
AND ST_SRID(l.geom) = 4326
|
|
AND GeometryType(l.geom) = 'POINT'
|
|
AND ST_IsValid(l.geom)
|
|
AND NOT ST_IsEmpty(l.geom)
|
|
AND l.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
|
AND ST_DWithin(l.geom::geography, i.geom::geography, $5)
|
|
)
|
|
SELECT facility_type, source_record_id, name, town, village,
|
|
round(longitude::numeric, 6)::float8, round(latitude::numeric, 6)::float8,
|
|
round(distance_meters::numeric, 1)::float8, reported_status, management_unit
|
|
FROM candidates
|
|
ORDER BY distance_meters, facility_type, source_record_id
|
|
LIMIT $6`
|
|
|
|
const nearbyAccessLinesSQL = `
|
|
WITH incident AS (
|
|
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
|
), candidates AS (
|
|
SELECT
|
|
a.gid::text AS source_record_id,
|
|
LEFT(COALESCE(a.auth, ''), 100) AS town,
|
|
LEFT(NULLIF(a.name, ''), 255) AS name,
|
|
ST_Distance(a.geom::geography, i.geom::geography) AS distance_meters,
|
|
ST_ClosestPoint(a.geom, i.geom) AS nearest_point,
|
|
ST_Length(a.geom::geography) AS length_meters,
|
|
a.sttime::text AS source_updated_raw
|
|
FROM public.st_2_xianyoufanghuotongdao AS a
|
|
CROSS JOIN incident AS i
|
|
WHERE ($3::boolean OR a.auth = ANY($4::text[]))
|
|
AND a.geom IS NOT NULL
|
|
AND ST_SRID(a.geom) = 4326
|
|
AND GeometryType(a.geom) IN ('LINESTRING', 'MULTILINESTRING')
|
|
AND ST_IsValid(a.geom)
|
|
AND NOT ST_IsEmpty(a.geom)
|
|
AND a.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
|
AND ST_DWithin(a.geom::geography, i.geom::geography, $5)
|
|
)
|
|
SELECT source_record_id, town, name, round(distance_meters::numeric, 1)::float8,
|
|
round(ST_X(nearest_point)::numeric, 6)::float8,
|
|
round(ST_Y(nearest_point)::numeric, 6)::float8,
|
|
length_meters, source_updated_raw
|
|
FROM candidates
|
|
ORDER BY distance_meters, source_record_id
|
|
LIMIT $6`
|
|
|
|
const responsibleUnitsSQL = `
|
|
WITH incident AS (
|
|
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
|
)
|
|
SELECT g.gid::text, LEFT(COALESCE(g.auth, ''), 100), LEFT(COALESCE(g.name, ''), 255), LEFT(COALESCE(g.fhzd, ''), 255)
|
|
FROM public.st_2_fanghuowangge AS g
|
|
CROSS JOIN incident AS i
|
|
WHERE ($3::boolean OR g.auth = ANY($4::text[]))
|
|
AND g.geom IS NOT NULL
|
|
AND ST_SRID(g.geom) = 4326
|
|
AND GeometryType(g.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
|
AND ST_IsValid(g.geom)
|
|
AND NOT ST_IsEmpty(g.geom)
|
|
AND ST_Covers(g.geom, i.geom)
|
|
ORDER BY ST_Area(g.geom::geography), g.gid
|
|
LIMIT 20`
|
|
|
|
const nearbyRiskAreasSQL = `
|
|
WITH incident AS (
|
|
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
|
), candidates AS (
|
|
SELECT
|
|
'cemetery_area'::text AS risk_type,
|
|
c.gid::text AS source_record_id,
|
|
LEFT(NULLIF(c.name, ''), 255) AS name,
|
|
LEFT(COALESCE(c.zj, ''), 100) AS town,
|
|
LEFT(NULLIF(c.cmc, ''), 255) AS village,
|
|
LEFT(NULLIF(c.fw, ''), 255) AS direction,
|
|
ST_Covers(c.geom, i.geom) AS covers_point,
|
|
ST_Distance(c.geom::geography, i.geom::geography) AS distance_meters
|
|
FROM public.st_2_mudifenqu_mian AS c
|
|
CROSS JOIN incident AS i
|
|
WHERE ($3::boolean OR c.zj = ANY($4::text[]))
|
|
AND c.geom IS NOT NULL
|
|
AND ST_SRID(c.geom) = 4326
|
|
AND GeometryType(c.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
|
AND ST_IsValid(c.geom)
|
|
AND NOT ST_IsEmpty(c.geom)
|
|
AND c.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
|
AND ST_DWithin(c.geom::geography, i.geom::geography, $5)
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'forest_enterprise'::text,
|
|
e.gid::text,
|
|
LEFT(NULLIF(e.lx, ''), 255),
|
|
LEFT(COALESCE(e.auth, ''), 100),
|
|
LEFT(NULLIF(e.c, ''), 255),
|
|
LEFT(NULLIF(e.fw, ''), 255),
|
|
ST_Covers(e.geom, i.geom),
|
|
ST_Distance(e.geom::geography, i.geom::geography)
|
|
FROM public.st_2_linqugongkuangqiye AS e
|
|
CROSS JOIN incident AS i
|
|
WHERE ($3::boolean OR e.auth = ANY($4::text[]))
|
|
AND e.geom IS NOT NULL
|
|
AND ST_SRID(e.geom) = 4326
|
|
AND GeometryType(e.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
|
AND ST_IsValid(e.geom)
|
|
AND NOT ST_IsEmpty(e.geom)
|
|
AND e.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
|
AND ST_DWithin(e.geom::geography, i.geom::geography, $5)
|
|
)
|
|
SELECT risk_type, source_record_id, name, town, village, direction, covers_point,
|
|
round(distance_meters::numeric, 1)::float8
|
|
FROM candidates
|
|
ORDER BY covers_point DESC, distance_meters, risk_type, source_record_id
|
|
LIMIT $6`
|