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:
Marc
2026-03-31 01:45:09 +02:00
commit 0ed29fe2fd
90 changed files with 12133 additions and 0 deletions

377
pkg/orm/field.go Normal file
View File

@@ -0,0 +1,377 @@
package orm
import (
"fmt"
"time"
)
// Field defines a model field.
// Mirrors: odoo/orm/fields.py Field class
//
// Odoo field declaration:
//
// name = fields.Char(string='Name', required=True, index=True)
//
// Go equivalent:
//
// Char("name", FieldOpts{String: "Name", Required: true, Index: true})
type Field struct {
Name string // Technical name (e.g., "name", "partner_id")
Type FieldType // Field type (TypeChar, TypeMany2one, etc.)
String string // Human-readable label
// Constraints
Required bool
Readonly bool
Index bool // Create database index
Unique bool // Unique constraint (not in Odoo, but useful)
Size int // Max length for Char fields
Default Value // Default value or function name
Help string // Tooltip / help text
// Computed fields
// Mirrors: odoo/orm/fields.py Field.compute, Field.inverse, Field.depends
Compute string // Method name to compute this field
Inverse string // Method name to write back computed value
Depends []string // Field names this compute depends on
Store bool // Store computed value in DB (default: false for computed)
Precompute bool // Compute before first DB flush
// Related fields
// Mirrors: odoo/orm/fields.py Field.related
Related string // Dot-separated path (e.g., "partner_id.name")
// Selection
Selection []SelectionItem
// Relational fields
// Mirrors: odoo/orm/fields_relational.py
Comodel string // Target model name (e.g., "res.company")
InverseField string // For One2many: field name in comodel pointing back
Relation string // For Many2many: junction table name
Column1 string // For Many2many: column name for this model's FK
Column2 string // For Many2many: column name for comodel's FK
Domain Domain // Default domain filter for relational fields
OnDelete OnDelete // For Many2one: what happens on target deletion
// Monetary
CurrencyField string // Field name holding the currency_id
// Translation
Translate bool // Field supports multi-language
// Groups (access control)
Groups string // Comma-separated group XML IDs
// Internal
model *Model // Back-reference to owning model
column string // SQL column name (usually same as Name)
}
// Column returns the SQL column name for this field.
func (f *Field) Column() string {
if f.column != "" {
return f.column
}
return f.Name
}
// SQLType returns the PostgreSQL type for this field.
func (f *Field) SQLType() string {
if f.Type == TypeChar && f.Size > 0 {
return fmt.Sprintf("varchar(%d)", f.Size)
}
return f.Type.SQLType()
}
// IsStored returns true if this field has a database column.
func (f *Field) IsStored() bool {
// Computed fields without Store are not stored
if f.Compute != "" && !f.Store {
return false
}
// Related fields without Store are not stored
if f.Related != "" && !f.Store {
return false
}
return f.Type.IsStored()
}
// IsRelational returns true for relational field types.
func (f *Field) IsRelational() bool {
return f.Type.IsRelational()
}
// --- Field constructors ---
// Mirror Odoo's fields.Char(), fields.Integer(), etc.
// FieldOpts holds optional parameters for field constructors.
type FieldOpts struct {
String string
Required bool
Readonly bool
Index bool
Size int
Default Value
Help string
Compute string
Inverse string
Depends []string
Store bool
Precompute bool
Related string
Selection []SelectionItem
Comodel string
InverseField string
Relation string
Column1 string
Column2 string
Domain Domain
OnDelete OnDelete
CurrencyField string
Translate bool
Groups string
}
func newField(name string, typ FieldType, opts FieldOpts) *Field {
f := &Field{
Name: name,
Type: typ,
String: opts.String,
Required: opts.Required,
Readonly: opts.Readonly,
Index: opts.Index,
Size: opts.Size,
Default: opts.Default,
Help: opts.Help,
Compute: opts.Compute,
Inverse: opts.Inverse,
Depends: opts.Depends,
Store: opts.Store,
Precompute: opts.Precompute,
Related: opts.Related,
Selection: opts.Selection,
Comodel: opts.Comodel,
InverseField: opts.InverseField,
Relation: opts.Relation,
Column1: opts.Column1,
Column2: opts.Column2,
Domain: opts.Domain,
OnDelete: opts.OnDelete,
CurrencyField: opts.CurrencyField,
Translate: opts.Translate,
Groups: opts.Groups,
column: name,
}
if f.String == "" {
f.String = name
}
return f
}
// Char creates a character field. Mirrors: fields.Char
func Char(name string, opts FieldOpts) *Field {
return newField(name, TypeChar, opts)
}
// Text creates a text field. Mirrors: fields.Text
func Text(name string, opts FieldOpts) *Field {
return newField(name, TypeText, opts)
}
// HTML creates an HTML field. Mirrors: fields.Html
func HTML(name string, opts FieldOpts) *Field {
return newField(name, TypeHTML, opts)
}
// Integer creates an integer field. Mirrors: fields.Integer
func Integer(name string, opts FieldOpts) *Field {
return newField(name, TypeInteger, opts)
}
// Float creates a float field. Mirrors: fields.Float
func Float(name string, opts FieldOpts) *Field {
return newField(name, TypeFloat, opts)
}
// Monetary creates a monetary field. Mirrors: fields.Monetary
func Monetary(name string, opts FieldOpts) *Field {
return newField(name, TypeMonetary, opts)
}
// Boolean creates a boolean field. Mirrors: fields.Boolean
func Boolean(name string, opts FieldOpts) *Field {
return newField(name, TypeBoolean, opts)
}
// Date creates a date field. Mirrors: fields.Date
func Date(name string, opts FieldOpts) *Field {
return newField(name, TypeDate, opts)
}
// Datetime creates a datetime field. Mirrors: fields.Datetime
func Datetime(name string, opts FieldOpts) *Field {
return newField(name, TypeDatetime, opts)
}
// Binary creates a binary field. Mirrors: fields.Binary
func Binary(name string, opts FieldOpts) *Field {
return newField(name, TypeBinary, opts)
}
// Selection creates a selection field. Mirrors: fields.Selection
func Selection(name string, selection []SelectionItem, opts FieldOpts) *Field {
opts.Selection = selection
return newField(name, TypeSelection, opts)
}
// Json creates a JSON field. Mirrors: fields.Json
func Json(name string, opts FieldOpts) *Field {
return newField(name, TypeJson, opts)
}
// Many2one creates a many-to-one relational field. Mirrors: fields.Many2one
func Many2one(name string, comodel string, opts FieldOpts) *Field {
opts.Comodel = comodel
if opts.OnDelete == "" {
opts.OnDelete = OnDeleteSetNull
}
f := newField(name, TypeMany2one, opts)
f.Index = true // M2O fields are always indexed in Odoo
return f
}
// One2many creates a one-to-many relational field. Mirrors: fields.One2many
func One2many(name string, comodel string, inverseField string, opts FieldOpts) *Field {
opts.Comodel = comodel
opts.InverseField = inverseField
return newField(name, TypeOne2many, opts)
}
// Many2many creates a many-to-many relational field. Mirrors: fields.Many2many
func Many2many(name string, comodel string, opts FieldOpts) *Field {
opts.Comodel = comodel
return newField(name, TypeMany2many, opts)
}
// --- Default & Validation Helpers ---
// ResolveDefault returns the concrete default value for this field.
// Handles: literal values, "today" sentinel for date/datetime fields.
func (f *Field) ResolveDefault() Value {
if f.Default == nil {
return nil
}
switch v := f.Default.(type) {
case string:
if v == "today" && (f.Type == TypeDate || f.Type == TypeDatetime) {
return time.Now().Format("2006-01-02")
}
return v
case bool, int, int64, float64:
return v
default:
return v
}
}
// ApplyDefaults sets default values on vals for any stored field that is
// missing from vals and has a non-nil Default.
// Mirrors: odoo/orm/models.py BaseModel._add_missing_default_values()
func ApplyDefaults(m *Model, vals Values) {
for _, name := range m.fieldOrder {
f := m.fields[name]
if name == "id" || !f.IsStored() || f.Default == nil {
continue
}
if _, exists := vals[name]; exists {
continue
}
if resolved := f.ResolveDefault(); resolved != nil {
vals[name] = resolved
}
}
}
// ValidateRequired checks that all required stored fields have a non-nil value in vals.
// Returns an error describing the first missing required field.
// Mirrors: odoo/orm/models.py BaseModel._check_required()
func ValidateRequired(m *Model, vals Values, isCreate bool) error {
for _, name := range m.fieldOrder {
f := m.fields[name]
if !f.Required || !f.IsStored() || name == "id" {
continue
}
// Magic fields are auto-set
if name == "create_uid" || name == "write_uid" || name == "create_date" || name == "write_date" {
continue
}
if isCreate {
val, exists := vals[name]
if !exists || val == nil {
return fmt.Errorf("orm: field '%s' is required on %s", name, m.Name())
}
}
// On write: only check if the field is explicitly set to nil
if !isCreate {
val, exists := vals[name]
if exists && val == nil {
return fmt.Errorf("orm: field '%s' cannot be set to null on %s (required)", name, m.Name())
}
}
}
return nil
}
// JunctionTable returns the M2M junction table name for this field.
func (f *Field) JunctionTable() string {
if f.Relation != "" {
return f.Relation
}
if f.model == nil || f.Comodel == "" {
return ""
}
comodel := Registry.Get(f.Comodel)
if comodel == nil {
return ""
}
t1, t2 := f.model.Table(), comodel.Table()
if t1 > t2 {
t1, t2 = t2, t1
}
return fmt.Sprintf("%s_%s_rel", t1, t2)
}
// JunctionCol1 returns this model's FK column in the junction table.
func (f *Field) JunctionCol1() string {
if f.Column1 != "" {
return f.Column1
}
if f.model == nil {
return ""
}
col := f.model.Table() + "_id"
// Self-referential
comodel := Registry.Get(f.Comodel)
if comodel != nil && f.model.Table() == comodel.Table() {
col = f.model.Table() + "_src_id"
}
return col
}
// JunctionCol2 returns the comodel's FK column in the junction table.
func (f *Field) JunctionCol2() string {
if f.Column2 != "" {
return f.Column2
}
comodel := Registry.Get(f.Comodel)
if comodel == nil {
return ""
}
col := comodel.Table() + "_id"
if f.model != nil && f.model.Table() == comodel.Table() {
col = comodel.Table() + "_dst_id"
}
return col
}