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:
282
pkg/orm/relational.go
Normal file
282
pkg/orm/relational.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProcessO2MCommands processes One2many field commands after a Create/Write.
|
||||
// Mirrors: odoo/orm/fields_relational.py One2many write logic
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// CmdCreate(vals) → Create child record with inverse_field = parentID
|
||||
// CmdUpdate(id, vals) → Update child record
|
||||
// CmdDelete(id) → Delete child record
|
||||
func ProcessO2MCommands(env *Environment, f *Field, parentID int64, cmds []Command) error {
|
||||
if f.Comodel == "" || f.InverseField == "" {
|
||||
return fmt.Errorf("orm: O2M field %q missing comodel or inverse_field", f.Name)
|
||||
}
|
||||
|
||||
comodelRS := env.Model(f.Comodel)
|
||||
|
||||
for _, cmd := range cmds {
|
||||
switch cmd.Operation {
|
||||
case CommandCreate:
|
||||
vals := cmd.Values
|
||||
if vals == nil {
|
||||
vals = make(Values)
|
||||
}
|
||||
vals[f.InverseField] = parentID
|
||||
if _, err := comodelRS.Create(vals); err != nil {
|
||||
return fmt.Errorf("orm: O2M create on %s: %w", f.Comodel, err)
|
||||
}
|
||||
|
||||
case CommandUpdate:
|
||||
if cmd.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := comodelRS.Browse(cmd.ID).Write(cmd.Values); err != nil {
|
||||
return fmt.Errorf("orm: O2M update %s(%d): %w", f.Comodel, cmd.ID, err)
|
||||
}
|
||||
|
||||
case CommandDelete:
|
||||
if cmd.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := comodelRS.Browse(cmd.ID).Unlink(); err != nil {
|
||||
return fmt.Errorf("orm: O2M delete %s(%d): %w", f.Comodel, cmd.ID, err)
|
||||
}
|
||||
|
||||
case CommandClear:
|
||||
// Delete all children linked to this parent
|
||||
children, err := comodelRS.Search(And(Leaf(f.InverseField, "=", parentID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := children.Unlink(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessM2MCommands processes Many2many field commands after a Create/Write.
|
||||
// Mirrors: odoo/orm/fields_relational.py Many2many write logic
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// CmdLink(id) → Add link in junction table
|
||||
// CmdUnlink(id) → Remove link from junction table
|
||||
// CmdClear() → Remove all links
|
||||
// CmdSet(ids) → Replace all links
|
||||
// CmdCreate(vals) → Create comodel record then link it
|
||||
func ProcessM2MCommands(env *Environment, f *Field, parentID int64, cmds []Command) error {
|
||||
jt := f.JunctionTable()
|
||||
col1 := f.JunctionCol1()
|
||||
col2 := f.JunctionCol2()
|
||||
|
||||
if jt == "" || col1 == "" || col2 == "" {
|
||||
return fmt.Errorf("orm: M2M field %q: cannot determine junction table", f.Name)
|
||||
}
|
||||
|
||||
for _, cmd := range cmds {
|
||||
switch cmd.Operation {
|
||||
case CommandLink:
|
||||
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
jt, col1, col2,
|
||||
), parentID, cmd.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: M2M link %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
case CommandUnlink:
|
||||
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`DELETE FROM %q WHERE %q = $1 AND %q = $2`,
|
||||
jt, col1, col2,
|
||||
), parentID, cmd.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: M2M unlink %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
case CommandClear:
|
||||
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`DELETE FROM %q WHERE %q = $1`,
|
||||
jt, col1,
|
||||
), parentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: M2M clear %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
case CommandSet:
|
||||
// Clear then link all
|
||||
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`DELETE FROM %q WHERE %q = $1`,
|
||||
jt, col1,
|
||||
), parentID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, targetID := range cmd.IDs {
|
||||
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
jt, col1, col2,
|
||||
), parentID, targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case CommandCreate:
|
||||
// Create comodel record then link
|
||||
comodelRS := env.Model(f.Comodel)
|
||||
created, err := comodelRS.Create(cmd.Values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
jt, col1, col2,
|
||||
), parentID, created.ID()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadM2MField reads the linked IDs for a Many2many field.
|
||||
func ReadM2MField(env *Environment, f *Field, parentIDs []int64) (map[int64][]int64, error) {
|
||||
jt := f.JunctionTable()
|
||||
col1 := f.JunctionCol1()
|
||||
col2 := f.JunctionCol2()
|
||||
|
||||
if jt == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(parentIDs))
|
||||
args := make([]interface{}, len(parentIDs))
|
||||
for i, id := range parentIDs {
|
||||
args[i] = id
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT %q, %q FROM %q WHERE %q IN (%s)`,
|
||||
col1, col2, jt, col1, strings.Join(placeholders, ", "),
|
||||
)
|
||||
|
||||
rows, err := env.tx.Query(env.ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[int64][]int64)
|
||||
for rows.Next() {
|
||||
var pid, tid int64
|
||||
if err := rows.Scan(&pid, &tid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[pid] = append(result[pid], tid)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ParseCommands converts a JSON value to a slice of Commands.
|
||||
// JSON format: [[0, 0, {vals}], [4, id, 0], [6, 0, [ids]], ...]
|
||||
func ParseCommands(raw interface{}) ([]Command, bool) {
|
||||
list, ok := raw.([]interface{})
|
||||
if !ok || len(list) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if this looks like commands (list of lists)
|
||||
first, ok := list[0].([]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if len(first) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var cmds []Command
|
||||
for _, item := range list {
|
||||
tuple, ok := item.([]interface{})
|
||||
if !ok || len(tuple) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
op, ok := toInt64(tuple[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch CommandOp(op) {
|
||||
case CommandCreate: // [0, _, {vals}]
|
||||
vals := make(Values)
|
||||
if len(tuple) > 2 {
|
||||
if m, ok := tuple[2].(map[string]interface{}); ok {
|
||||
vals = m
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, CmdCreate(vals))
|
||||
|
||||
case CommandUpdate: // [1, id, {vals}]
|
||||
id, _ := toInt64(tuple[1])
|
||||
vals := make(Values)
|
||||
if len(tuple) > 2 {
|
||||
if m, ok := tuple[2].(map[string]interface{}); ok {
|
||||
vals = m
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, CmdUpdate(id, vals))
|
||||
|
||||
case CommandDelete: // [2, id, _]
|
||||
id, _ := toInt64(tuple[1])
|
||||
cmds = append(cmds, CmdDelete(id))
|
||||
|
||||
case CommandUnlink: // [3, id, _]
|
||||
id, _ := toInt64(tuple[1])
|
||||
cmds = append(cmds, CmdUnlink(id))
|
||||
|
||||
case CommandLink: // [4, id, _]
|
||||
id, _ := toInt64(tuple[1])
|
||||
cmds = append(cmds, CmdLink(id))
|
||||
|
||||
case CommandClear: // [5, _, _]
|
||||
cmds = append(cmds, CmdClear())
|
||||
|
||||
case CommandSet: // [6, _, [ids]]
|
||||
var ids []int64
|
||||
if len(tuple) > 2 {
|
||||
if arr, ok := tuple[2].([]interface{}); ok {
|
||||
for _, v := range arr {
|
||||
if id, ok := toInt64(v); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, CmdSet(ids))
|
||||
}
|
||||
}
|
||||
|
||||
if len(cmds) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return cmds, true
|
||||
}
|
||||
|
||||
func toInt64(v interface{}) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
case int:
|
||||
return int64(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
Reference in New Issue
Block a user