Full port of Odoo's ERP system from Python to Go, with the original Odoo JavaScript frontend (OWL framework) running against the Go server. Backend (10,691 LoC Go): - Custom ORM: CRUD, domains→SQL with JOINs, computed fields, sequences - 93 models across 14 modules (base, account, sale, stock, purchase, hr, project, crm, fleet, product, l10n_de, google_address/translate/calendar) - Auth with bcrypt + session cookies - Setup wizard (company, SKR03 chart, admin, demo data) - Double-entry bookkeeping constraint - Sale→Invoice workflow (confirm SO → generate invoice → post) - SKR03 chart of accounts (110 accounts) + German taxes (USt/VSt) - Record rules (multi-company filter) - Google integrations as opt-in modules (Maps, Translate, Calendar) Frontend: - Odoo's original OWL webclient (503 JS modules, 378 XML templates) - JS transpiled via Odoo's js_transpiler (ES modules → odoo.define) - SCSS compiled to CSS (675KB) via dart-sass - XML templates compiled to registerTemplate() JS calls - Static file serving from Odoo source addons - Login page, session management, menu navigation - Contacts list view renders with real data from PostgreSQL Infrastructure: - 14MB single binary (CGO_ENABLED=0) - Docker Compose (Go server + PostgreSQL 16) - Zero phone-home (no outbound calls to odoo.com) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
211 lines
5.8 KiB
Go
211 lines
5.8 KiB
Go
package models
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"odoo-go/pkg/orm"
|
|
"odoo-go/pkg/tools"
|
|
)
|
|
|
|
// Google Maps API client — only initialized if API key is set.
|
|
var mapsClient *tools.APIClient
|
|
|
|
func getClient() *tools.APIClient {
|
|
if mapsClient != nil {
|
|
return mapsClient
|
|
}
|
|
apiKey := os.Getenv("GOOGLE_MAPS_API_KEY")
|
|
if apiKey == "" {
|
|
return nil
|
|
}
|
|
mapsClient = tools.NewAPIClient("https://maps.googleapis.com", apiKey)
|
|
return mapsClient
|
|
}
|
|
|
|
// initGoogleAddress extends res.partner with geocoding fields and methods.
|
|
func initGoogleAddress() {
|
|
// Extend res.partner with lat/lng fields
|
|
partner := orm.Registry.Get("res.partner")
|
|
if partner != nil {
|
|
partner.Extend(
|
|
orm.Float("partner_latitude", orm.FieldOpts{String: "Geo Latitude"}),
|
|
orm.Float("partner_longitude", orm.FieldOpts{String: "Geo Longitude"}),
|
|
)
|
|
|
|
// geo_localize: Geocode partner address → lat/lng
|
|
// Calls Google Geocoding API: https://maps.googleapis.com/maps/api/geocode/json
|
|
partner.RegisterMethod("geo_localize", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
client := getClient()
|
|
if client == nil {
|
|
return nil, fmt.Errorf("google_address: GOOGLE_MAPS_API_KEY not configured")
|
|
}
|
|
|
|
env := rs.Env()
|
|
for _, id := range rs.IDs() {
|
|
// Read address fields
|
|
var street, city, zip, country string
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT COALESCE(street,''), COALESCE(city,''), COALESCE(zip,''),
|
|
COALESCE((SELECT code FROM res_country WHERE id = p.country_id), '')
|
|
FROM res_partner p WHERE p.id = $1`, id,
|
|
).Scan(&street, &city, &zip, &country)
|
|
|
|
address := fmt.Sprintf("%s, %s %s, %s", street, zip, city, country)
|
|
|
|
// Call Geocoding API
|
|
var result GeocodingResponse
|
|
err := client.GetJSON("/maps/api/geocode/json", map[string]string{
|
|
"address": address,
|
|
}, &result)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("google_address: geocode failed: %w", err)
|
|
}
|
|
|
|
if result.Status == "OK" && len(result.Results) > 0 {
|
|
loc := result.Results[0].Geometry.Location
|
|
env.Tx().Exec(env.Ctx(),
|
|
`UPDATE res_partner SET partner_latitude = $1, partner_longitude = $2 WHERE id = $3`,
|
|
loc.Lat, loc.Lng, id)
|
|
}
|
|
}
|
|
return true, nil
|
|
})
|
|
|
|
// address_autocomplete: Search for addresses via Google Places
|
|
// Returns suggestions for autocomplete in the UI.
|
|
partner.RegisterMethod("address_autocomplete", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
client := getClient()
|
|
if client == nil {
|
|
return nil, fmt.Errorf("google_address: GOOGLE_MAPS_API_KEY not configured")
|
|
}
|
|
|
|
query := ""
|
|
if len(args) > 0 {
|
|
query, _ = args[0].(string)
|
|
}
|
|
if query == "" {
|
|
return []interface{}{}, nil
|
|
}
|
|
|
|
var result AutocompleteResponse
|
|
err := client.GetJSON("/maps/api/place/autocomplete/json", map[string]string{
|
|
"input": query,
|
|
"types": "address",
|
|
}, &result)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var suggestions []map[string]interface{}
|
|
for _, p := range result.Predictions {
|
|
suggestions = append(suggestions, map[string]interface{}{
|
|
"description": p.Description,
|
|
"place_id": p.PlaceID,
|
|
})
|
|
}
|
|
return suggestions, nil
|
|
})
|
|
|
|
// place_details: Get full address from place_id
|
|
partner.RegisterMethod("place_details", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
client := getClient()
|
|
if client == nil {
|
|
return nil, fmt.Errorf("google_address: GOOGLE_MAPS_API_KEY not configured")
|
|
}
|
|
|
|
placeID := ""
|
|
if len(args) > 0 {
|
|
placeID, _ = args[0].(string)
|
|
}
|
|
if placeID == "" {
|
|
return nil, fmt.Errorf("google_address: place_id required")
|
|
}
|
|
|
|
var result PlaceDetailsResponse
|
|
err := client.GetJSON("/maps/api/place/details/json", map[string]string{
|
|
"place_id": placeID,
|
|
"fields": "address_components,geometry,formatted_address",
|
|
}, &result)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if result.Status != "OK" {
|
|
return nil, fmt.Errorf("google_address: place details failed: %s", result.Status)
|
|
}
|
|
|
|
// Parse address components
|
|
address := map[string]interface{}{
|
|
"formatted_address": result.Result.FormattedAddress,
|
|
"latitude": result.Result.Geometry.Location.Lat,
|
|
"longitude": result.Result.Geometry.Location.Lng,
|
|
}
|
|
|
|
for _, comp := range result.Result.AddressComponents {
|
|
for _, t := range comp.Types {
|
|
switch t {
|
|
case "street_number":
|
|
address["street_number"] = comp.LongName
|
|
case "route":
|
|
address["street"] = comp.LongName
|
|
case "locality":
|
|
address["city"] = comp.LongName
|
|
case "postal_code":
|
|
address["zip"] = comp.LongName
|
|
case "country":
|
|
address["country_code"] = comp.ShortName
|
|
address["country"] = comp.LongName
|
|
case "administrative_area_level_1":
|
|
address["state"] = comp.LongName
|
|
}
|
|
}
|
|
}
|
|
|
|
return address, nil
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Google API Response Types ---
|
|
|
|
type GeocodingResponse struct {
|
|
Status string `json:"status"`
|
|
Results []struct {
|
|
FormattedAddress string `json:"formatted_address"`
|
|
Geometry struct {
|
|
Location LatLng `json:"location"`
|
|
} `json:"geometry"`
|
|
} `json:"results"`
|
|
}
|
|
|
|
type LatLng struct {
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
}
|
|
|
|
type AutocompleteResponse struct {
|
|
Status string `json:"status"`
|
|
Predictions []struct {
|
|
Description string `json:"description"`
|
|
PlaceID string `json:"place_id"`
|
|
} `json:"predictions"`
|
|
}
|
|
|
|
type PlaceDetailsResponse struct {
|
|
Status string `json:"status"`
|
|
Result struct {
|
|
FormattedAddress string `json:"formatted_address"`
|
|
Geometry struct {
|
|
Location LatLng `json:"location"`
|
|
} `json:"geometry"`
|
|
AddressComponents []AddressComponent `json:"address_components"`
|
|
} `json:"result"`
|
|
}
|
|
|
|
type AddressComponent struct {
|
|
LongName string `json:"long_name"`
|
|
ShortName string `json:"short_name"`
|
|
Types []string `json:"types"`
|
|
}
|