Saving current working state before proceeding to Stage 2. Includes: - Backend: Python-based QC validator with shapefile processing - Frontend: Drag-and-drop file upload interface - Sample files for testing - Documentation and revision history 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
413 lines
14 KiB
Go
413 lines
14 KiB
Go
package qc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"verofy-backend/models"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type UndergroundEndpointResult struct {
|
|
SegmentID int `json:"segment_id"`
|
|
SegmentName string `json:"segment_name"`
|
|
Type string `json:"type"`
|
|
IsValid bool `json:"is_valid"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
StartEndpoint string `json:"start_endpoint,omitempty"`
|
|
EndEndpoint string `json:"end_endpoint,omitempty"`
|
|
Geometry map[string]interface{} `json:"geometry,omitempty"`
|
|
}
|
|
|
|
type UndergroundEndpointSummary struct {
|
|
TotalUndergroundSegments int `json:"total_underground_segments"`
|
|
ValidSegments int `json:"valid_segments"`
|
|
InvalidSegments int `json:"invalid_segments"`
|
|
PassRate float64 `json:"pass_rate"`
|
|
Results []UndergroundEndpointResult `json:"results"`
|
|
}
|
|
|
|
func UndergroundEndpointsRoute(router *gin.Engine, db *gorm.DB, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) {
|
|
// Full underground endpoints summary endpoint
|
|
router.GET("/api/qc/underground-endpoints", func(c *gin.Context) {
|
|
mapID := c.Query("map_id")
|
|
zone := c.Query("zone")
|
|
if mapID == "" || zone == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "map_id and zone are required"})
|
|
return
|
|
}
|
|
|
|
summary, err := CheckUndergroundEndpoints(db, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, summary)
|
|
})
|
|
|
|
// Invalid segments only endpoint
|
|
router.GET("/api/qc/underground-endpoints/invalid", func(c *gin.Context) {
|
|
mapID := c.Query("map_id")
|
|
zone := c.Query("zone")
|
|
if mapID == "" || zone == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "map_id and zone are required"})
|
|
return
|
|
}
|
|
|
|
invalid, err := GetInvalidUndergroundEndpoints(db, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"invalid_segments": invalid,
|
|
"count": len(invalid),
|
|
})
|
|
})
|
|
|
|
// Update QC flags endpoint
|
|
router.POST("/api/qc/underground-endpoints/update-flags", func(c *gin.Context) {
|
|
var request struct {
|
|
SegmentIDs []int `json:"segment_ids"`
|
|
MapID string `json:"map_id"`
|
|
Zone string `json:"zone"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
err := UpdateUndergroundEndpointFlags(db, request.SegmentIDs, schema, segmentTable, idCol, qcFlagCol)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Updated QC flags for %d segments", len(request.SegmentIDs))})
|
|
})
|
|
}
|
|
|
|
// CheckUndergroundEndpoints validates that underground segments have poles or access points at both endpoints
|
|
func CheckUndergroundEndpoints(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) (*UndergroundEndpointSummary, error) {
|
|
var segments []models.SegmentGeoJSON
|
|
table := fmt.Sprintf("%s.%s", schema, segmentTable)
|
|
|
|
// Query underground segments
|
|
err := db.Table(table).
|
|
Select(fmt.Sprintf("id_0, %s, segment_type, segment_status, %s, protection_status, %s, ST_AsGeoJSON(geom)::json AS geometry", mapIDCol, idCol, qcFlagCol)).
|
|
Where(fmt.Sprintf("%s = ? AND %s = ? AND LOWER(segment_type) = ?", mapIDCol, zoneCol), mapID, zone, "underground").
|
|
Find(&segments).Error
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch underground segments: %w", err)
|
|
}
|
|
|
|
// Get poles and access points for the same map/zone
|
|
poles, err := getPoles(db, mapID, schema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch poles: %w", err)
|
|
}
|
|
|
|
accessPoints, err := getAccessPoints(db, mapID, schema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch access points: %w", err)
|
|
}
|
|
|
|
summary := &UndergroundEndpointSummary{
|
|
TotalUndergroundSegments: len(segments),
|
|
Results: make([]UndergroundEndpointResult, 0, len(segments)),
|
|
}
|
|
|
|
for _, segment := range segments {
|
|
result := validateUndergroundEndpoints(segment, poles, accessPoints)
|
|
summary.Results = append(summary.Results, result)
|
|
|
|
if result.IsValid {
|
|
summary.ValidSegments++
|
|
} else {
|
|
summary.InvalidSegments++
|
|
}
|
|
}
|
|
|
|
// Calculate pass rate
|
|
if summary.TotalUndergroundSegments > 0 {
|
|
summary.PassRate = float64(summary.ValidSegments) / float64(summary.TotalUndergroundSegments) * 100
|
|
}
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// validateUndergroundEndpoints checks if underground segment has poles/access points at both ends
|
|
func validateUndergroundEndpoints(segment models.SegmentGeoJSON, poles []models.PolesGeoJSON, accessPoints []models.AccessPointGeoJSON) UndergroundEndpointResult {
|
|
result := UndergroundEndpointResult{
|
|
SegmentID: int(segment.ID),
|
|
Type: segment.SegmentType,
|
|
IsValid: false,
|
|
}
|
|
|
|
// Parse the geometry to get start and end coordinates
|
|
if len(segment.Geometry) == 0 {
|
|
result.ErrorMessage = "Segment has no geometry data"
|
|
return result
|
|
}
|
|
|
|
startCoord, endCoord, geometry, err := getSegmentEndpoints(segment.Geometry)
|
|
if err != nil {
|
|
result.ErrorMessage = fmt.Sprintf("Failed to parse geometry: %v", err)
|
|
return result
|
|
}
|
|
|
|
result.Geometry = geometry
|
|
|
|
// Check for poles/access points near start and end coordinates
|
|
// Using a buffer distance of ~10 meters (0.0001 degrees approximately)
|
|
bufferDistance := 0.0001
|
|
|
|
startHasEndpoint := hasEndpointNearCoordinate(startCoord, poles, accessPoints, bufferDistance)
|
|
endHasEndpoint := hasEndpointNearCoordinate(endCoord, poles, accessPoints, bufferDistance)
|
|
|
|
startEndpointType := getEndpointTypeNearCoordinate(startCoord, poles, accessPoints, bufferDistance)
|
|
endEndpointType := getEndpointTypeNearCoordinate(endCoord, poles, accessPoints, bufferDistance)
|
|
|
|
result.StartEndpoint = startEndpointType
|
|
result.EndEndpoint = endEndpointType
|
|
|
|
if startHasEndpoint && endHasEndpoint {
|
|
result.IsValid = true
|
|
} else {
|
|
errorParts := []string{}
|
|
if !startHasEndpoint {
|
|
errorParts = append(errorParts, "no pole/access point at start")
|
|
}
|
|
if !endHasEndpoint {
|
|
errorParts = append(errorParts, "no pole/access point at end")
|
|
}
|
|
result.ErrorMessage = fmt.Sprintf("Underground segment missing endpoints: %s", fmt.Sprintf("%s", errorParts))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// getSegmentEndpoints extracts start and end coordinates from segment geometry
|
|
func getSegmentEndpoints(geometryRaw json.RawMessage) ([2]float64, [2]float64, map[string]interface{}, error) {
|
|
var geometry map[string]interface{}
|
|
|
|
if err := json.Unmarshal(geometryRaw, &geometry); err != nil {
|
|
return [2]float64{}, [2]float64{}, nil, fmt.Errorf("invalid GeoJSON: %w", err)
|
|
}
|
|
|
|
geometryType, ok := geometry["type"].(string)
|
|
if !ok {
|
|
return [2]float64{}, [2]float64{}, geometry, fmt.Errorf("missing or invalid geometry type")
|
|
}
|
|
|
|
coordinates, ok := geometry["coordinates"]
|
|
if !ok {
|
|
return [2]float64{}, [2]float64{}, geometry, fmt.Errorf("missing coordinates")
|
|
}
|
|
|
|
var startCoord, endCoord [2]float64
|
|
|
|
switch geometryType {
|
|
case "LineString":
|
|
// LineString coordinates are [[x,y], [x,y], ...]
|
|
if coordArray, ok := coordinates.([]interface{}); ok && len(coordArray) >= 2 {
|
|
if startPoint, ok := coordArray[0].([]interface{}); ok && len(startPoint) >= 2 {
|
|
if x, ok := startPoint[0].(float64); ok {
|
|
startCoord[0] = x
|
|
}
|
|
if y, ok := startPoint[1].(float64); ok {
|
|
startCoord[1] = y
|
|
}
|
|
}
|
|
if endPoint, ok := coordArray[len(coordArray)-1].([]interface{}); ok && len(endPoint) >= 2 {
|
|
if x, ok := endPoint[0].(float64); ok {
|
|
endCoord[0] = x
|
|
}
|
|
if y, ok := endPoint[1].(float64); ok {
|
|
endCoord[1] = y
|
|
}
|
|
}
|
|
} else {
|
|
return [2]float64{}, [2]float64{}, geometry, fmt.Errorf("invalid LineString coordinates")
|
|
}
|
|
case "MultiLineString":
|
|
// For MultiLineString, use first and last coordinates of the entire geometry
|
|
if coordArrays, ok := coordinates.([]interface{}); ok && len(coordArrays) > 0 {
|
|
// Get start from first LineString
|
|
if firstLine, ok := coordArrays[0].([]interface{}); ok && len(firstLine) >= 2 {
|
|
if startPoint, ok := firstLine[0].([]interface{}); ok && len(startPoint) >= 2 {
|
|
if x, ok := startPoint[0].(float64); ok {
|
|
startCoord[0] = x
|
|
}
|
|
if y, ok := startPoint[1].(float64); ok {
|
|
startCoord[1] = y
|
|
}
|
|
}
|
|
}
|
|
// Get end from last LineString
|
|
if lastLine, ok := coordArrays[len(coordArrays)-1].([]interface{}); ok && len(lastLine) >= 2 {
|
|
if endPoint, ok := lastLine[len(lastLine)-1].([]interface{}); ok && len(endPoint) >= 2 {
|
|
if x, ok := endPoint[0].(float64); ok {
|
|
endCoord[0] = x
|
|
}
|
|
if y, ok := endPoint[1].(float64); ok {
|
|
endCoord[1] = y
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
return [2]float64{}, [2]float64{}, geometry, fmt.Errorf("invalid MultiLineString coordinates")
|
|
}
|
|
default:
|
|
return [2]float64{}, [2]float64{}, geometry, fmt.Errorf("unsupported geometry type: %s", geometryType)
|
|
}
|
|
|
|
return startCoord, endCoord, geometry, nil
|
|
}
|
|
|
|
// hasEndpointNearCoordinate checks if there's a pole or access point within buffer distance of coordinate
|
|
func hasEndpointNearCoordinate(coord [2]float64, poles []models.PolesGeoJSON, accessPoints []models.AccessPointGeoJSON, buffer float64) bool {
|
|
// Check poles
|
|
for _, pole := range poles {
|
|
if poleCoord, err := getPointCoordinates(pole.Geometry); err == nil {
|
|
if distance(coord, poleCoord) <= buffer {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check access points
|
|
for _, ap := range accessPoints {
|
|
if apCoord, err := getPointCoordinates(ap.Geometry); err == nil {
|
|
if distance(coord, apCoord) <= buffer {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// getEndpointTypeNearCoordinate returns the type of endpoint near the coordinate
|
|
func getEndpointTypeNearCoordinate(coord [2]float64, poles []models.PolesGeoJSON, accessPoints []models.AccessPointGeoJSON, buffer float64) string {
|
|
// Check poles first
|
|
for _, pole := range poles {
|
|
if poleCoord, err := getPointCoordinates(pole.Geometry); err == nil {
|
|
if distance(coord, poleCoord) <= buffer {
|
|
return fmt.Sprintf("Pole (ID: %d)", *pole.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check access points
|
|
for _, ap := range accessPoints {
|
|
if apCoord, err := getPointCoordinates(ap.Geometry); err == nil {
|
|
if distance(coord, apCoord) <= buffer {
|
|
return fmt.Sprintf("Access Point (ID: %d)", *ap.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
return "None"
|
|
}
|
|
|
|
// getPointCoordinates extracts coordinates from point geometry
|
|
func getPointCoordinates(geometryRaw json.RawMessage) ([2]float64, error) {
|
|
var geometry map[string]interface{}
|
|
|
|
if err := json.Unmarshal(geometryRaw, &geometry); err != nil {
|
|
return [2]float64{}, fmt.Errorf("invalid GeoJSON: %w", err)
|
|
}
|
|
|
|
geometryType, ok := geometry["type"].(string)
|
|
if !ok || geometryType != "Point" {
|
|
return [2]float64{}, fmt.Errorf("not a Point geometry")
|
|
}
|
|
|
|
coordinates, ok := geometry["coordinates"].([]interface{})
|
|
if !ok || len(coordinates) < 2 {
|
|
return [2]float64{}, fmt.Errorf("invalid Point coordinates")
|
|
}
|
|
|
|
var coord [2]float64
|
|
if x, ok := coordinates[0].(float64); ok {
|
|
coord[0] = x
|
|
}
|
|
if y, ok := coordinates[1].(float64); ok {
|
|
coord[1] = y
|
|
}
|
|
|
|
return coord, nil
|
|
}
|
|
|
|
// distance calculates simple Euclidean distance between two coordinates
|
|
func distance(a, b [2]float64) float64 {
|
|
dx := a[0] - b[0]
|
|
dy := a[1] - b[1]
|
|
return dx*dx + dy*dy // Using squared distance for efficiency
|
|
}
|
|
|
|
// getPoles fetches poles for the given map ID
|
|
func getPoles(db *gorm.DB, mapID, schema string) ([]models.PolesGeoJSON, error) {
|
|
var poles []models.PolesGeoJSON
|
|
table := fmt.Sprintf("%s.poles", schema)
|
|
|
|
err := db.Table(table).
|
|
Select("gid, id, mapprojectid, name, tags, group1, group2, owner, poleheight, attachmentheight, ST_AsGeoJSON(geom)::json AS geometry").
|
|
Where("mapprojectid = ?", mapID).
|
|
Find(&poles).Error
|
|
|
|
return poles, err
|
|
}
|
|
|
|
// getAccessPoints fetches access points for the given map ID
|
|
func getAccessPoints(db *gorm.DB, mapID, schema string) ([]models.AccessPointGeoJSON, error) {
|
|
var accessPoints []models.AccessPointGeoJSON
|
|
table := fmt.Sprintf("%s.access_points", schema)
|
|
|
|
err := db.Table(table).
|
|
Select(`gid, id, name, mapprojectid, latitude, longitude, manufacturer, size, locked, description, aka,
|
|
createdby, createddate, modifiedby, modifieddate, historyid, group1, group2, typeid, statusid,
|
|
crmvendorid, billdate, ST_AsGeoJSON(geom)::json AS geometry`).
|
|
Where("mapprojectid = ?", mapID).
|
|
Find(&accessPoints).Error
|
|
|
|
return accessPoints, err
|
|
}
|
|
|
|
// GetInvalidUndergroundEndpoints returns only the segments that failed the check
|
|
func GetInvalidUndergroundEndpoints(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) ([]UndergroundEndpointResult, error) {
|
|
summary, err := CheckUndergroundEndpoints(db, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var invalid []UndergroundEndpointResult
|
|
for _, result := range summary.Results {
|
|
if !result.IsValid {
|
|
invalid = append(invalid, result)
|
|
}
|
|
}
|
|
|
|
return invalid, nil
|
|
}
|
|
|
|
// UpdateUndergroundEndpointFlags updates QC flags for invalid segments
|
|
func UpdateUndergroundEndpointFlags(db *gorm.DB, segmentIDs []int, schema, segmentTable, idCol, qcFlagCol string) error {
|
|
if len(segmentIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
table := fmt.Sprintf("%s.%s", schema, segmentTable)
|
|
|
|
return db.Table(table).
|
|
Where(fmt.Sprintf("%s IN ?", idCol), segmentIDs).
|
|
Update(qcFlagCol, "underground_endpoint_issue").Error
|
|
}
|