#!/usr/bin/env python3 """Import Libo administrative units and business zones into Postgres. Two tracks are kept physically separate and are never derived from each other: * Statutory (法定) — from AMap reverse geocoding + civil-affairs code tables. Keyed by ``towncode``. Read-only reference data. * Business (业务) — the玉屏街道 city zone set. Keyed by ``zone_id``. Has no administrative standing. Zones are irregular polygons; POI membership is decided by point-in-polygon (NOT bounding boxes — 48% of the 12 zone bboxes overlap, which would misassign ~37% of POIs). Active zone set: ``ZS-LB-YUPING-V2`` — 12 surveyed city zones (A01–A12), replacing the earlier 18-anchor V1 scheme. Sources (2026-08-06 revised delivery, ``~/Desktop/荔波行政区与片区/``): * ``1_荔波县_乡镇街道.csv`` -> libo_admin_towns (8) * ``2_荔波县_村社区.csv`` -> libo_admin_villages (100) * ``3_玉屏街道_片区.csv`` -> libo_business_zones (12, attrs) * ``4_玉屏街道_片区边界.geojson`` -> libo_business_zones.geometry + POI 落区 * town-level POI assignment -> amap_spatial_pois.towncode Usage:: python scripts/import_libo_zones.py --graph-name yunyou_libo python scripts/import_libo_zones.py --dry-run """ from __future__ import annotations import argparse import csv import json import sys from pathlib import Path from typing import Any import psycopg from psycopg.rows import dict_row ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from app.config import settings # noqa: E402 HOME = Path.home() DEFAULT_ZONE_DIR = HOME / "Desktop" / "荔波行政区与片区" # Statutory town assignment for the full county (all 8 towns), one row per POI. DEFAULT_TOWN_POI_CSV = ( HOME / "Documents" / "云游荔波" / "outputs" / "libo_admin_grid_verified_20260805" / "不同业务POI乡镇归属" / "荔波县_POI实体_乡镇归属去重_20260805.csv" ) ZONE_SET_ID = "ZS-LB-YUPING-V2" # Zone sets superseded by the current import; cleared for the target graph. LEGACY_ZONE_SET_IDS = ("ZS-LB-YUPING-V1",) YUPING_TOWNCODE = "522722001000" SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS __SCHEMA__.libo_admin_towns ( town_uid TEXT PRIMARY KEY, civil_adcode TEXT NOT NULL, name TEXT NOT NULL, unit_type TEXT NOT NULL, towncode TEXT NOT NULL UNIQUE, stat_code TEXT, community_count INTEGER NOT NULL DEFAULT 0, village_count INTEGER NOT NULL DEFAULT 0, unit_total INTEGER NOT NULL DEFAULT 0, code_note TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS __SCHEMA__.libo_admin_villages ( village_uid TEXT PRIMARY KEY, parent_civil_adcode TEXT NOT NULL, town_name TEXT NOT NULL, parent_type TEXT, name TEXT NOT NULL, unit_type TEXT NOT NULL, alias TEXT, stat_code TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_libo_admin_villages_parent ON __SCHEMA__.libo_admin_villages (parent_civil_adcode); CREATE TABLE IF NOT EXISTS __SCHEMA__.libo_business_zones ( zone_set_id TEXT NOT NULL, zone_id TEXT NOT NULL, crs TEXT NOT NULL DEFAULT 'GCJ-02', zone_name TEXT NOT NULL, anchor_name TEXT, anchor_lng DOUBLE PRECISION, anchor_lat DOUBLE PRECISION, cap_m DOUBLE PRECISION, parent_admin_unit_id TEXT, parent_admin_name TEXT, poi_count INTEGER NOT NULL DEFAULT 0, food_count INTEGER NOT NULL DEFAULT 0, hotel_count INTEGER NOT NULL DEFAULT 0, transit_count INTEGER NOT NULL DEFAULT 0, scenic_count INTEGER NOT NULL DEFAULT 0, area_km2 DOUBLE PRECISION, dominant_category TEXT, bank TEXT, anchor_source TEXT, geometry JSONB, geometry_variant TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (zone_set_id, zone_id) ); CREATE TABLE IF NOT EXISTS __SCHEMA__.poi_zone_assignments ( graph_name TEXT NOT NULL, gaode_poi_id TEXT NOT NULL, zone_set_id TEXT NOT NULL, zone_id TEXT NOT NULL, zone_name TEXT NOT NULL, method TEXT, distance_m DOUBLE PRECISION, review_status TEXT, computed_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (graph_name, gaode_poi_id, zone_set_id) ); CREATE INDEX IF NOT EXISTS idx_poi_zone_assignments_zone ON __SCHEMA__.poi_zone_assignments (graph_name, zone_set_id, zone_id); """ # Newer schema installs need columns the earlier table version lacked. ZONE_EXTRA_COLUMNS = ( "scenic_count INTEGER NOT NULL DEFAULT 0", "area_km2 DOUBLE PRECISION", "dominant_category TEXT", "bank TEXT", ) def read_csv(path: Path) -> list[dict[str, str]]: with path.open(encoding="utf-8-sig", newline="") as handle: return list(csv.DictReader(handle)) def as_int(value: str | None) -> int: try: return int(float(str(value).strip())) except (TypeError, ValueError): return 0 def as_float(value: str | None) -> float | None: try: return float(str(value).strip()) except (TypeError, ValueError): return None # ── Point-in-polygon (GCJ-02, ray casting) ────────────────────────────────── def _in_ring(ring: list[list[float]], x: float, y: float) -> bool: n = len(ring) inside = False for i in range(n): x1, y1 = ring[i] x2, y2 = ring[(i + 1) % n] if (y1 > y) != (y2 > y): xt = (x2 - x1) * (y - y1) / (y2 - y1) + x1 if x < xt: inside = not inside return inside def load_zone_geometry(path: Path) -> dict[str, dict[str, Any]]: """zone_id -> {geometry, polys, variant, anchors, area_ha, bank}.""" payload = json.loads(path.read_text(encoding="utf-8")) result: dict[str, dict[str, Any]] = {} for feature in payload.get("features", []): props = feature.get("properties") or {} zone_id = props.get("zone_id") if not zone_id: continue geom = feature.get("geometry") or {} polys = ( geom.get("coordinates") if geom.get("type") == "MultiPolygon" else [geom.get("coordinates")] ) result[str(zone_id)] = { "geometry": geom, "polys": [p for p in (polys or []) if p], "variant": props.get("scheme") or props.get("status") or "surveyed", "anchors": props.get("anchors_gcj02") or [], "bank": props.get("bank"), } return result def assign_zone( lng: float, lat: float, ordered_zones: list[tuple[str, str, list]], ) -> tuple[str, str]: for zone_id, zone_name, polys in ordered_zones: for rings in polys: if _in_ring(rings[0], lng, lat) and not any( _in_ring(hole, lng, lat) for hole in rings[1:] ): return zone_id, zone_name return "", "" def import_towns(cur, schema: str, rows: list[dict[str, str]]) -> int: for row in rows: cur.execute( f"""INSERT INTO {schema}.libo_admin_towns (town_uid, civil_adcode, name, unit_type, towncode, stat_code, community_count, village_count, unit_total, code_note, updated_at) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, now()) ON CONFLICT (town_uid) DO UPDATE SET civil_adcode = EXCLUDED.civil_adcode, name = EXCLUDED.name, unit_type = EXCLUDED.unit_type, towncode = EXCLUDED.towncode, stat_code = EXCLUDED.stat_code, community_count = EXCLUDED.community_count, village_count = EXCLUDED.village_count, unit_total = EXCLUDED.unit_total, code_note = EXCLUDED.code_note, updated_at = now()""", ( row["内部行政区ID"], row["民政行政区划代码9位"], row["名称"], row["类型"], row["高德towncode12位"], row.get("2023统计用乡级代码12位"), as_int(row.get("社区数")), as_int(row.get("行政村数")), as_int(row.get("基层单元合计")), row.get("代码差异提示"), ), ) return len(rows) def import_villages(cur, schema: str, rows: list[dict[str, str]]) -> int: for row in rows: cur.execute( f"""INSERT INTO {schema}.libo_admin_villages (village_uid, parent_civil_adcode, town_name, parent_type, name, unit_type, alias, stat_code, updated_at) VALUES (%s,%s,%s,%s,%s,%s,%s,%s, now()) ON CONFLICT (village_uid) DO UPDATE SET parent_civil_adcode = EXCLUDED.parent_civil_adcode, town_name = EXCLUDED.town_name, parent_type = EXCLUDED.parent_type, name = EXCLUDED.name, unit_type = EXCLUDED.unit_type, alias = EXCLUDED.alias, stat_code = EXCLUDED.stat_code, updated_at = now()""", ( row["基层单元ID"], row["父级民政代码"], row["乡镇/街道"], row.get("父级类型"), row["现行名称"], row["单元类型"], row.get("别名/旧名线索"), row.get("统计用区划代码12位"), ), ) return len(rows) def import_zones( cur, schema: str, rows: list[dict[str, str]], geometry: dict[str, dict[str, Any]], ) -> tuple[int, list[str]]: without_face: list[str] = [] for row in rows: zone_id = row["片区编号"] face = geometry.get(zone_id) if not face: without_face.append(zone_id) anchors = face["anchors"] if face else [] # 面标签落点:多锚点取均值,否则留空。 if anchors: anchor_lng = sum(a[0] for a in anchors) / len(anchors) anchor_lat = sum(a[1] for a in anchors) / len(anchors) else: anchor_lng = anchor_lat = None cur.execute( f"""INSERT INTO {schema}.libo_business_zones (zone_set_id, zone_id, crs, zone_name, anchor_name, anchor_lng, anchor_lat, cap_m, parent_admin_unit_id, parent_admin_name, poi_count, food_count, hotel_count, transit_count, scenic_count, area_km2, dominant_category, bank, anchor_source, geometry, geometry_variant, updated_at) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, now()) ON CONFLICT (zone_set_id, zone_id) DO UPDATE SET crs = EXCLUDED.crs, zone_name = EXCLUDED.zone_name, anchor_name = EXCLUDED.anchor_name, anchor_lng = EXCLUDED.anchor_lng, anchor_lat = EXCLUDED.anchor_lat, cap_m = EXCLUDED.cap_m, parent_admin_unit_id = EXCLUDED.parent_admin_unit_id, parent_admin_name = EXCLUDED.parent_admin_name, poi_count = EXCLUDED.poi_count, food_count = EXCLUDED.food_count, hotel_count = EXCLUDED.hotel_count, transit_count = EXCLUDED.transit_count, scenic_count = EXCLUDED.scenic_count, area_km2 = EXCLUDED.area_km2, dominant_category = EXCLUDED.dominant_category, bank = EXCLUDED.bank, anchor_source = EXCLUDED.anchor_source, geometry = EXCLUDED.geometry, geometry_variant = EXCLUDED.geometry_variant, updated_at = now()""", ( ZONE_SET_ID, zone_id, "GCJ-02", row["片区名称"], None, anchor_lng, anchor_lat, None, # cap_m: 面几何为真多边形,不再有半径闸 "LB-522722001", row.get("所属街道") or "玉屏街道", as_int(row.get("POI数")), as_int(row.get("美食")), as_int(row.get("酒店")), as_int(row.get("交通")), as_int(row.get("景区")), as_float(row.get("面积km²")), row.get("主导业态"), face["bank"] if face else None, "玉屏街道城区实测划分(A方案)", json.dumps(face["geometry"], ensure_ascii=False) if face else None, face["variant"] if face else None, ), ) return len(rows), without_face def import_assignments( cur, schema: str, graph_name: str, geometry: dict[str, dict[str, Any]], zone_names: dict[str, str], ) -> tuple[int, int]: """Reassign every project POI to a city zone by point-in-polygon.""" ordered_zones = [ (zone_id, zone_names.get(zone_id, ""), geometry[zone_id]["polys"]) for zone_id in sorted(geometry) ] cur.execute( f"""SELECT gaode_poi_id, lng, lat FROM {schema}.amap_spatial_pois WHERE graph_name=%s AND lng IS NOT NULL AND lat IS NOT NULL""", (graph_name,), ) poi_rows = cur.fetchall() # Rebuild the active set cleanly: drop old rows for this graph+set first. cur.execute( f"""DELETE FROM {schema}.poi_zone_assignments WHERE graph_name=%s AND zone_set_id=%s""", (graph_name, ZONE_SET_ID), ) assigned = 0 for row in poi_rows: zone_id, zone_name = assign_zone( float(row["lng"]), float(row["lat"]), ordered_zones ) if not zone_id: continue cur.execute( f"""INSERT INTO {schema}.poi_zone_assignments (graph_name, gaode_poi_id, zone_set_id, zone_id, zone_name, method, review_status, computed_at, updated_at) VALUES (%s,%s,%s,%s,%s,%s,%s, now(), now()) ON CONFLICT (graph_name, gaode_poi_id, zone_set_id) DO UPDATE SET zone_id = EXCLUDED.zone_id, zone_name = EXCLUDED.zone_name, method = EXCLUDED.method, review_status = EXCLUDED.review_status, computed_at = now(), updated_at = now()""", ( graph_name, row["gaode_poi_id"], ZONE_SET_ID, zone_id, zone_name, "point_in_polygon", "自动归属", ), ) assigned += 1 return len(poi_rows), assigned def backfill_towncode( cur, schema: str, graph_name: str, rows: list[dict[str, str]], ) -> tuple[int, int]: """Write the statutory town assignment onto the POI table.""" cur.execute( f"ALTER TABLE {schema}.amap_spatial_pois " "ADD COLUMN IF NOT EXISTS towncode TEXT" ) cur.execute( f"ALTER TABLE {schema}.amap_spatial_pois " "ADD COLUMN IF NOT EXISTS town_name TEXT" ) cur.execute( f"CREATE INDEX IF NOT EXISTS idx_amap_spatial_pois_towncode " f"ON {schema}.amap_spatial_pois (graph_name, towncode)" ) updated = 0 for row in rows: towncode = (row.get("高德towncode") or "").strip() town_name = (row.get("高德乡镇街道") or "").strip() if not towncode or not town_name: continue cur.execute( f"""UPDATE {schema}.amap_spatial_pois SET towncode=%s, town_name=%s WHERE graph_name=%s AND gaode_poi_id=%s""", (towncode, town_name, graph_name, row["高德POI_ID"]), ) updated += cur.rowcount cur.execute( f"""SELECT count(*) AS n FROM {schema}.amap_spatial_pois WHERE graph_name=%s AND towncode IS NULL""", (graph_name,), ) missing = cur.fetchone()["n"] return updated, missing def purge_legacy_zone_sets(cur, schema: str, graph_name: str) -> None: """Remove superseded zone sets so no wrong V1 data lingers.""" for legacy in LEGACY_ZONE_SET_IDS: cur.execute( f"DELETE FROM {schema}.poi_zone_assignments " "WHERE graph_name=%s AND zone_set_id=%s", (graph_name, legacy), ) cur.execute( f"DELETE FROM {schema}.libo_business_zones WHERE zone_set_id=%s", (legacy,), ) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--graph-name", default="yunyou_libo") parser.add_argument("--zone-dir", type=Path, default=DEFAULT_ZONE_DIR) parser.add_argument("--town-poi-csv", type=Path, default=DEFAULT_TOWN_POI_CSV) parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() schema = settings.db_schema towns_csv = args.zone_dir / "1_荔波县_乡镇街道.csv" villages_csv = args.zone_dir / "2_荔波县_村社区.csv" zones_csv = args.zone_dir / "3_玉屏街道_片区.csv" geojson = args.zone_dir / "4_玉屏街道_片区边界.geojson" for path in (towns_csv, villages_csv, zones_csv, geojson, args.town_poi_csv): if not path.exists(): print(f"! 源文件不存在: {path}") return 1 towns = read_csv(towns_csv) villages = read_csv(villages_csv) zones = read_csv(zones_csv) town_pois = read_csv(args.town_poi_csv) geometry = load_zone_geometry(geojson) zone_names = {row["片区编号"]: row["片区名称"] for row in zones} print( f"读取: 乡级 {len(towns)} · 村社区 {len(villages)} · 片区 {len(zones)} · " f"面几何 {len(geometry)} · 乡镇归属 {len(town_pois)} · 目标集 {ZONE_SET_ID}" ) if args.dry_run: print("dry-run,未写库") return 0 with psycopg.connect(settings.database_url, row_factory=dict_row) as connection: with connection.cursor() as cur: cur.execute(SCHEMA_SQL.replace("__SCHEMA__", schema)) for ddl in ZONE_EXTRA_COLUMNS: cur.execute( f"ALTER TABLE {schema}.libo_business_zones " f"ADD COLUMN IF NOT EXISTS {ddl}" ) n_towns = import_towns(cur, schema, towns) n_villages = import_villages(cur, schema, villages) n_zones, without_face = import_zones(cur, schema, zones, geometry) n_pois, n_assign = import_assignments( cur, schema, args.graph_name, geometry, zone_names ) updated, missing = backfill_towncode( cur, schema, args.graph_name, town_pois ) purge_legacy_zone_sets(cur, schema, args.graph_name) connection.commit() print(f"✓ libo_admin_towns {n_towns}") print(f"✓ libo_admin_villages {n_villages}") print(f"✓ libo_business_zones {n_zones}(无面几何: {', '.join(without_face) or '无'})") print(f"✓ poi_zone_assignments {n_assign} / {n_pois} 落区(其余为城区外)") print(f"✓ amap_spatial_pois 乡镇回填 {updated} 行,仍缺 towncode {missing} 行") print(f"✓ 已清除旧片区集: {', '.join(LEGACY_ZONE_SET_IDS)}") return 0 if __name__ == "__main__": raise SystemExit(main())