Odoo ERP ported to Go — complete backend + original OWL frontend
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>
This commit is contained in:
172
pkg/server/web_methods.go
Normal file
172
pkg/server/web_methods.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// handleWebSearchRead implements the web_search_read method.
|
||||
// Mirrors: odoo/addons/web/models/models.py web_search_read()
|
||||
// Returns {length: N, records: [...]} instead of just records.
|
||||
func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
|
||||
rs := env.Model(model)
|
||||
|
||||
// Parse domain from first arg
|
||||
domain := parseDomain(params.Args)
|
||||
|
||||
// Parse specification from kwargs
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
fields := specToFields(spec)
|
||||
|
||||
// Always include id
|
||||
hasID := false
|
||||
for _, f := range fields {
|
||||
if f == "id" {
|
||||
hasID = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasID {
|
||||
fields = append([]string{"id"}, fields...)
|
||||
}
|
||||
|
||||
// Parse offset, limit, order
|
||||
offset := 0
|
||||
limit := 80
|
||||
order := ""
|
||||
if v, ok := params.KW["offset"].(float64); ok {
|
||||
offset = int(v)
|
||||
}
|
||||
if v, ok := params.KW["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
if v, ok := params.KW["order"].(string); ok {
|
||||
order = v
|
||||
}
|
||||
|
||||
// Get total count
|
||||
count, err := rs.SearchCount(domain)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
|
||||
// Search with offset/limit
|
||||
found, err := rs.Search(domain, orm.SearchOpts{
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
Order: order,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
|
||||
var records []orm.Values
|
||||
if !found.IsEmpty() {
|
||||
records, err = found.Read(fields)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
// Format M2O fields as {id, display_name} when spec requests it
|
||||
formatM2OFields(env, model, records, spec)
|
||||
|
||||
if records == nil {
|
||||
records = []orm.Values{}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"length": count,
|
||||
"records": records,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleWebRead implements the web_read method.
|
||||
// Mirrors: odoo/addons/web/models/models.py web_read()
|
||||
func handleWebRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return []orm.Values{}, nil
|
||||
}
|
||||
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
fields := specToFields(spec)
|
||||
|
||||
rs := env.Model(model)
|
||||
records, err := rs.Browse(ids...).Read(fields)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
|
||||
formatM2OFields(env, model, records, spec)
|
||||
|
||||
if records == nil {
|
||||
records = []orm.Values{}
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// specToFields extracts field names from a specification dict.
|
||||
// {"name": {}, "partner_id": {"fields": {"display_name": {}}}} → ["name", "partner_id"]
|
||||
func specToFields(spec map[string]interface{}) []string {
|
||||
if len(spec) == 0 {
|
||||
return nil
|
||||
}
|
||||
fields := make([]string, 0, len(spec))
|
||||
for name := range spec {
|
||||
fields = append(fields, name)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// formatM2OFields converts Many2one field values from raw int to {id, display_name}.
|
||||
func formatM2OFields(env *orm.Environment, modelName string, records []orm.Values, spec map[string]interface{}) {
|
||||
m := orm.Registry.Get(modelName)
|
||||
if m == nil || spec == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
for fieldName, fieldSpec := range spec {
|
||||
f := m.GetField(fieldName)
|
||||
if f == nil || f.Type != orm.TypeMany2one {
|
||||
continue
|
||||
}
|
||||
|
||||
// Accept any spec entry for M2O fields (even empty {} means include it)
|
||||
_, ok := fieldSpec.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the raw FK ID
|
||||
rawID := rec[fieldName]
|
||||
fkID := int64(0)
|
||||
switch v := rawID.(type) {
|
||||
case int64:
|
||||
fkID = v
|
||||
case int32:
|
||||
fkID = int64(v)
|
||||
case float64:
|
||||
fkID = int64(v)
|
||||
}
|
||||
|
||||
if fkID == 0 {
|
||||
rec[fieldName] = false // Odoo convention for empty M2O
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch display_name from comodel — return as [id, "name"] array
|
||||
if f.Comodel != "" {
|
||||
coRS := env.Model(f.Comodel).Browse(fkID)
|
||||
names, err := coRS.NameGet()
|
||||
if err == nil && len(names) > 0 {
|
||||
rec[fieldName] = []interface{}{fkID, names[fkID]}
|
||||
} else {
|
||||
rec[fieldName] = []interface{}{fkID, fmt.Sprintf("%s,%d", f.Comodel, fkID)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user