turash/bugulma/backend/internal/domain/site.go

276 lines
9.1 KiB
Go

package domain
import (
"context"
"encoding/json"
"fmt"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// SiteType represents the primary usage type of a site
type SiteType string
const (
SiteTypeIndustrial SiteType = "industrial"
SiteTypeOffice SiteType = "office"
SiteTypeWarehouse SiteType = "warehouse"
SiteTypeRetail SiteType = "retail"
SiteTypeMixed SiteType = "mixed"
)
// Ownership represents the ownership status
type Ownership string
const (
OwnershipOwned Ownership = "owned"
OwnershipLeased Ownership = "leased"
OwnershipShared Ownership = "shared"
)
// Site represents a physical location/building where business activities occur
type Site struct {
ID string `gorm:"primaryKey;type:text"`
Name string `gorm:"type:text;index"` // Primary name (Russian)
// Location
Latitude float64 `gorm:"type:double precision;not null;index:idx_site_location"`
Longitude float64 `gorm:"type:double precision;not null;index:idx_site_location"`
// PostGIS geometry field (location_geometry) is managed separately via migrations
// It's excluded from GORM operations using raw SQL queries only
// The column exists in the database but is not part of the GORM model
// Site characteristics
SiteType SiteType `gorm:"type:varchar(50);index"`
FloorAreaM2 float64 `gorm:"type:double precision"`
Ownership Ownership `gorm:"type:varchar(50)"`
// Ownership and operations
OwnerOrganizationID string `gorm:"type:text;index"`
// Infrastructure
AvailableUtilities datatypes.JSON `gorm:"default:'[]'"` // []string - electricity, gas, water, wastewater, heating, cooling
ParkingSpaces int `gorm:"type:integer"`
LoadingDocks int `gorm:"type:integer"`
CraneCapacityTonnes float64 `gorm:"type:double precision"`
// Environmental
EnergyRating string `gorm:"type:text"`
WasteManagement datatypes.JSON `gorm:"default:'[]'"` // []string
EnvironmentalImpact string `gorm:"type:text"`
// Historical/architectural (for heritage sites)
YearBuilt string `gorm:"type:text"`
BuilderOwner string `gorm:"type:text"`
Architect string `gorm:"type:text"`
OriginalPurpose string `gorm:"type:text"`
CurrentUse string `gorm:"type:text"`
Style string `gorm:"type:text"`
Materials string `gorm:"type:text"`
Storeys int `gorm:"type:integer"`
HeritageStatus string `gorm:"type:text"`
// Metadata
Notes string `gorm:"type:text"`
Sources datatypes.JSON `gorm:"type:jsonb"`
// Timestamps
CreatedAt time.Time `gorm:"autoCreateTime;index"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
// Associations
Addresses []Address `gorm:"many2many:site_addresses;"`
OwnerOrganization *Organization `gorm:"foreignKey:OwnerOrganizationID"`
OperatingOrganizations []*Organization `gorm:"many2many:site_operating_organizations;"`
ResourceFlows []ResourceFlow `gorm:"foreignKey:SiteID"`
SharedAssets []SharedAsset `gorm:"foreignKey:SiteID"`
}
// TableName specifies the table name for GORM
func (Site) TableName() string {
return "sites"
}
// unmarshalStringSliceSite safely unmarshals datatypes.JSON to []string for Site
// Uses the same logic as organization but with additional handling for objects
func unmarshalStringSliceSite(data datatypes.JSON) []string {
if len(data) == 0 {
return []string{}
}
// First try to unmarshal as []string
var result []string
if err := json.Unmarshal(data, &result); err == nil {
return result
}
// If that fails, try to unmarshal as any type and handle it
var anyValue interface{}
if err := json.Unmarshal(data, &anyValue); err == nil {
switch v := anyValue.(type) {
case []interface{}:
// It's an array of interfaces, convert to strings
strings := make([]string, len(v))
for i, item := range v {
if str, ok := item.(string); ok {
strings[i] = str
} else {
// Convert to string representation
if bytes, err := json.Marshal(item); err == nil {
strings[i] = string(bytes)
} else {
strings[i] = fmt.Sprintf("%v", item)
}
}
}
return strings
case map[string]interface{}:
// It's an object, convert to JSON string
if bytes, err := json.Marshal(v); err == nil {
return []string{string(bytes)}
}
case string:
return []string{v}
default:
// Convert to string representation
if bytes, err := json.Marshal(v); err == nil {
return []string{string(bytes)}
}
}
}
// As last resort, return empty array
return []string{}
}
// MarshalJSON custom marshaling to ensure arrays are always arrays
func (s Site) MarshalJSON() ([]byte, error) {
type Alias Site
return json.Marshal(&struct {
AvailableUtilities []string `json:"AvailableUtilities"`
WasteManagement []string `json:"WasteManagement"`
Sources []string `json:"Sources"`
*Alias
}{
AvailableUtilities: unmarshalStringSliceSite(s.AvailableUtilities),
WasteManagement: unmarshalStringSliceSite(s.WasteManagement),
Sources: unmarshalStringSliceSite(s.Sources),
Alias: (*Alias)(&s),
})
}
// PrimaryAddress returns the primary site address formatted string
func (s *Site) PrimaryAddress() string {
if len(s.Addresses) > 0 {
if s.Addresses[0].FormattedRu != "" {
return s.Addresses[0].FormattedRu
}
if s.Addresses[0].FormattedEn != "" {
return s.Addresses[0].FormattedEn
}
}
return ""
}
// AfterCreate hook to update PostGIS geometry from lat/lng using raw SQL
// Uses ST_SetSRID and ST_MakePoint for proper PostGIS geometry creation (2025 best practice)
// This avoids GORM trying to handle the geometry column directly
func (s *Site) AfterCreate(tx *gorm.DB) error {
if tx.Dialector.Name() == "postgres" {
// Check if PostGIS is available and column exists before updating
var postgisAvailable bool
var columnExists bool
if err := tx.Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisAvailable).Error; err != nil {
return nil // Silently skip if PostGIS check fails
}
if !postgisAvailable {
return nil // PostGIS not available, skip geometry update
}
if err := tx.Raw(`
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name = 'sites' AND column_name = 'location_geometry'
)
`).Scan(&columnExists).Error; err != nil || !columnExists {
return nil // Column doesn't exist, skip
}
// Update geometry column using proper PostGIS functions (2025 best practice)
// ST_SetSRID ensures correct SRID, ST_MakePoint creates point from coordinates
if s.Latitude != 0 && s.Longitude != 0 {
if err := tx.Exec(`
UPDATE sites
SET location_geometry = ST_SetSRID(ST_MakePoint(?::double precision, ?::double precision), 4326)
WHERE id = ?
`, s.Longitude, s.Latitude, s.ID).Error; err != nil {
// Log error but don't fail the transaction
// Geometry update is non-critical for basic operations
return nil
}
}
}
return nil
}
// AfterUpdate hook to update PostGIS geometry from lat/lng using raw SQL
// Uses ST_SetSRID and ST_MakePoint for proper PostGIS geometry creation (2025 best practice)
func (s *Site) AfterUpdate(tx *gorm.DB) error {
if tx.Dialector.Name() == "postgres" {
// Check if PostGIS is available and column exists before updating
var postgisAvailable bool
var columnExists bool
if err := tx.Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisAvailable).Error; err != nil {
return nil // Silently skip if PostGIS check fails
}
if !postgisAvailable {
return nil // PostGIS not available, skip geometry update
}
if err := tx.Raw(`
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name = 'sites' AND column_name = 'location_geometry'
)
`).Scan(&columnExists).Error; err != nil || !columnExists {
return nil // Column doesn't exist, skip
}
// Update geometry column using proper PostGIS functions (2025 best practice)
// ST_SetSRID ensures correct SRID, ST_MakePoint creates point from coordinates
if s.Latitude != 0 && s.Longitude != 0 {
if err := tx.Exec(`
UPDATE sites
SET location_geometry = ST_SetSRID(ST_MakePoint(?::double precision, ?::double precision), 4326)
WHERE id = ?
`, s.Longitude, s.Latitude, s.ID).Error; err != nil {
// Log error but don't fail the transaction
// Geometry update is non-critical for basic operations
return nil
}
}
}
return nil
}
type SiteRepository interface {
Create(ctx context.Context, site *Site) error
GetByID(ctx context.Context, id string) (*Site, error)
GetByOrganizationID(ctx context.Context, organizationID string) ([]*Site, error)
GetWithinRadius(ctx context.Context, lat, lng, radiusKm float64) ([]*Site, error)
GetAll(ctx context.Context) ([]*Site, error)
GetBySiteType(ctx context.Context, siteType SiteType) ([]*Site, error)
GetHeritageSites(ctx context.Context, locale string) ([]*Site, error)
// Find records by arbitrary where clause (convenience for service code/tests)
FindWhere(query interface{}, args ...interface{}) ([]*Site, error)
Update(ctx context.Context, site *Site) error
Delete(ctx context.Context, id string) error
}