feat: Districts table and geojson stub
This commit is contained in:
parent
f364ce4c6e
commit
0b45636e5b
@ -28,6 +28,8 @@ services:
|
|||||||
command: ["serve"]
|
command: ["serve"]
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ../stubs:/stubs
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- minio
|
- minio
|
||||||
|
|||||||
97
internal/cli/import_districts.go
Normal file
97
internal/cli/import_districts.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gis/internal/config"
|
||||||
|
"gis/internal/repository/postgres"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// districtFeature is the subset of a GeoJSON feature we care about: the raw
|
||||||
|
// geometry (handed to PostGIS verbatim) and the kato/name_ru properties. kato
|
||||||
|
// arrives as a JSON number but is stored as text, so it is decoded as
|
||||||
|
// json.Number to preserve its exact digits.
|
||||||
|
type districtFeature struct {
|
||||||
|
Geometry json.RawMessage `json:"geometry"`
|
||||||
|
Props struct {
|
||||||
|
Kato json.Number `json:"kato"`
|
||||||
|
NameRU string `json:"name_ru"`
|
||||||
|
} `json:"properties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type districtCollection struct {
|
||||||
|
Features []districtFeature `json:"features"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var importDistrictsCmd = &cobra.Command{
|
||||||
|
Use: "import-districts <geojson-file>",
|
||||||
|
Short: "Import district boundaries from a GeoJSON file",
|
||||||
|
Long: "Parse a districts GeoJSON FeatureCollection and upsert each feature\n" +
|
||||||
|
"into the districts table, keyed by KATO code. Each feature's name_ru\n" +
|
||||||
|
"and kato properties become name/kato; its geometry is stored as a\n" +
|
||||||
|
"MultiPolygon in EPSG:4326. Re-running upserts on kato, so existing\n" +
|
||||||
|
"districts have their name and geometry refreshed.\n\n" +
|
||||||
|
"Example:\n" +
|
||||||
|
" gis import-districts districts.geojson",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
raw, err := os.ReadFile(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read geojson %q: %w", args[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fc districtCollection
|
||||||
|
if err := json.Unmarshal(raw, &fc); err != nil {
|
||||||
|
return fmt.Errorf("parse geojson %q: %w", args[0], err)
|
||||||
|
}
|
||||||
|
if len(fc.Features) == 0 {
|
||||||
|
return fmt.Errorf("no features found in %q", args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := signalContext()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := postgres.Connect(ctx, cfg.DB.URL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect postgres: %w", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
const upsert = `
|
||||||
|
INSERT INTO districts (kato, name, coordinates)
|
||||||
|
VALUES ($1, $2, ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON($3), 4326)))
|
||||||
|
ON CONFLICT (kato) DO UPDATE
|
||||||
|
SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates`
|
||||||
|
|
||||||
|
var imported int
|
||||||
|
for i, f := range fc.Features {
|
||||||
|
kato := f.Props.Kato.String()
|
||||||
|
if kato == "" {
|
||||||
|
return fmt.Errorf("feature %d: missing kato", i)
|
||||||
|
}
|
||||||
|
if f.Props.NameRU == "" {
|
||||||
|
return fmt.Errorf("feature %d (kato %s): missing name_ru", i, kato)
|
||||||
|
}
|
||||||
|
if len(f.Geometry) == 0 {
|
||||||
|
return fmt.Errorf("feature %d (kato %s): missing geometry", i, kato)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, upsert, kato, f.Props.NameRU, string(f.Geometry)); err != nil {
|
||||||
|
return fmt.Errorf("upsert district kato %s: %w", kato, err)
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("imported %d districts from %s\n", imported, args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -28,7 +28,7 @@ func Execute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(serveCmd, workerCmd, migrateCmd, reprocessCmd)
|
rootCmd.AddCommand(serveCmd, workerCmd, migrateCmd, reprocessCmd, importDistrictsCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// signalContext returns a context cancelled on SIGINT or SIGTERM.
|
// signalContext returns a context cancelled on SIGINT or SIGTERM.
|
||||||
|
|||||||
17
migrations/00006_create_districts_table.sql
Normal file
17
migrations/00006_create_districts_table.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Administrative districts, keyed by KATO code. Geometry is the district
|
||||||
|
-- boundary stored as MultiPolygon in EPSG:4326. Populated from a districts
|
||||||
|
-- GeoJSON file via the `gis import-districts` subcommand.
|
||||||
|
CREATE TABLE districts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
kato TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
coordinates geometry(MultiPolygon, 4326) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_districts_kato ON districts (kato);
|
||||||
|
CREATE INDEX idx_districts_coordinates ON districts USING GIST (coordinates);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE districts;
|
||||||
4
stubs/districts.geojson
Normal file
4
stubs/districts.geojson
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user