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>
283 lines
6.8 KiB
Go
283 lines
6.8 KiB
Go
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
|
|
}
|