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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Build output
build/
*.exe
*.test
*.out
# Go
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Docker volumes
db-data/
# Claude
.claude/

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM golang:1.22-bookworm AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /odoo-server ./cmd/odoo-server
FROM debian:bookworm-slim
RUN useradd -m -s /bin/bash odoo
COPY --from=builder /odoo-server /usr/local/bin/odoo-server
USER odoo
EXPOSE 8069
ENTRYPOINT ["odoo-server"]

View File

@@ -0,0 +1,204 @@
package models
import "odoo-go/pkg/orm"
// initAccountAccount registers the chart of accounts.
// Mirrors: odoo/addons/account/models/account_account.py
func initAccountAccount() {
// account.account — Chart of Accounts
m := orm.NewModel("account.account", orm.ModelOpts{
Description: "Account",
Order: "code, company_id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Account Name", Required: true, Index: true, Translate: true}),
orm.Char("code", orm.FieldOpts{String: "Code", Required: true, Size: 64, Index: true}),
orm.Selection("account_type", []orm.SelectionItem{
// Balance Sheet
{Value: "asset_receivable", Label: "Receivable"},
{Value: "asset_cash", Label: "Bank and Cash"},
{Value: "asset_current", Label: "Current Assets"},
{Value: "asset_non_current", Label: "Non-current Assets"},
{Value: "asset_prepayments", Label: "Prepayments"},
{Value: "asset_fixed", Label: "Fixed Assets"},
{Value: "liability_payable", Label: "Payable"},
{Value: "liability_credit_card", Label: "Credit Card"},
{Value: "liability_current", Label: "Current Liabilities"},
{Value: "liability_non_current", Label: "Non-current Liabilities"},
{Value: "equity", Label: "Equity"},
{Value: "equity_unaffected", Label: "Current Year Earnings"},
// P&L
{Value: "income", Label: "Income"},
{Value: "income_other", Label: "Other Income"},
{Value: "expense", Label: "Expenses"},
{Value: "expense_depreciation", Label: "Depreciation"},
{Value: "expense_direct_cost", Label: "Cost of Revenue"},
// Special
{Value: "off_balance", Label: "Off-Balance Sheet"},
}, orm.FieldOpts{String: "Type", Required: true}),
orm.Boolean("reconcile", orm.FieldOpts{String: "Allow Reconciliation"}),
orm.Boolean("non_trade", orm.FieldOpts{String: "Non-Trade Receivable/Payable"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Account Currency"}),
orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{String: "Default Taxes"}),
orm.Many2many("tag_ids", "account.account.tag", orm.FieldOpts{String: "Tags"}),
orm.Text("note", orm.FieldOpts{String: "Internal Notes"}),
orm.Boolean("deprecated", orm.FieldOpts{String: "Deprecated"}),
orm.Boolean("include_initial_balance", orm.FieldOpts{String: "Include Initial Balance"}),
)
// account.account.tag — Account tags for reporting
orm.NewModel("account.account.tag", orm.ModelOpts{
Description: "Account Tag",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Tag Name", Required: true, Translate: true}),
orm.Selection("applicability", []orm.SelectionItem{
{Value: "accounts", Label: "Accounts"},
{Value: "taxes", Label: "Taxes"},
{Value: "products", Label: "Products"},
}, orm.FieldOpts{String: "Applicability", Default: "accounts", Required: true}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Char("color", orm.FieldOpts{String: "Color Index"}),
)
// account.group — Account groups for hierarchy
orm.NewModel("account.group", orm.ModelOpts{
Description: "Account Group",
Order: "code_prefix_start",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Account Group", Required: true, Translate: true}),
orm.Char("code_prefix_start", orm.FieldOpts{String: "Code Prefix Start"}),
orm.Char("code_prefix_end", orm.FieldOpts{String: "Code Prefix End"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
)
}
// initAccountTax registers tax models.
// Mirrors: odoo/addons/account/models/account_tax.py
func initAccountTax() {
m := orm.NewModel("account.tax", orm.ModelOpts{
Description: "Tax",
Order: "sequence, id",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Tax Name", Required: true, Translate: true}),
orm.Selection("type_tax_use", []orm.SelectionItem{
{Value: "sale", Label: "Sales"},
{Value: "purchase", Label: "Purchases"},
{Value: "none", Label: "None"},
}, orm.FieldOpts{String: "Tax Type", Required: true, Default: "sale"}),
orm.Selection("amount_type", []orm.SelectionItem{
{Value: "group", Label: "Group of Taxes"},
{Value: "fixed", Label: "Fixed"},
{Value: "percent", Label: "Percentage of Price"},
{Value: "division", Label: "Percentage of Price Tax Included"},
}, orm.FieldOpts{String: "Tax Computation", Required: true, Default: "percent"}),
orm.Float("amount", orm.FieldOpts{String: "Amount", Required: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
orm.Boolean("price_include", orm.FieldOpts{String: "Included in Price"}),
orm.Boolean("include_base_amount", orm.FieldOpts{String: "Affect Base of Subsequent Taxes"}),
orm.Boolean("is_base_affected", orm.FieldOpts{String: "Base Affected by Previous Taxes", Default: true}),
orm.Many2many("children_tax_ids", "account.tax", orm.FieldOpts{String: "Children Taxes"}),
orm.Char("description", orm.FieldOpts{String: "Label on Invoices"}),
orm.Many2one("tax_group_id", "account.tax.group", orm.FieldOpts{String: "Tax Group"}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
// Repartition lines define how tax amounts are distributed to accounts
orm.One2many("invoice_repartition_line_ids", "account.tax.repartition.line", "tax_id", orm.FieldOpts{String: "Invoice Repartition"}),
orm.One2many("refund_repartition_line_ids", "account.tax.repartition.line", "tax_id", orm.FieldOpts{String: "Refund Repartition"}),
)
// account.tax.group — Tax group for reports
orm.NewModel("account.tax.group", orm.ModelOpts{
Description: "Tax Group",
Order: "sequence, name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
)
// account.tax.repartition.line — How tax is split across accounts
// Mirrors: odoo/addons/account/models/account_tax.py AccountTaxRepartitionLine
orm.NewModel("account.tax.repartition.line", orm.ModelOpts{
Description: "Tax Repartition Line",
Order: "sequence, id",
}).AddFields(
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
orm.Float("factor_percent", orm.FieldOpts{String: "Percentage", Default: 100, Required: true}),
orm.Selection("repartition_type", []orm.SelectionItem{
{Value: "base", Label: "Base"},
{Value: "tax", Label: "Tax"},
}, orm.FieldOpts{String: "Based On", Default: "tax", Required: true}),
orm.Many2one("account_id", "account.account", orm.FieldOpts{String: "Account"}),
orm.Many2one("tax_id", "account.tax", orm.FieldOpts{String: "Tax", OnDelete: orm.OnDeleteCascade}),
orm.Many2many("tag_ids", "account.account.tag", orm.FieldOpts{String: "Tax Grids"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Selection("document_type", []orm.SelectionItem{
{Value: "invoice", Label: "Invoice"},
{Value: "refund", Label: "Refund"},
}, orm.FieldOpts{String: "Document Type"}),
orm.Boolean("use_in_tax_closing", orm.FieldOpts{String: "Tax Closing Entry", Default: true}),
)
// account.fiscal.position — Tax mapping per country/partner
// Mirrors: odoo/addons/account/models/account_fiscal_position.py
initAccountFiscalPosition()
}
func initAccountFiscalPosition() {
m := orm.NewModel("account.fiscal.position", orm.ModelOpts{
Description: "Fiscal Position",
Order: "sequence",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Fiscal Position", Required: true, Translate: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
orm.One2many("tax_ids", "account.fiscal.position.tax", "position_id", orm.FieldOpts{String: "Tax Mapping"}),
orm.One2many("account_ids", "account.fiscal.position.account", "position_id", orm.FieldOpts{String: "Account Mapping"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
orm.Boolean("auto_apply", orm.FieldOpts{String: "Detect Automatically"}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
orm.Many2one("country_group_id", "res.country.group", orm.FieldOpts{String: "Country Group"}),
orm.Boolean("vat_required", orm.FieldOpts{String: "VAT required"}),
orm.Text("note", orm.FieldOpts{String: "Notes", Translate: true}),
)
// Fiscal position tax mapping
orm.NewModel("account.fiscal.position.tax", orm.ModelOpts{
Description: "Tax Mapping",
}).AddFields(
orm.Many2one("position_id", "account.fiscal.position", orm.FieldOpts{String: "Fiscal Position", Required: true, OnDelete: orm.OnDeleteCascade}),
orm.Many2one("tax_src_id", "account.tax", orm.FieldOpts{String: "Tax on Product", Required: true}),
orm.Many2one("tax_dest_id", "account.tax", orm.FieldOpts{String: "Tax to Apply"}),
)
// Fiscal position account mapping
orm.NewModel("account.fiscal.position.account", orm.ModelOpts{
Description: "Account Mapping",
}).AddFields(
orm.Many2one("position_id", "account.fiscal.position", orm.FieldOpts{String: "Fiscal Position", Required: true, OnDelete: orm.OnDeleteCascade}),
orm.Many2one("account_src_id", "account.account", orm.FieldOpts{String: "Account on Product", Required: true}),
orm.Many2one("account_dest_id", "account.account", orm.FieldOpts{String: "Account to Use", Required: true}),
)
// res.country.group — for fiscal position country grouping
orm.NewModel("res.country.group", orm.ModelOpts{
Description: "Country Group",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Many2many("country_ids", "res.country", orm.FieldOpts{String: "Countries"}),
)
}

View File

@@ -0,0 +1,568 @@
package models
import (
"fmt"
"odoo-go/pkg/orm"
)
// initAccountJournal registers account.journal — where entries are posted.
// Mirrors: odoo/addons/account/models/account_journal.py
func initAccountJournal() {
m := orm.NewModel("account.journal", orm.ModelOpts{
Description: "Journal",
Order: "sequence, type, code",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Journal Name", Required: true, Translate: true}),
orm.Char("code", orm.FieldOpts{String: "Short Code", Required: true, Size: 5}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Selection("type", []orm.SelectionItem{
{Value: "sale", Label: "Sales"},
{Value: "purchase", Label: "Purchase"},
{Value: "cash", Label: "Cash"},
{Value: "bank", Label: "Bank"},
{Value: "general", Label: "Miscellaneous"},
{Value: "credit", Label: "Credit Card"},
}, orm.FieldOpts{String: "Type", Required: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Many2one("default_account_id", "account.account", orm.FieldOpts{String: "Default Account"}),
orm.Many2one("suspense_account_id", "account.account", orm.FieldOpts{String: "Suspense Account"}),
orm.Many2one("profit_account_id", "account.account", orm.FieldOpts{String: "Profit Account"}),
orm.Many2one("loss_account_id", "account.account", orm.FieldOpts{String: "Loss Account"}),
orm.Many2one("sequence_id", "ir.sequence", orm.FieldOpts{String: "Entry Sequence"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Integer("color", orm.FieldOpts{String: "Color"}),
orm.Char("bank_acc_number", orm.FieldOpts{String: "Account Number"}),
orm.Many2one("bank_id", "res.bank", orm.FieldOpts{String: "Bank"}),
orm.Many2one("bank_account_id", "res.partner.bank", orm.FieldOpts{String: "Bank Account"}),
orm.Boolean("restrict_mode_hash_table", orm.FieldOpts{String: "Lock Posted Entries with Hash"}),
)
}
// initAccountMove registers account.move — the core journal entry / invoice model.
// Mirrors: odoo/addons/account/models/account_move.py
//
// account.move is THE central model in Odoo accounting. It represents:
// - Journal entries (manual bookkeeping)
// - Customer invoices / credit notes
// - Vendor bills / refunds
// - Receipts
func initAccountMove() {
m := orm.NewModel("account.move", orm.ModelOpts{
Description: "Journal Entry",
Order: "date desc, name desc, id desc",
RecName: "name",
})
// -- Identity --
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Number", Index: true, Readonly: true, Default: "/"}),
orm.Date("date", orm.FieldOpts{String: "Date", Required: true, Index: true}),
orm.Char("ref", orm.FieldOpts{String: "Reference"}),
)
// -- Type & State --
m.AddFields(
orm.Selection("move_type", []orm.SelectionItem{
{Value: "entry", Label: "Journal Entry"},
{Value: "out_invoice", Label: "Customer Invoice"},
{Value: "out_refund", Label: "Customer Credit Note"},
{Value: "in_invoice", Label: "Vendor Bill"},
{Value: "in_refund", Label: "Vendor Credit Note"},
{Value: "out_receipt", Label: "Sales Receipt"},
{Value: "in_receipt", Label: "Purchase Receipt"},
}, orm.FieldOpts{String: "Type", Required: true, Default: "entry", Index: true}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "posted", Label: "Posted"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}),
orm.Selection("payment_state", []orm.SelectionItem{
{Value: "not_paid", Label: "Not Paid"},
{Value: "in_payment", Label: "In Payment"},
{Value: "paid", Label: "Paid"},
{Value: "partial", Label: "Partially Paid"},
{Value: "reversed", Label: "Reversed"},
{Value: "blocked", Label: "Blocked"},
}, orm.FieldOpts{String: "Payment Status", Compute: "_compute_payment_state", Store: true}),
)
// -- Relationships --
m.AddFields(
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{
String: "Journal", Required: true, Index: true,
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
String: "Currency", Required: true,
}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Partner", Index: true,
}),
orm.Many2one("commercial_partner_id", "res.partner", orm.FieldOpts{
String: "Commercial Entity", Related: "partner_id.commercial_partner_id",
}),
orm.Many2one("fiscal_position_id", "account.fiscal.position", orm.FieldOpts{
String: "Fiscal Position",
}),
orm.Many2one("partner_bank_id", "res.partner.bank", orm.FieldOpts{
String: "Recipient Bank",
}),
)
// -- Lines --
m.AddFields(
orm.One2many("line_ids", "account.move.line", "move_id", orm.FieldOpts{
String: "Journal Items",
}),
orm.One2many("invoice_line_ids", "account.move.line", "move_id", orm.FieldOpts{
String: "Invoice Lines",
}),
)
// -- Amounts (Computed) --
m.AddFields(
orm.Monetary("amount_untaxed", orm.FieldOpts{String: "Untaxed Amount", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
orm.Monetary("amount_tax", orm.FieldOpts{String: "Tax", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
orm.Monetary("amount_total", orm.FieldOpts{String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
orm.Monetary("amount_residual", orm.FieldOpts{String: "Amount Due", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
orm.Monetary("amount_total_in_currency_signed", orm.FieldOpts{String: "Total in Currency Signed", Compute: "_compute_amount", Store: true}),
)
// -- Invoice specific --
m.AddFields(
orm.Date("invoice_date", orm.FieldOpts{String: "Invoice/Bill Date"}),
orm.Date("invoice_date_due", orm.FieldOpts{String: "Due Date"}),
orm.Char("invoice_origin", orm.FieldOpts{String: "Source Document"}),
orm.Many2one("invoice_payment_term_id", "account.payment.term", orm.FieldOpts{
String: "Payment Terms",
}),
orm.Text("narration", orm.FieldOpts{String: "Terms and Conditions"}),
)
// -- Technical --
m.AddFields(
orm.Boolean("auto_post", orm.FieldOpts{String: "Auto-post"}),
orm.Char("sequence_prefix", orm.FieldOpts{String: "Sequence Prefix"}),
orm.Integer("sequence_number", orm.FieldOpts{String: "Sequence Number"}),
)
// -- Computed Fields --
// _compute_amount: sums invoice lines to produce totals.
// Mirrors: odoo/addons/account/models/account_move.py AccountMove._compute_amount()
computeAmount := func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
moveID := rs.IDs()[0]
var untaxed, tax, total float64
rows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0)
FROM account_move_line WHERE move_id = $1`, moveID)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
var debitSum, creditSum float64
if err := rows.Scan(&debitSum, &creditSum); err != nil {
return nil, err
}
total = debitSum // For invoices, total = sum of debits (or credits)
if debitSum > creditSum {
total = debitSum
} else {
total = creditSum
}
// Tax lines have display_type='tax', product lines don't
untaxed = total // Simplified: full total as untaxed for now
}
rows.Close()
// Get actual tax amount from tax lines
err = env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(ABS(balance)), 0)
FROM account_move_line WHERE move_id = $1 AND display_type = 'tax'`,
moveID).Scan(&tax)
if err != nil {
tax = 0
}
if tax > 0 {
untaxed = total - tax
}
return orm.Values{
"amount_untaxed": untaxed,
"amount_tax": tax,
"amount_total": total,
"amount_residual": total, // Simplified: residual = total until payments
}, nil
}
m.RegisterCompute("amount_untaxed", computeAmount)
m.RegisterCompute("amount_tax", computeAmount)
m.RegisterCompute("amount_total", computeAmount)
m.RegisterCompute("amount_residual", computeAmount)
// -- Business Methods: State Transitions --
// Mirrors: odoo/addons/account/models/account_move.py action_post(), button_cancel()
// action_post: draft → posted
m.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM account_move WHERE id = $1`, id).Scan(&state)
if err != nil {
return nil, err
}
if state != "draft" {
return nil, fmt.Errorf("account: can only post draft entries (current: %s)", state)
}
// Check balanced
var debitSum, creditSum float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(debit),0), COALESCE(SUM(credit),0)
FROM account_move_line WHERE move_id = $1`, id).Scan(&debitSum, &creditSum)
diff := debitSum - creditSum
if diff < -0.005 || diff > 0.005 {
return nil, fmt.Errorf("account: cannot post unbalanced entry (debit=%.2f, credit=%.2f)", debitSum, creditSum)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE account_move SET state = 'posted' WHERE id = $1`, id); err != nil {
return nil, err
}
}
return true, nil
})
// button_cancel: posted → cancel (or draft → cancel)
m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE account_move SET state = 'cancel' WHERE id = $1`, id); err != nil {
return nil, err
}
}
return true, nil
})
// button_draft: cancel → draft
m.RegisterMethod("button_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM account_move WHERE id = $1`, id).Scan(&state)
if err != nil {
return nil, err
}
if state != "cancel" {
return nil, fmt.Errorf("account: can only reset cancelled entries to draft (current: %s)", state)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE account_move SET state = 'draft' WHERE id = $1`, id); err != nil {
return nil, err
}
}
return true, nil
})
// -- Double-Entry Constraint --
// SUM(debit) must equal SUM(credit) per journal entry.
// Mirrors: odoo/addons/account/models/account_move.py _check_balanced()
m.AddConstraint(func(rs *orm.Recordset) error {
env := rs.Env()
for _, id := range rs.IDs() {
var debitSum, creditSum float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0)
FROM account_move_line WHERE move_id = $1`, id,
).Scan(&debitSum, &creditSum)
if err != nil {
return err
}
// Allow empty moves (no lines yet)
if debitSum == 0 && creditSum == 0 {
continue
}
diff := debitSum - creditSum
if diff < -0.005 || diff > 0.005 {
return fmt.Errorf("account: journal entry is unbalanced — debit=%.2f credit=%.2f (diff=%.2f)", debitSum, creditSum, diff)
}
}
return nil
})
// -- BeforeCreate Hook: Generate sequence number --
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
name, _ := vals["name"].(string)
if name == "" || name == "/" {
moveType, _ := vals["move_type"].(string)
code := "account.move"
switch moveType {
case "out_invoice", "out_refund", "out_receipt":
code = "account.move.out_invoice"
case "in_invoice", "in_refund", "in_receipt":
code = "account.move.in_invoice"
}
seq, err := orm.NextByCode(env, code)
if err != nil {
// Fallback to generic sequence
seq, err = orm.NextByCode(env, "account.move")
if err != nil {
return nil // No sequence configured, keep "/"
}
}
vals["name"] = seq
}
return nil
}
}
// initAccountMoveLine registers account.move.line — journal items / invoice lines.
// Mirrors: odoo/addons/account/models/account_move_line.py
//
// CRITICAL: In double-entry bookkeeping, sum(debit) must equal sum(credit) per move.
func initAccountMoveLine() {
m := orm.NewModel("account.move.line", orm.ModelOpts{
Description: "Journal Item",
Order: "date desc, id",
})
// -- Parent --
m.AddFields(
orm.Many2one("move_id", "account.move", orm.FieldOpts{
String: "Journal Entry", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Char("move_name", orm.FieldOpts{String: "Journal Entry Name", Related: "move_id.name"}),
orm.Date("date", orm.FieldOpts{String: "Date", Related: "move_id.date", Store: true, Index: true}),
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Index: true}),
)
// -- Accounts --
m.AddFields(
orm.Many2one("account_id", "account.account", orm.FieldOpts{
String: "Account", Required: true, Index: true,
}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Partner", Index: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
orm.Many2one("company_currency_id", "res.currency", orm.FieldOpts{String: "Company Currency"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
)
// -- Amounts (Double-Entry) --
m.AddFields(
orm.Monetary("debit", orm.FieldOpts{String: "Debit", Default: 0.0, CurrencyField: "company_currency_id"}),
orm.Monetary("credit", orm.FieldOpts{String: "Credit", Default: 0.0, CurrencyField: "company_currency_id"}),
orm.Monetary("balance", orm.FieldOpts{String: "Balance", Compute: "_compute_balance", Store: true, CurrencyField: "company_currency_id"}),
orm.Monetary("amount_currency", orm.FieldOpts{String: "Amount in Currency", CurrencyField: "currency_id"}),
orm.Float("amount_residual", orm.FieldOpts{String: "Residual Amount"}),
orm.Float("amount_residual_currency", orm.FieldOpts{String: "Residual Amount in Currency"}),
)
// -- Invoice line fields --
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Label"}),
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1.0}),
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
orm.Float("price_subtotal", orm.FieldOpts{String: "Subtotal", Compute: "_compute_totals", Store: true}),
orm.Float("price_total", orm.FieldOpts{String: "Total", Compute: "_compute_totals", Store: true}),
)
// -- Tax --
m.AddFields(
orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{String: "Taxes"}),
orm.Many2one("tax_line_id", "account.tax", orm.FieldOpts{String: "Originator Tax"}),
orm.Many2one("tax_group_id", "account.tax.group", orm.FieldOpts{String: "Tax Group"}),
orm.Many2one("tax_repartition_line_id", "account.tax.repartition.line", orm.FieldOpts{
String: "Tax Repartition Line",
}),
)
// -- Display --
m.AddFields(
orm.Selection("display_type", []orm.SelectionItem{
{Value: "product", Label: "Product"},
{Value: "cogs", Label: "COGS"},
{Value: "tax", Label: "Tax"},
{Value: "rounding", Label: "Rounding"},
{Value: "payment_term", Label: "Payment Term"},
{Value: "line_section", Label: "Section"},
{Value: "line_note", Label: "Note"},
{Value: "epd", Label: "Early Payment Discount"},
}, orm.FieldOpts{String: "Display Type", Default: "product"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
// -- Reconciliation --
m.AddFields(
orm.Boolean("reconciled", orm.FieldOpts{String: "Reconciled"}),
orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Matching"}),
)
}
// initAccountPayment registers account.payment.
// Mirrors: odoo/addons/account/models/account_payment.py
func initAccountPayment() {
m := orm.NewModel("account.payment", orm.ModelOpts{
Description: "Payments",
Order: "date desc, name desc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Readonly: true}),
orm.Many2one("move_id", "account.move", orm.FieldOpts{
String: "Journal Entry", Required: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Selection("payment_type", []orm.SelectionItem{
{Value: "outbound", Label: "Send"},
{Value: "inbound", Label: "Receive"},
}, orm.FieldOpts{String: "Payment Type", Required: true}),
orm.Selection("partner_type", []orm.SelectionItem{
{Value: "customer", Label: "Customer"},
{Value: "supplier", Label: "Vendor"},
}, orm.FieldOpts{String: "Partner Type"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "in_process", Label: "In Process"},
{Value: "paid", Label: "Paid"},
{Value: "canceled", Label: "Cancelled"},
{Value: "rejected", Label: "Rejected"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true, CurrencyField: "currency_id"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency", Required: true}),
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer/Vendor"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
orm.Many2one("partner_bank_id", "res.partner.bank", orm.FieldOpts{String: "Recipient Bank Account"}),
orm.Many2one("destination_account_id", "account.account", orm.FieldOpts{String: "Destination Account"}),
orm.Boolean("is_reconciled", orm.FieldOpts{String: "Is Reconciled"}),
orm.Boolean("is_matched", orm.FieldOpts{String: "Is Matched With a Bank Statement"}),
orm.Char("payment_reference", orm.FieldOpts{String: "Payment Reference"}),
orm.Char("payment_method_code", orm.FieldOpts{String: "Payment Method Code"}),
)
}
// initAccountPaymentTerm registers payment terms.
// Mirrors: odoo/addons/account/models/account_payment_term.py
func initAccountPaymentTerm() {
m := orm.NewModel("account.payment.term", orm.ModelOpts{
Description: "Payment Terms",
Order: "sequence, id",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Payment Terms", Required: true, Translate: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Text("note", orm.FieldOpts{String: "Description on the Invoice", Translate: true}),
orm.One2many("line_ids", "account.payment.term.line", "payment_id", orm.FieldOpts{String: "Terms"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Selection("early_discount", []orm.SelectionItem{
{Value: "none", Label: "None"},
{Value: "mixed", Label: "On early payment"},
}, orm.FieldOpts{String: "Early Discount", Default: "none"}),
orm.Float("discount_percentage", orm.FieldOpts{String: "Discount %"}),
orm.Integer("discount_days", orm.FieldOpts{String: "Discount Days"}),
)
// Payment term lines — each line defines a portion
orm.NewModel("account.payment.term.line", orm.ModelOpts{
Description: "Payment Terms Line",
Order: "sequence, id",
}).AddFields(
orm.Many2one("payment_id", "account.payment.term", orm.FieldOpts{
String: "Payment Terms", Required: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Selection("value", []orm.SelectionItem{
{Value: "balance", Label: "Balance"},
{Value: "percent", Label: "Percent"},
{Value: "fixed", Label: "Fixed Amount"},
}, orm.FieldOpts{String: "Type", Required: true, Default: "balance"}),
orm.Float("value_amount", orm.FieldOpts{String: "Value"}),
orm.Integer("nb_days", orm.FieldOpts{String: "Days", Required: true, Default: 0}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
}
// initAccountReconcile registers reconciliation models.
// Mirrors: odoo/addons/account/models/account_reconcile_model.py
func initAccountReconcile() {
// Full reconcile — groups partial reconciles
orm.NewModel("account.full.reconcile", orm.ModelOpts{
Description: "Full Reconcile",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.One2many("partial_reconcile_ids", "account.partial.reconcile", "full_reconcile_id", orm.FieldOpts{String: "Reconciliation Parts"}),
orm.One2many("reconciled_line_ids", "account.move.line", "full_reconcile_id", orm.FieldOpts{String: "Matched Journal Items"}),
orm.Many2one("exchange_move_id", "account.move", orm.FieldOpts{String: "Exchange Rate Entry"}),
)
// Partial reconcile — matches debit ↔ credit lines
orm.NewModel("account.partial.reconcile", orm.ModelOpts{
Description: "Partial Reconcile",
}).AddFields(
orm.Many2one("debit_move_id", "account.move.line", orm.FieldOpts{String: "Debit line", Required: true, Index: true}),
orm.Many2one("credit_move_id", "account.move.line", orm.FieldOpts{String: "Credit line", Required: true, Index: true}),
orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Full Reconcile"}),
orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true}),
orm.Monetary("debit_amount_currency", orm.FieldOpts{String: "Debit Amount Currency"}),
orm.Monetary("credit_amount_currency", orm.FieldOpts{String: "Credit Amount Currency"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Many2one("debit_currency_id", "res.currency", orm.FieldOpts{String: "Debit Currency"}),
orm.Many2one("credit_currency_id", "res.currency", orm.FieldOpts{String: "Credit Currency"}),
orm.Many2one("exchange_move_id", "account.move", orm.FieldOpts{String: "Exchange Rate Entry"}),
)
}
// initAccountBankStatement registers bank statement models.
// Mirrors: odoo/addons/account/models/account_bank_statement.py
func initAccountBankStatement() {
m := orm.NewModel("account.bank.statement", orm.ModelOpts{
Description: "Bank Statement",
Order: "date desc, name desc, id desc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Reference"}),
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
orm.Float("balance_start", orm.FieldOpts{String: "Starting Balance"}),
orm.Float("balance_end_real", orm.FieldOpts{String: "Ending Balance"}),
orm.Float("balance_end", orm.FieldOpts{String: "Computed Balance", Compute: "_compute_balance_end"}),
orm.One2many("line_ids", "account.bank.statement.line", "statement_id", orm.FieldOpts{String: "Statement Lines"}),
)
// Bank statement line
orm.NewModel("account.bank.statement.line", orm.ModelOpts{
Description: "Bank Statement Line",
Order: "internal_index desc, sequence, id desc",
}).AddFields(
orm.Many2one("statement_id", "account.bank.statement", orm.FieldOpts{String: "Statement"}),
orm.Many2one("move_id", "account.move", orm.FieldOpts{String: "Journal Entry", Required: true}),
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Partner"}),
orm.Char("payment_ref", orm.FieldOpts{String: "Label"}),
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true, CurrencyField: "currency_id"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Char("transaction_type", orm.FieldOpts{String: "Transaction Type"}),
orm.Char("account_number", orm.FieldOpts{String: "Bank Account Number"}),
orm.Char("partner_name", orm.FieldOpts{String: "Partner Name"}),
orm.Char("narration", orm.FieldOpts{String: "Notes"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
orm.Char("internal_index", orm.FieldOpts{String: "Internal Index"}),
orm.Boolean("is_reconciled", orm.FieldOpts{String: "Is Reconciled"}),
)
}

View File

@@ -0,0 +1,14 @@
package models
func Init() {
initAccountAccount()
initAccountJournal()
initAccountTax()
initAccountMove()
initAccountMoveLine()
initAccountPayment()
initAccountPaymentTerm()
initAccountReconcile()
initAccountBankStatement()
initAccountFiscalPosition()
}

22
addons/account/module.go Normal file
View File

@@ -0,0 +1,22 @@
// Package account implements Odoo's accounting module.
// Mirrors: odoo/addons/account/__manifest__.py
package account
import (
"odoo-go/addons/account/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "account",
Description: "Invoicing & Accounting",
Version: "19.0.1.0.0",
Category: "Accounting/Accounting",
Depends: []string{"base"},
Application: true,
Installable: true,
Sequence: 10,
Init: models.Init,
})
}

View File

@@ -0,0 +1,16 @@
// Package models registers all base module models.
package models
// Init registers all models for the base module.
// Called by the module loader in dependency order.
func Init() {
initIrUI() // ir.ui.menu, ir.ui.view, ir.actions, ir.sequence, ir.attachment, report.paperformat
initResCurrency() // res.currency, res.country, res.country.state
initResCompany() // res.company (needs res.currency, res.country)
initResPartner() // res.partner, res.partner.category, res.partner.title, res.partner.bank, res.bank
initResUsers() // res.users, res.groups (needs res.partner, res.company)
initIrModel() // ir.model, ir.model.fields, ir.module.category
initIrModelAccess()
initIrRule()
initIrModelData()
}

View File

@@ -0,0 +1,175 @@
package models
import "odoo-go/pkg/orm"
// initIrModel registers ir.model and ir.model.fields — Odoo's model metadata.
// Mirrors: odoo/addons/base/models/ir_model.py
//
// These meta-models describe all other models at runtime.
// They are the foundation for dynamic field access, view generation, and access control.
func initIrModel() {
// ir.model — Model metadata
m := orm.NewModel("ir.model", orm.ModelOpts{
Description: "Models",
Order: "model",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Model Description", Required: true, Translate: true}),
orm.Char("model", orm.FieldOpts{String: "Model", Required: true, Index: true}),
orm.Char("info", orm.FieldOpts{String: "Information"}),
orm.One2many("field_id", "ir.model.fields", "model_id", orm.FieldOpts{String: "Fields"}),
orm.One2many("access_ids", "ir.model.access", "model_id", orm.FieldOpts{String: "Access"}),
orm.One2many("rule_ids", "ir.rule", "model_id", orm.FieldOpts{String: "Record Rules"}),
orm.Char("state", orm.FieldOpts{String: "Type", Default: "base"}),
orm.Boolean("transient", orm.FieldOpts{String: "Transient Model"}),
orm.Many2many("group_ids", "res.groups", orm.FieldOpts{String: "Groups"}),
)
// ir.model.fields — Field metadata
f := orm.NewModel("ir.model.fields", orm.ModelOpts{
Description: "Fields",
Order: "model_id, name",
RecName: "name",
})
f.AddFields(
orm.Char("name", orm.FieldOpts{String: "Field Name", Required: true, Index: true}),
orm.Char("field_description", orm.FieldOpts{String: "Field Label", Translate: true}),
orm.Many2one("model_id", "ir.model", orm.FieldOpts{
String: "Model", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Char("model", orm.FieldOpts{String: "Model Name", Related: "model_id.model"}),
orm.Selection("ttype", []orm.SelectionItem{
{Value: "char", Label: "Char"},
{Value: "text", Label: "Text"},
{Value: "html", Label: "Html"},
{Value: "integer", Label: "Integer"},
{Value: "float", Label: "Float"},
{Value: "monetary", Label: "Monetary"},
{Value: "boolean", Label: "Boolean"},
{Value: "date", Label: "Date"},
{Value: "datetime", Label: "Datetime"},
{Value: "binary", Label: "Binary"},
{Value: "selection", Label: "Selection"},
{Value: "many2one", Label: "Many2one"},
{Value: "one2many", Label: "One2many"},
{Value: "many2many", Label: "Many2many"},
{Value: "reference", Label: "Reference"},
}, orm.FieldOpts{String: "Field Type", Required: true}),
orm.Char("relation", orm.FieldOpts{String: "Related Model"}),
orm.Char("relation_field", orm.FieldOpts{String: "Relation Field"}),
orm.Boolean("required", orm.FieldOpts{String: "Required"}),
orm.Boolean("readonly", orm.FieldOpts{String: "Readonly"}),
orm.Boolean("index", orm.FieldOpts{String: "Indexed"}),
orm.Boolean("store", orm.FieldOpts{String: "Stored", Default: true}),
orm.Char("compute", orm.FieldOpts{String: "Compute"}),
orm.Char("depends", orm.FieldOpts{String: "Dependencies"}),
orm.Text("help", orm.FieldOpts{String: "Field Help"}),
orm.Char("state", orm.FieldOpts{String: "Type", Default: "base"}),
orm.Integer("size", orm.FieldOpts{String: "Size"}),
orm.Char("on_delete", orm.FieldOpts{String: "On Delete", Default: "set null"}),
orm.Boolean("translate", orm.FieldOpts{String: "Translatable"}),
orm.Text("selection_ids", orm.FieldOpts{String: "Selection Options"}),
orm.Char("copied", orm.FieldOpts{String: "Copied", Default: "1"}),
orm.Many2many("group_ids", "res.groups", orm.FieldOpts{String: "Groups"}),
)
// ir.module.category — Module categories for group organization
orm.NewModel("ir.module.category", orm.ModelOpts{
Description: "Application",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Many2one("parent_id", "ir.module.category", orm.FieldOpts{String: "Parent Application"}),
orm.One2many("child_ids", "ir.module.category", "parent_id", orm.FieldOpts{String: "Child Applications"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
orm.Boolean("visible", orm.FieldOpts{String: "Visible", Default: true}),
)
}
// initIrModelAccess registers ir.model.access — Object-level ACLs.
// Mirrors: odoo/addons/base/models/ir_model.py class IrModelAccess
//
// Access is defined as CSV in each module:
//
// id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
// access_res_partner,res.partner,model_res_partner,base.group_user,1,1,1,0
func initIrModelAccess() {
m := orm.NewModel("ir.model.access", orm.ModelOpts{
Description: "Access Controls",
Order: "model_id, group_id",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Index: true}),
orm.Many2one("model_id", "ir.model", orm.FieldOpts{
String: "Model", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Many2one("group_id", "res.groups", orm.FieldOpts{
String: "Group", OnDelete: orm.OnDeleteRestrict, Index: true,
}),
orm.Boolean("perm_read", orm.FieldOpts{String: "Read Access"}),
orm.Boolean("perm_write", orm.FieldOpts{String: "Write Access"}),
orm.Boolean("perm_create", orm.FieldOpts{String: "Create Access"}),
orm.Boolean("perm_unlink", orm.FieldOpts{String: "Delete Access"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
}
// initIrRule registers ir.rule — Record-level access rules.
// Mirrors: odoo/addons/base/models/ir_rule.py class IrRule
//
// Record rules add WHERE clause filters per user/group:
//
// Rule: domain = [('company_id', 'in', company_ids)]
// → User can only see records of their companies
func initIrRule() {
m := orm.NewModel("ir.rule", orm.ModelOpts{
Description: "Record Rule",
Order: "model_id, name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("model_id", "ir.model", orm.FieldOpts{
String: "Model", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Many2many("groups", "res.groups", orm.FieldOpts{String: "Groups"}),
orm.Text("domain_force", orm.FieldOpts{String: "Domain"}),
orm.Boolean("perm_read", orm.FieldOpts{String: "Apply for Read", Default: true}),
orm.Boolean("perm_write", orm.FieldOpts{String: "Apply for Write", Default: true}),
orm.Boolean("perm_create", orm.FieldOpts{String: "Apply for Create", Default: true}),
orm.Boolean("perm_unlink", orm.FieldOpts{String: "Apply for Delete", Default: true}),
orm.Boolean("global", orm.FieldOpts{
String: "Global",
Compute: "_compute_global",
Store: true,
Help: "If no group is specified, the rule is global and applied to everyone",
}),
)
}
// initIrModelData registers ir.model.data — External identifiers (XML IDs).
// Mirrors: odoo/addons/base/models/ir_model.py class IrModelData
//
// Maps module.xml_id → (model, res_id) for referencing records across modules.
// Example: 'base.main_company' → res.company(1)
func initIrModelData() {
m := orm.NewModel("ir.model.data", orm.ModelOpts{
Description: "External Identifiers",
Order: "module, name",
RecName: "complete_name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "External Identifier", Required: true, Index: true}),
orm.Char("complete_name", orm.FieldOpts{String: "Complete ID", Compute: "_compute_complete_name"}),
orm.Char("module", orm.FieldOpts{String: "Module", Required: true, Index: true, Default: ""}),
orm.Char("model", orm.FieldOpts{String: "Model Name", Required: true}),
orm.Integer("res_id", orm.FieldOpts{String: "Record ID", Index: true}),
orm.Boolean("noupdate", orm.FieldOpts{String: "Non Updatable", Default: false}),
)
}

228
addons/base/models/ir_ui.go Normal file
View File

@@ -0,0 +1,228 @@
package models
import "odoo-go/pkg/orm"
// initIrUI registers UI infrastructure models.
// Mirrors: odoo/addons/base/models/ir_ui_menu.py, ir_ui_view.py, ir_actions.py
func initIrUI() {
initIrUIMenu()
initIrUIView()
initIrActions()
initIrSequence()
initIrAttachment()
initReportPaperformat()
}
// initIrUIMenu registers ir.ui.menu — Application menu structure.
// Mirrors: odoo/addons/base/models/ir_ui_menu.py class IrUiMenu
func initIrUIMenu() {
m := orm.NewModel("ir.ui.menu", orm.ModelOpts{
Description: "Menu",
Order: "sequence, id",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Menu", Required: true, Translate: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Many2one("parent_id", "ir.ui.menu", orm.FieldOpts{String: "Parent Menu", Index: true}),
orm.One2many("child_id", "ir.ui.menu", "parent_id", orm.FieldOpts{String: "Child Menus"}),
orm.Char("complete_name", orm.FieldOpts{String: "Full Path", Compute: "_compute_complete_name"}),
orm.Many2many("groups_id", "res.groups", orm.FieldOpts{String: "Groups"}),
orm.Char("web_icon", orm.FieldOpts{String: "Web Icon File"}),
orm.Char("action", orm.FieldOpts{String: "Action"}),
)
}
// initIrUIView registers ir.ui.view — UI view definitions.
// Mirrors: odoo/addons/base/models/ir_ui_view.py class View
func initIrUIView() {
m := orm.NewModel("ir.ui.view", orm.ModelOpts{
Description: "View",
Order: "priority, name, id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "View Name", Required: true}),
orm.Selection("type", []orm.SelectionItem{
{Value: "tree", Label: "Tree"},
{Value: "form", Label: "Form"},
{Value: "graph", Label: "Graph"},
{Value: "pivot", Label: "Pivot"},
{Value: "calendar", Label: "Calendar"},
{Value: "gantt", Label: "Gantt"},
{Value: "kanban", Label: "Kanban"},
{Value: "search", Label: "Search"},
{Value: "qweb", Label: "QWeb"},
{Value: "list", Label: "List"},
{Value: "activity", Label: "Activity"},
}, orm.FieldOpts{String: "View Type"}),
orm.Char("model", orm.FieldOpts{String: "Model", Index: true}),
orm.Integer("priority", orm.FieldOpts{String: "Sequence", Default: 16}),
orm.Text("arch", orm.FieldOpts{String: "View Architecture"}),
orm.Text("arch_db", orm.FieldOpts{String: "Arch Blob", Translate: true}),
orm.Many2one("inherit_id", "ir.ui.view", orm.FieldOpts{String: "Inherited View"}),
orm.One2many("inherit_children_ids", "ir.ui.view", "inherit_id", orm.FieldOpts{String: "Views which inherit from this one"}),
orm.Char("key", orm.FieldOpts{String: "Key", Index: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Selection("mode", []orm.SelectionItem{
{Value: "primary", Label: "Base view"},
{Value: "extension", Label: "Extension View"},
}, orm.FieldOpts{String: "View inheritance mode", Default: "primary"}),
orm.Many2many("groups_id", "res.groups", orm.FieldOpts{String: "Groups"}),
)
}
// initIrActions registers ir.actions.* — Action definitions.
// Mirrors: odoo/addons/base/models/ir_actions.py
func initIrActions() {
// ir.actions.act_window — Window actions (open a view)
m := orm.NewModel("ir.actions.act_window", orm.ModelOpts{
Description: "Action Window",
Table: "ir_act_window",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Action Name", Required: true, Translate: true}),
orm.Char("type", orm.FieldOpts{String: "Action Type", Default: "ir.actions.act_window"}),
orm.Char("res_model", orm.FieldOpts{String: "Model", Required: true}),
orm.Selection("view_mode", []orm.SelectionItem{
{Value: "tree", Label: "Tree"},
{Value: "form", Label: "Form"},
{Value: "tree,form", Label: "Tree, Form"},
{Value: "form,tree", Label: "Form, Tree"},
{Value: "kanban", Label: "Kanban"},
{Value: "kanban,tree,form", Label: "Kanban, Tree, Form"},
}, orm.FieldOpts{String: "View Mode", Default: "tree,form"}),
orm.Integer("res_id", orm.FieldOpts{String: "Record ID"}),
orm.Char("domain", orm.FieldOpts{String: "Domain Value"}),
orm.Char("context", orm.FieldOpts{String: "Context Value", Default: "{}"}),
orm.Integer("limit", orm.FieldOpts{String: "Limit", Default: 80}),
orm.Char("search_view_id", orm.FieldOpts{String: "Search View Ref"}),
orm.Char("target", orm.FieldOpts{String: "Target Window", Default: "current"}),
orm.Boolean("auto_search", orm.FieldOpts{String: "Auto Search", Default: true}),
orm.Many2many("groups_id", "res.groups", orm.FieldOpts{String: "Groups"}),
orm.Char("help", orm.FieldOpts{String: "Action Description"}),
orm.Many2one("binding_model_id", "ir.model", orm.FieldOpts{String: "Binding Model"}),
)
// ir.actions.server — Server actions (execute Python/code)
srv := orm.NewModel("ir.actions.server", orm.ModelOpts{
Description: "Server Actions",
Table: "ir_act_server",
Order: "sequence, name",
})
srv.AddFields(
orm.Char("name", orm.FieldOpts{String: "Action Name", Required: true, Translate: true}),
orm.Char("type", orm.FieldOpts{String: "Action Type", Default: "ir.actions.server"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 5}),
orm.Many2one("model_id", "ir.model", orm.FieldOpts{String: "Model", Required: true, OnDelete: orm.OnDeleteCascade}),
orm.Char("model_name", orm.FieldOpts{String: "Model Name", Related: "model_id.model"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "code", Label: "Execute Code"},
{Value: "object_write", Label: "Update Record"},
{Value: "object_create", Label: "Create Record"},
{Value: "multi", Label: "Execute Several Actions"},
}, orm.FieldOpts{String: "Action To Do", Default: "code", Required: true}),
orm.Text("code", orm.FieldOpts{String: "Code"}),
orm.Many2many("groups_id", "res.groups", orm.FieldOpts{String: "Groups"}),
)
}
// initIrSequence registers ir.sequence — Automatic numbering.
// Mirrors: odoo/addons/base/models/ir_sequence.py
func initIrSequence() {
m := orm.NewModel("ir.sequence", orm.ModelOpts{
Description: "Sequence",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Char("code", orm.FieldOpts{String: "Sequence Code"}),
orm.Selection("implementation", []orm.SelectionItem{
{Value: "standard", Label: "Standard"},
{Value: "no_gap", Label: "No gap"},
}, orm.FieldOpts{String: "Implementation", Default: "standard", Required: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Char("prefix", orm.FieldOpts{String: "Prefix"}),
orm.Char("suffix", orm.FieldOpts{String: "Suffix"}),
orm.Integer("number_next", orm.FieldOpts{String: "Next Number", Default: 1, Required: true}),
orm.Integer("number_increment", orm.FieldOpts{String: "Step", Default: 1, Required: true}),
orm.Integer("padding", orm.FieldOpts{String: "Sequence Size", Default: 0, Required: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Boolean("use_date_range", orm.FieldOpts{String: "Use subsequences per date_range"}),
)
}
// initIrAttachment registers ir.attachment — File storage.
// Mirrors: odoo/addons/base/models/ir_attachment.py
func initIrAttachment() {
m := orm.NewModel("ir.attachment", orm.ModelOpts{
Description: "Attachment",
Order: "id desc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Char("description", orm.FieldOpts{String: "Description"}),
orm.Char("res_model", orm.FieldOpts{String: "Resource Model", Index: true}),
orm.Integer("res_id", orm.FieldOpts{String: "Resource ID", Index: true}),
orm.Char("res_field", orm.FieldOpts{String: "Resource Field"}),
orm.Char("res_name", orm.FieldOpts{String: "Resource Name"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Selection("type", []orm.SelectionItem{
{Value: "url", Label: "URL"},
{Value: "binary", Label: "File"},
}, orm.FieldOpts{String: "Type", Default: "binary", Required: true}),
orm.Char("url", orm.FieldOpts{String: "Url"}),
orm.Binary("datas", orm.FieldOpts{String: "File Content"}),
orm.Char("store_fname", orm.FieldOpts{String: "Stored Filename"}),
orm.Integer("file_size", orm.FieldOpts{String: "File Size"}),
orm.Char("checksum", orm.FieldOpts{String: "Checksum/SHA1", Size: 40, Index: true}),
orm.Char("mimetype", orm.FieldOpts{String: "Mime Type"}),
orm.Boolean("public", orm.FieldOpts{String: "Is public document"}),
)
}
// initReportPaperformat registers report.paperformat.
// Mirrors: odoo/addons/base/models/report_paperformat.py
func initReportPaperformat() {
m := orm.NewModel("report.paperformat", orm.ModelOpts{
Description: "Paper Format Config",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Boolean("default", orm.FieldOpts{String: "Default paper format"}),
orm.Selection("format", []orm.SelectionItem{
{Value: "A0", Label: "A0 (841 x 1189 mm)"},
{Value: "A1", Label: "A1 (594 x 841 mm)"},
{Value: "A2", Label: "A2 (420 x 594 mm)"},
{Value: "A3", Label: "A3 (297 x 420 mm)"},
{Value: "A4", Label: "A4 (210 x 297 mm)"},
{Value: "A5", Label: "A5 (148 x 210 mm)"},
{Value: "Legal", Label: "Legal (216 x 356 mm)"},
{Value: "Letter", Label: "Letter (216 x 279 mm)"},
{Value: "custom", Label: "Custom"},
}, orm.FieldOpts{String: "Paper size", Default: "A4"}),
orm.Integer("margin_top", orm.FieldOpts{String: "Top Margin (mm)", Default: 40}),
orm.Integer("margin_bottom", orm.FieldOpts{String: "Bottom Margin (mm)", Default: 20}),
orm.Integer("margin_left", orm.FieldOpts{String: "Left Margin (mm)", Default: 7}),
orm.Integer("margin_right", orm.FieldOpts{String: "Right Margin (mm)", Default: 7}),
orm.Integer("page_height", orm.FieldOpts{String: "Page height (mm)"}),
orm.Integer("page_width", orm.FieldOpts{String: "Page width (mm)"}),
orm.Selection("orientation", []orm.SelectionItem{
{Value: "Landscape", Label: "Landscape"},
{Value: "Portrait", Label: "Portrait"},
}, orm.FieldOpts{String: "Orientation", Default: "Portrait"}),
orm.Integer("header_spacing", orm.FieldOpts{String: "Header spacing (mm)"}),
orm.Integer("dpi", orm.FieldOpts{String: "Output DPI", Default: 90, Required: true}),
orm.Boolean("disable_shrinking", orm.FieldOpts{String: "Disable smart shrinking"}),
)
}

View File

@@ -0,0 +1,71 @@
package models
import "odoo-go/pkg/orm"
// initResCompany registers the res.company model.
// Mirrors: odoo/addons/base/models/res_company.py class Company
//
// In Odoo, companies are central to multi-company support.
// Every record can be scoped to a company via company_id.
func initResCompany() {
m := orm.NewModel("res.company", orm.ModelOpts{
Description: "Companies",
Order: "sequence, name",
})
// -- Identity --
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Company Name", Required: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("parent_id", "res.company", orm.FieldOpts{String: "Parent Company"}),
orm.One2many("child_ids", "res.company", "parent_id", orm.FieldOpts{String: "Child Companies"}),
)
// -- Contact (delegates to partner) --
// In Odoo: _inherits = {'res.partner': 'partner_id'}
// We use explicit fields instead of delegation for clarity.
m.AddFields(
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Partner", Required: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Char("street", orm.FieldOpts{String: "Street"}),
orm.Char("street2", orm.FieldOpts{String: "Street2"}),
orm.Char("zip", orm.FieldOpts{String: "Zip"}),
orm.Char("city", orm.FieldOpts{String: "City"}),
orm.Many2one("state_id", "res.country.state", orm.FieldOpts{String: "State"}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
orm.Char("email", orm.FieldOpts{String: "Email"}),
orm.Char("phone", orm.FieldOpts{String: "Phone"}),
orm.Char("mobile", orm.FieldOpts{String: "Mobile"}),
orm.Char("website", orm.FieldOpts{String: "Website"}),
orm.Char("vat", orm.FieldOpts{String: "Tax ID"}),
orm.Char("company_registry", orm.FieldOpts{String: "Company ID (Registry)"}),
)
// -- Currency --
m.AddFields(
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
String: "Currency", Required: true,
}),
)
// -- Display --
m.AddFields(
orm.Binary("logo", orm.FieldOpts{String: "Company Logo"}),
orm.Char("color", orm.FieldOpts{String: "Color"}),
orm.Integer("font_color", orm.FieldOpts{String: "Font Color"}),
)
// -- Report Layout --
m.AddFields(
orm.Many2one("paperformat_id", "report.paperformat", orm.FieldOpts{String: "Paper Format"}),
orm.HTML("report_header", orm.FieldOpts{String: "Company Tagline"}),
orm.HTML("report_footer", orm.FieldOpts{String: "Report Footer"}),
)
// -- Fiscal --
m.AddFields(
orm.Many2one("account_fiscal_country_id", "res.country", orm.FieldOpts{String: "Fiscal Country"}),
)
}

View File

@@ -0,0 +1,79 @@
package models
import "odoo-go/pkg/orm"
// initResCurrency registers currency models.
// Mirrors: odoo/addons/base/models/res_currency.py
func initResCurrency() {
// res.currency — Currency definition
m := orm.NewModel("res.currency", orm.ModelOpts{
Description: "Currency",
Order: "name",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Currency", Required: true, Size: 3}),
orm.Char("full_name", orm.FieldOpts{String: "Name"}),
orm.Char("symbol", orm.FieldOpts{String: "Symbol", Required: true, Size: 4}),
orm.Integer("decimal_places", orm.FieldOpts{String: "Decimal Places"}),
orm.Float("rounding", orm.FieldOpts{String: "Rounding Factor"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Selection("position", []orm.SelectionItem{
{Value: "after", Label: "After Amount"},
{Value: "before", Label: "Before Amount"},
}, orm.FieldOpts{String: "Symbol Position", Default: "after"}),
orm.Float("rate", orm.FieldOpts{String: "Current Rate", Compute: "_compute_current_rate"}),
orm.One2many("rate_ids", "res.currency.rate", "currency_id", orm.FieldOpts{String: "Rates"}),
)
// res.currency.rate — Exchange rates
rate := orm.NewModel("res.currency.rate", orm.ModelOpts{
Description: "Currency Rate",
Order: "name desc",
RecName: "name",
})
rate.AddFields(
orm.Date("name", orm.FieldOpts{String: "Date", Required: true, Index: true, Default: "today"}),
orm.Float("rate", orm.FieldOpts{String: "Rate", Required: true}),
orm.Float("inverse_company_rate", orm.FieldOpts{String: "Inverse Rate"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
String: "Currency", Required: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
)
// res.country — Country
// Mirrors: odoo/addons/base/models/res_country.py
country := orm.NewModel("res.country", orm.ModelOpts{
Description: "Country",
Order: "name",
})
country.AddFields(
orm.Char("name", orm.FieldOpts{String: "Country Name", Required: true, Translate: true}),
orm.Char("code", orm.FieldOpts{String: "Country Code", Size: 2, Required: true}),
orm.Char("phone_code", orm.FieldOpts{String: "Country Calling Code"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Binary("image", orm.FieldOpts{String: "Flag"}),
orm.One2many("state_ids", "res.country.state", "country_id", orm.FieldOpts{String: "States"}),
orm.Char("address_format", orm.FieldOpts{String: "Layout in Reports"}),
orm.Char("vat_label", orm.FieldOpts{String: "Vat Label", Translate: true}),
)
// res.country.state — Country state/province
state := orm.NewModel("res.country.state", orm.ModelOpts{
Description: "Country state",
Order: "code",
})
state.AddFields(
orm.Char("name", orm.FieldOpts{String: "State Name", Required: true}),
orm.Char("code", orm.FieldOpts{String: "State Code", Required: true, Size: 3}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{
String: "Country", Required: true, OnDelete: orm.OnDeleteCascade,
}),
)
}

View File

@@ -0,0 +1,158 @@
package models
import "odoo-go/pkg/orm"
// initResPartner registers the res.partner model.
// Mirrors: odoo/addons/base/models/res_partner.py class Partner(models.Model)
//
// res.partner is the central contact model in Odoo. It stores:
// - Companies and individuals
// - Customers, vendors, employees (via type)
// - Addresses (street, city, zip, country)
// - Communication (email, phone, website)
func initResPartner() {
m := orm.NewModel("res.partner", orm.ModelOpts{
Description: "Contact",
Order: "name, id",
RecName: "name",
})
// -- Identity --
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Index: true}),
orm.Char("display_name", orm.FieldOpts{String: "Display Name", Compute: "_compute_display_name", Store: true}),
orm.Char("ref", orm.FieldOpts{String: "Reference", Index: true}),
orm.Selection("type", []orm.SelectionItem{
{Value: "contact", Label: "Contact"},
{Value: "invoice", Label: "Invoice Address"},
{Value: "delivery", Label: "Delivery Address"},
{Value: "other", Label: "Other Address"},
{Value: "private", Label: "Private Address"},
}, orm.FieldOpts{String: "Address Type", Default: "contact"}),
orm.Boolean("is_company", orm.FieldOpts{String: "Is a Company", Default: false}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Char("lang", orm.FieldOpts{String: "Language", Default: "en_US"}),
orm.Char("tz", orm.FieldOpts{String: "Timezone"}),
)
// -- Address --
m.AddFields(
orm.Char("street", orm.FieldOpts{String: "Street"}),
orm.Char("street2", orm.FieldOpts{String: "Street2"}),
orm.Char("zip", orm.FieldOpts{String: "Zip"}),
orm.Char("city", orm.FieldOpts{String: "City"}),
orm.Many2one("state_id", "res.country.state", orm.FieldOpts{String: "State"}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
)
// -- Communication --
m.AddFields(
orm.Char("email", orm.FieldOpts{String: "Email"}),
orm.Char("phone", orm.FieldOpts{String: "Phone"}),
orm.Char("mobile", orm.FieldOpts{String: "Mobile"}),
orm.Char("website", orm.FieldOpts{String: "Website"}),
orm.Char("vat", orm.FieldOpts{String: "Tax ID", Index: true}),
)
// -- Company --
m.AddFields(
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Index: true}),
orm.Char("company_name", orm.FieldOpts{String: "Company Name"}),
orm.Char("company_registry", orm.FieldOpts{String: "Company Registry"}),
)
// -- Relationships --
m.AddFields(
orm.Many2one("parent_id", "res.partner", orm.FieldOpts{String: "Related Company", Index: true}),
orm.One2many("child_ids", "res.partner", "parent_id", orm.FieldOpts{String: "Contact & Addresses"}),
orm.Many2many("category_ids", "res.partner.category", orm.FieldOpts{String: "Tags"}),
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Salesperson"}),
orm.Many2one("title", "res.partner.title", orm.FieldOpts{String: "Title"}),
)
// -- Commercial --
m.AddFields(
orm.Many2one("commercial_partner_id", "res.partner", orm.FieldOpts{
String: "Commercial Entity", Compute: "_compute_commercial_partner", Store: true,
}),
orm.Selection("customer_rank", []orm.SelectionItem{
{Value: "0", Label: "None"},
{Value: "1", Label: "Customer"},
}, orm.FieldOpts{String: "Customer Rank", Default: "0"}),
orm.Selection("supplier_rank", []orm.SelectionItem{
{Value: "0", Label: "None"},
{Value: "1", Label: "Vendor"},
}, orm.FieldOpts{String: "Vendor Rank", Default: "0"}),
)
// -- Banking --
m.AddFields(
orm.One2many("bank_ids", "res.partner.bank", "partner_id", orm.FieldOpts{String: "Bank Accounts"}),
)
// -- Notes --
m.AddFields(
orm.Text("comment", orm.FieldOpts{String: "Notes"}),
orm.Binary("image_1920", orm.FieldOpts{String: "Image"}),
)
// --- Supporting models ---
// res.partner.category — Contact tags
// Mirrors: odoo/addons/base/models/res_partner.py class PartnerCategory
cat := orm.NewModel("res.partner.category", orm.ModelOpts{
Description: "Contact Tag",
Order: "name",
})
cat.AddFields(
orm.Char("name", orm.FieldOpts{String: "Tag Name", Required: true}),
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
orm.Many2one("parent_id", "res.partner.category", orm.FieldOpts{String: "Parent Category"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
// res.partner.title — Contact titles (Mr., Mrs., etc.)
// Mirrors: odoo/addons/base/models/res_partner.py class PartnerTitle
title := orm.NewModel("res.partner.title", orm.ModelOpts{
Description: "Partner Title",
Order: "name",
})
title.AddFields(
orm.Char("name", orm.FieldOpts{String: "Title", Required: true, Translate: true}),
orm.Char("shortcut", orm.FieldOpts{String: "Abbreviation", Translate: true}),
)
// res.partner.bank — Bank accounts
// Mirrors: odoo/addons/base/models/res_bank.py class ResPartnerBank
bank := orm.NewModel("res.partner.bank", orm.ModelOpts{
Description: "Bank Accounts",
Order: "id",
RecName: "acc_number",
})
bank.AddFields(
orm.Char("acc_number", orm.FieldOpts{String: "Account Number", Required: true}),
orm.Char("sanitized_acc_number", orm.FieldOpts{String: "Sanitized Account Number", Compute: "_compute_sanitized"}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Account Holder", Required: true, OnDelete: orm.OnDeleteCascade}),
orm.Many2one("bank_id", "res.bank", orm.FieldOpts{String: "Bank"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Char("acc_holder_name", orm.FieldOpts{String: "Account Holder Name"}),
)
// res.bank — Bank directory
resBank := orm.NewModel("res.bank", orm.ModelOpts{
Description: "Bank",
Order: "name",
})
resBank.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Char("bic", orm.FieldOpts{String: "Bank Identifier Code", Index: true}),
orm.Char("street", orm.FieldOpts{String: "Street"}),
orm.Char("street2", orm.FieldOpts{String: "Street2"}),
orm.Char("zip", orm.FieldOpts{String: "Zip"}),
orm.Char("city", orm.FieldOpts{String: "City"}),
orm.Many2one("country", "res.country", orm.FieldOpts{String: "Country"}),
orm.Char("email", orm.FieldOpts{String: "Email"}),
orm.Char("phone", orm.FieldOpts{String: "Phone"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
}

View File

@@ -0,0 +1,101 @@
package models
import "odoo-go/pkg/orm"
// initResUsers registers the res.users model.
// Mirrors: odoo/addons/base/models/res_users.py class Users
//
// In Odoo, res.users inherits from res.partner via _inherits.
// Every user has a linked partner record for contact info.
func initResUsers() {
m := orm.NewModel("res.users", orm.ModelOpts{
Description: "Users",
Order: "login",
})
// -- Authentication --
m.AddFields(
orm.Char("login", orm.FieldOpts{String: "Login", Required: true, Index: true}),
orm.Char("password", orm.FieldOpts{String: "Password"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
// -- Partner link (Odoo: _inherits = {'res.partner': 'partner_id'}) --
m.AddFields(
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Related Partner", Required: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Char("name", orm.FieldOpts{String: "Name", Related: "partner_id.name"}),
orm.Char("email", orm.FieldOpts{String: "Email", Related: "partner_id.email"}),
)
// -- Company --
m.AddFields(
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2many("company_ids", "res.company", orm.FieldOpts{String: "Allowed Companies"}),
)
// -- Groups / Permissions --
m.AddFields(
orm.Many2many("groups_id", "res.groups", orm.FieldOpts{String: "Groups"}),
)
// -- Preferences --
m.AddFields(
orm.Char("lang", orm.FieldOpts{String: "Language", Default: "en_US"}),
orm.Char("tz", orm.FieldOpts{String: "Timezone", Default: "UTC"}),
orm.Selection("notification_type", []orm.SelectionItem{
{Value: "email", Label: "Handle by Emails"},
{Value: "inbox", Label: "Handle in Odoo"},
}, orm.FieldOpts{String: "Notification", Default: "email"}),
orm.Binary("image_1920", orm.FieldOpts{String: "Avatar"}),
orm.Char("signature", orm.FieldOpts{String: "Email Signature"}),
)
// -- Status --
m.AddFields(
orm.Boolean("share", orm.FieldOpts{
String: "Share User", Compute: "_compute_share", Store: true,
Help: "External user with limited access (portal/public)",
}),
)
}
// initResGroups registers the res.groups model.
// Mirrors: odoo/addons/base/models/res_users.py class Groups
//
// Groups define permission sets. Users belong to groups.
// Groups can imply other groups (hierarchy).
func initResGroups() {
m := orm.NewModel("res.groups", orm.ModelOpts{
Description: "Access Groups",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Text("comment", orm.FieldOpts{String: "Comment"}),
orm.Many2one("category_id", "ir.module.category", orm.FieldOpts{String: "Application"}),
orm.Char("color", orm.FieldOpts{String: "Color Index"}),
orm.Char("full_name", orm.FieldOpts{String: "Group Name", Compute: "_compute_full_name"}),
orm.Boolean("share", orm.FieldOpts{String: "Share Group", Default: false}),
)
// -- Relationships --
m.AddFields(
orm.Many2many("users", "res.users", orm.FieldOpts{String: "Users"}),
orm.Many2many("implied_ids", "res.groups", orm.FieldOpts{
String: "Inherits",
Help: "Users of this group automatically inherit those groups",
}),
)
// -- Access Control --
m.AddFields(
orm.One2many("model_access", "ir.model.access", "group_id", orm.FieldOpts{String: "Access Controls"}),
orm.One2many("rule_groups", "ir.rule", "group_id", orm.FieldOpts{String: "Rules"}),
orm.Many2many("menu_access", "ir.ui.menu", orm.FieldOpts{String: "Access Menu"}),
)
}

33
addons/base/module.go Normal file
View File

@@ -0,0 +1,33 @@
// Package base implements the 'base' module — the foundation of Odoo.
// Mirrors: odoo/addons/base/__manifest__.py
//
// The base module provides core models that every other module depends on:
// - res.partner (contacts)
// - res.users (system users)
// - res.company (companies)
// - res.currency (currencies)
// - ir.module.module (installed modules)
// - ir.model (model metadata)
// - ir.model.access (access control)
// - ir.rule (record rules)
// - ir.model.data (external identifiers)
package base
import (
"odoo-go/addons/base/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "base",
Description: "Base module — core models and infrastructure",
Version: "19.0.1.0.0",
Category: "Hidden",
Depends: nil, // base has no dependencies
Application: false,
Installable: true,
Sequence: 0,
Init: models.Init,
})
}

131
addons/crm/models/crm.go Normal file
View File

@@ -0,0 +1,131 @@
package models
import "odoo-go/pkg/orm"
// initCRMLead registers the crm.lead model.
// Mirrors: odoo/addons/crm/models/crm_lead.py
func initCRMLead() {
m := orm.NewModel("crm.lead", orm.ModelOpts{
Description: "Lead/Opportunity",
Order: "priority desc, id desc",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Opportunity", Required: true, Index: true}),
orm.Selection("type", []orm.SelectionItem{
{Value: "lead", Label: "Lead"},
{Value: "opportunity", Label: "Opportunity"},
}, orm.FieldOpts{String: "Type", Required: true, Default: "lead", Index: true}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer", Index: true}),
orm.Char("partner_name", orm.FieldOpts{String: "Company Name"}),
orm.Char("email_from", orm.FieldOpts{String: "Email", Index: true}),
orm.Char("phone", orm.FieldOpts{String: "Phone"}),
orm.Char("website", orm.FieldOpts{String: "Website"}),
orm.Char("function", orm.FieldOpts{String: "Job Position"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "open", Label: "Open"},
{Value: "won", Label: "Won"},
{Value: "lost", Label: "Lost"},
}, orm.FieldOpts{String: "Status", Default: "open"}),
orm.Many2one("stage_id", "crm.stage", orm.FieldOpts{String: "Stage", Index: true}),
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Salesperson", Index: true}),
orm.Many2one("team_id", "crm.team", orm.FieldOpts{String: "Sales Team", Index: true}),
orm.Monetary("expected_revenue", orm.FieldOpts{
String: "Expected Revenue", CurrencyField: "currency_id",
}),
orm.Monetary("recurring_revenue", orm.FieldOpts{
String: "Recurring Revenue", CurrencyField: "currency_id",
}),
orm.Selection("recurring_plan", []orm.SelectionItem{
{Value: "monthly", Label: "Monthly"},
{Value: "quarterly", Label: "Quarterly"},
{Value: "yearly", Label: "Yearly"},
}, orm.FieldOpts{String: "Recurring Plan"}),
orm.Date("date_deadline", orm.FieldOpts{String: "Expected Closing"}),
orm.Datetime("date_last_stage_update", orm.FieldOpts{String: "Last Stage Update"}),
orm.Selection("priority", []orm.SelectionItem{
{Value: "0", Label: "Normal"},
{Value: "1", Label: "Low"},
{Value: "2", Label: "High"},
{Value: "3", Label: "Very High"},
}, orm.FieldOpts{String: "Priority", Default: "0"}),
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
orm.Many2many("tag_ids", "crm.tag", orm.FieldOpts{String: "Tags"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Float("probability", orm.FieldOpts{String: "Probability (%)"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Text("description", orm.FieldOpts{String: "Notes"}),
orm.Many2one("lost_reason_id", "crm.lost.reason", orm.FieldOpts{String: "Lost Reason"}),
// Address fields
orm.Char("city", orm.FieldOpts{String: "City"}),
orm.Char("street", orm.FieldOpts{String: "Street"}),
orm.Char("zip", orm.FieldOpts{String: "Zip"}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
)
}
// initCRMStage registers the crm.stage model.
// Mirrors: odoo/addons/crm/models/crm_stage.py
func initCRMStage() {
m := orm.NewModel("crm.stage", orm.ModelOpts{
Description: "CRM Stage",
Order: "sequence, name, id",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Stage Name", Required: true, Translate: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
orm.Boolean("fold", orm.FieldOpts{String: "Folded in Pipeline"}),
orm.Boolean("is_won", orm.FieldOpts{String: "Is Won Stage"}),
orm.Many2many("team_ids", "crm.team", orm.FieldOpts{String: "Sales Teams"}),
orm.Text("requirements", orm.FieldOpts{String: "Requirements"}),
)
}
// initCRMTeam registers the crm.team model.
// Mirrors: odoo/addons/crm/models/crm_team.py
func initCRMTeam() {
m := orm.NewModel("crm.team", orm.ModelOpts{
Description: "Sales Team",
Order: "sequence, name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Sales Team", Required: true, Translate: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Index: true,
}),
orm.Many2many("member_ids", "res.users", orm.FieldOpts{String: "Channel Members"}),
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
}
// initCRMTag registers the crm.tag model.
// Mirrors: odoo/addons/crm/models/crm_lead.py CrmTag
func initCRMTag() {
orm.NewModel("crm.tag", orm.ModelOpts{
Description: "CRM Tag",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Tag Name", Required: true, Translate: true}),
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
)
}
// initCRMLostReason registers the crm.lost.reason model.
// Mirrors: odoo/addons/crm/models/crm_lost_reason.py
func initCRMLostReason() {
orm.NewModel("crm.lost.reason", orm.ModelOpts{
Description: "Opp. Lost Reason",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Description", Required: true, Translate: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
}

View File

@@ -0,0 +1,9 @@
package models
func Init() {
initCRMTag()
initCRMLostReason()
initCRMTeam()
initCRMStage()
initCRMLead()
}

22
addons/crm/module.go Normal file
View File

@@ -0,0 +1,22 @@
// Package crm implements Odoo's Customer Relationship Management module.
// Mirrors: odoo/addons/crm/__manifest__.py
package crm
import (
"odoo-go/addons/crm/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "crm",
Description: "CRM",
Version: "19.0.1.0.0",
Category: "Sales/CRM",
Depends: []string{"base"},
Application: true,
Installable: true,
Sequence: 15,
Init: models.Init,
})
}

View File

@@ -0,0 +1,222 @@
package models
import "odoo-go/pkg/orm"
// initFleetVehicle registers the fleet.vehicle model.
// Mirrors: odoo/addons/fleet/models/fleet_vehicle.py
func initFleetVehicle() {
m := orm.NewModel("fleet.vehicle", orm.ModelOpts{
Description: "Vehicle",
Order: "license_plate asc, name asc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Compute: "_compute_vehicle_name", Store: true}),
orm.Char("license_plate", orm.FieldOpts{String: "License Plate", Required: true, Index: true}),
orm.Char("vin_sn", orm.FieldOpts{String: "Chassis Number", Help: "Unique vehicle identification number (VIN)"}),
orm.Many2one("driver_id", "res.partner", orm.FieldOpts{String: "Driver", Index: true}),
orm.Many2one("future_driver_id", "res.partner", orm.FieldOpts{String: "Future Driver"}),
orm.Many2one("model_id", "fleet.vehicle.model", orm.FieldOpts{
String: "Model", Required: true,
}),
orm.Many2one("brand_id", "fleet.vehicle.model.brand", orm.FieldOpts{
String: "Brand", Related: "model_id.brand_id", Store: true,
}),
orm.Many2one("state_id", "fleet.vehicle.state", orm.FieldOpts{String: "State"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("manager_id", "res.users", orm.FieldOpts{String: "Fleet Manager"}),
orm.Char("color", orm.FieldOpts{String: "Color"}),
orm.Integer("seats", orm.FieldOpts{String: "Seats Number"}),
orm.Integer("doors", orm.FieldOpts{String: "Doors Number", Default: 5}),
orm.Selection("transmission", []orm.SelectionItem{
{Value: "manual", Label: "Manual"},
{Value: "automatic", Label: "Automatic"},
}, orm.FieldOpts{String: "Transmission"}),
orm.Selection("fuel_type", []orm.SelectionItem{
{Value: "gasoline", Label: "Gasoline"},
{Value: "diesel", Label: "Diesel"},
{Value: "lpg", Label: "LPG"},
{Value: "electric", Label: "Electric"},
{Value: "hybrid", Label: "Hybrid"},
}, orm.FieldOpts{String: "Fuel Type"}),
orm.Integer("power", orm.FieldOpts{String: "Power (kW)"}),
orm.Integer("horsepower", orm.FieldOpts{String: "Horsepower"}),
orm.Float("co2", orm.FieldOpts{String: "CO2 Emissions (g/km)"}),
orm.Float("horsepower_tax", orm.FieldOpts{String: "Horsepower Taxation"}),
orm.Float("odometer", orm.FieldOpts{String: "Last Odometer", Compute: "_compute_odometer"}),
orm.Selection("odometer_unit", []orm.SelectionItem{
{Value: "kilometers", Label: "km"},
{Value: "miles", Label: "mi"},
}, orm.FieldOpts{String: "Odometer Unit", Default: "kilometers", Required: true}),
orm.Date("acquisition_date", orm.FieldOpts{String: "Immatriculation Date"}),
orm.Date("first_contract_date", orm.FieldOpts{String: "First Contract Date"}),
orm.Many2many("tag_ids", "fleet.vehicle.tag", orm.FieldOpts{String: "Tags"}),
orm.One2many("log_contracts", "fleet.vehicle.log.contract", "vehicle_id", orm.FieldOpts{String: "Contracts"}),
orm.One2many("log_services", "fleet.vehicle.log.services", "vehicle_id", orm.FieldOpts{String: "Services"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
}
// initFleetVehicleModel registers the fleet.vehicle.model model.
// Mirrors: odoo/addons/fleet/models/fleet_vehicle_model.py
func initFleetVehicleModel() {
m := orm.NewModel("fleet.vehicle.model", orm.ModelOpts{
Description: "Model of a vehicle",
Order: "name asc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Model Name", Required: true}),
orm.Many2one("brand_id", "fleet.vehicle.model.brand", orm.FieldOpts{
String: "Manufacturer", Required: true, Index: true,
}),
orm.Many2one("category_id", "fleet.vehicle.model.category", orm.FieldOpts{String: "Category"}),
orm.Integer("seats", orm.FieldOpts{String: "Seats Number"}),
orm.Integer("doors", orm.FieldOpts{String: "Doors Number", Default: 5}),
orm.Char("color", orm.FieldOpts{String: "Color"}),
orm.Selection("transmission", []orm.SelectionItem{
{Value: "manual", Label: "Manual"},
{Value: "automatic", Label: "Automatic"},
}, orm.FieldOpts{String: "Transmission"}),
orm.Selection("fuel_type", []orm.SelectionItem{
{Value: "gasoline", Label: "Gasoline"},
{Value: "diesel", Label: "Diesel"},
{Value: "lpg", Label: "LPG"},
{Value: "electric", Label: "Electric"},
{Value: "hybrid", Label: "Hybrid"},
}, orm.FieldOpts{String: "Fuel Type"}),
orm.Integer("power", orm.FieldOpts{String: "Power (kW)"}),
orm.Integer("horsepower", orm.FieldOpts{String: "Horsepower"}),
orm.Float("co2", orm.FieldOpts{String: "CO2 Emissions (g/km)"}),
orm.Binary("image_128", orm.FieldOpts{String: "Image"}),
)
}
// initFleetVehicleModelBrand registers the fleet.vehicle.model.brand model.
// Mirrors: odoo/addons/fleet/models/fleet_vehicle_model.py FleetVehicleModelBrand
func initFleetVehicleModelBrand() {
orm.NewModel("fleet.vehicle.model.brand", orm.ModelOpts{
Description: "Brand of the vehicle",
Order: "name asc",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Make", Required: true}),
orm.Binary("image_128", orm.FieldOpts{String: "Image"}),
)
}
// initFleetVehicleModelCategory registers the fleet.vehicle.model.category model.
// Mirrors: odoo/addons/fleet/models/fleet_vehicle_model.py FleetVehicleModelCategory
func initFleetVehicleModelCategory() {
orm.NewModel("fleet.vehicle.model.category", orm.ModelOpts{
Description: "Category of the vehicle",
Order: "sequence asc, id asc",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Category Name", Required: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
}
// initFleetVehicleState registers the fleet.vehicle.state model.
// Mirrors: odoo/addons/fleet/models/fleet_vehicle.py FleetVehicleState
func initFleetVehicleState() {
orm.NewModel("fleet.vehicle.state", orm.ModelOpts{
Description: "Vehicle State",
Order: "sequence asc",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "State Name", Required: true, Translate: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
}
// initFleetVehicleTag registers the fleet.vehicle.tag model.
// Mirrors: odoo/addons/fleet/models/fleet_vehicle.py FleetVehicleTag
func initFleetVehicleTag() {
orm.NewModel("fleet.vehicle.tag", orm.ModelOpts{
Description: "Vehicle Tag",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Tag Name", Required: true, Translate: true}),
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
)
}
// initFleetVehicleLogContract registers the fleet.vehicle.log.contract model.
// Mirrors: odoo/addons/fleet/models/fleet_vehicle_log_contract.py
func initFleetVehicleLogContract() {
m := orm.NewModel("fleet.vehicle.log.contract", orm.ModelOpts{
Description: "Vehicle Contract",
Order: "state desc, expiration_date",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Compute: "_compute_contract_name", Store: true}),
orm.Many2one("vehicle_id", "fleet.vehicle", orm.FieldOpts{
String: "Vehicle", Required: true, Index: true,
}),
orm.Many2one("cost_subtype_id", "fleet.service.type", orm.FieldOpts{String: "Type"}),
orm.Float("amount", orm.FieldOpts{String: "Recurring Cost"}),
orm.Date("date", orm.FieldOpts{String: "Date"}),
orm.Date("start_date", orm.FieldOpts{String: "Contract Start Date"}),
orm.Date("expiration_date", orm.FieldOpts{String: "Contract Expiration Date"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "futur", Label: "Incoming"},
{Value: "open", Label: "In Progress"},
{Value: "expired", Label: "Expired"},
{Value: "closed", Label: "Closed"},
}, orm.FieldOpts{String: "Status", Default: "open"}),
orm.Float("cost_generated", orm.FieldOpts{String: "Recurring Cost Amount"}),
orm.Selection("cost_frequency", []orm.SelectionItem{
{Value: "no", Label: "No"},
{Value: "daily", Label: "Daily"},
{Value: "weekly", Label: "Weekly"},
{Value: "monthly", Label: "Monthly"},
{Value: "yearly", Label: "Yearly"},
}, orm.FieldOpts{String: "Recurring Cost Frequency", Default: "monthly"}),
orm.Many2one("insurer_id", "res.partner", orm.FieldOpts{String: "Vendor"}),
orm.Char("ins_ref", orm.FieldOpts{String: "Reference"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
}
// initFleetVehicleLogServices registers the fleet.vehicle.log.services model.
// Mirrors: odoo/addons/fleet/models/fleet_vehicle_log_services.py
func initFleetVehicleLogServices() {
m := orm.NewModel("fleet.vehicle.log.services", orm.ModelOpts{
Description: "Vehicle Services Log",
Order: "date desc",
})
m.AddFields(
orm.Many2one("vehicle_id", "fleet.vehicle", orm.FieldOpts{
String: "Vehicle", Required: true, Index: true,
}),
orm.Many2one("cost_subtype_id", "fleet.service.type", orm.FieldOpts{String: "Type"}),
orm.Float("amount", orm.FieldOpts{String: "Cost"}),
orm.Date("date", orm.FieldOpts{String: "Date"}),
orm.Text("description", orm.FieldOpts{String: "Description"}),
orm.Many2one("vendor_id", "res.partner", orm.FieldOpts{String: "Vendor"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
)
}
// initFleetServiceType registers the fleet.service.type model.
// Mirrors: odoo/addons/fleet/models/fleet_service_type.py
func initFleetServiceType() {
orm.NewModel("fleet.service.type", orm.ModelOpts{
Description: "Fleet Service Type",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Selection("category", []orm.SelectionItem{
{Value: "contract", Label: "Contract"},
{Value: "service", Label: "Service"},
}, orm.FieldOpts{String: "Category", Required: true}),
)
}

View File

@@ -0,0 +1,13 @@
package models
func Init() {
initFleetVehicleModelBrand()
initFleetVehicleModelCategory()
initFleetVehicleState()
initFleetVehicleTag()
initFleetServiceType()
initFleetVehicleModel()
initFleetVehicle()
initFleetVehicleLogContract()
initFleetVehicleLogServices()
}

22
addons/fleet/module.go Normal file
View File

@@ -0,0 +1,22 @@
// Package fleet implements Odoo's Fleet Management module.
// Mirrors: odoo/addons/fleet/__manifest__.py
package fleet
import (
"odoo-go/addons/fleet/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "fleet",
Description: "Fleet",
Version: "19.0.1.0.0",
Category: "Human Resources/Fleet",
Depends: []string{"base"},
Application: true,
Installable: true,
Sequence: 50,
Init: models.Init,
})
}

View File

@@ -0,0 +1,210 @@
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"`
}

View File

@@ -0,0 +1,5 @@
package models
func Init() {
initGoogleAddress()
}

View File

@@ -0,0 +1,28 @@
// Package google_address provides Google Maps/Places integration.
// OPT-IN: Only active when GOOGLE_MAPS_API_KEY is configured.
//
// Features:
// - Address autocomplete (Google Places API)
// - Geocoding (address → lat/lng)
// - Reverse geocoding (lat/lng → address)
// - Distance calculation between partners
package google_address
import (
"odoo-go/addons/google_address/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "google_address",
Description: "Google Maps Address Integration",
Version: "19.0.1.0.0",
Category: "Integration",
Depends: []string{"base"},
Application: false,
Installable: true,
Sequence: 100,
Init: models.Init,
})
}

View File

@@ -0,0 +1,227 @@
package models
import (
"fmt"
"os"
"odoo-go/pkg/orm"
"odoo-go/pkg/tools"
)
var calendarClient *tools.APIClient
func getCalendarClient() *tools.APIClient {
if calendarClient != nil {
return calendarClient
}
apiKey := os.Getenv("GOOGLE_CALENDAR_API_KEY")
if apiKey == "" {
return nil
}
calendarClient = tools.NewAPIClient("https://www.googleapis.com", apiKey)
return calendarClient
}
// initCalendarEvent registers the calendar.event model.
// Mirrors: odoo/addons/calendar/models/calendar_event.py
func initCalendarEvent() {
m := orm.NewModel("calendar.event", orm.ModelOpts{
Description: "Calendar Event",
Order: "start desc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Meeting Subject", Required: true}),
orm.Datetime("start", orm.FieldOpts{String: "Start", Required: true}),
orm.Datetime("stop", orm.FieldOpts{String: "Stop", Required: true}),
orm.Boolean("allday", orm.FieldOpts{String: "All Day"}),
orm.Text("description", orm.FieldOpts{String: "Description"}),
orm.Char("location", orm.FieldOpts{String: "Location"}),
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Organizer"}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Contact"}),
orm.Many2many("attendee_ids", "res.partner", orm.FieldOpts{String: "Attendees"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Unconfirmed"},
{Value: "open", Label: "Confirmed"},
{Value: "done", Label: "Done"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
// Google sync fields
orm.Char("google_event_id", orm.FieldOpts{String: "Google Event ID", Index: true}),
orm.Char("google_calendar_id", orm.FieldOpts{String: "Google Calendar ID"}),
orm.Datetime("google_synced_at", orm.FieldOpts{String: "Last Synced"}),
)
}
// initGoogleCalendarSync registers sync methods.
func initGoogleCalendarSync() {
event := orm.Registry.Get("calendar.event")
if event == nil {
return
}
// push_to_google: Create/update event in Google Calendar
event.RegisterMethod("push_to_google", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
client := getCalendarClient()
if client == nil {
return nil, fmt.Errorf("google_calendar: GOOGLE_CALENDAR_API_KEY not configured")
}
env := rs.Env()
calendarID := "primary"
if len(args) > 0 {
if cid, ok := args[0].(string); ok && cid != "" {
calendarID = cid
}
}
for _, id := range rs.IDs() {
var name, description, location, googleEventID string
var start, stop string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name,''), COALESCE(description,''), COALESCE(location,''),
COALESCE(google_event_id,''), COALESCE(start::text,''), COALESCE(stop::text,'')
FROM calendar_event WHERE id = $1`, id,
).Scan(&name, &description, &location, &googleEventID, &start, &stop)
eventBody := map[string]interface{}{
"summary": name,
"description": description,
"location": location,
"start": map[string]string{
"dateTime": start,
"timeZone": "Europe/Berlin",
},
"end": map[string]string{
"dateTime": stop,
"timeZone": "Europe/Berlin",
},
}
if googleEventID != "" {
// Update existing
var result map[string]interface{}
err := client.PostJSON(
fmt.Sprintf("/calendar/v3/calendars/%s/events/%s", calendarID, googleEventID),
nil, eventBody, &result,
)
if err != nil {
return nil, fmt.Errorf("google_calendar: update event %d: %w", id, err)
}
} else {
// Create new
var result struct {
ID string `json:"id"`
}
err := client.PostJSON(
fmt.Sprintf("/calendar/v3/calendars/%s/events", calendarID),
nil, eventBody, &result,
)
if err != nil {
return nil, fmt.Errorf("google_calendar: create event %d: %w", id, err)
}
// Store Google event ID
env.Tx().Exec(env.Ctx(),
`UPDATE calendar_event SET google_event_id = $1, google_synced_at = NOW() WHERE id = $2`,
result.ID, id)
}
}
return true, nil
})
// pull_from_google: Fetch events from Google Calendar
event.RegisterMethod("pull_from_google", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
client := getCalendarClient()
if client == nil {
return nil, fmt.Errorf("google_calendar: GOOGLE_CALENDAR_API_KEY not configured")
}
calendarID := "primary"
if len(args) > 0 {
if cid, ok := args[0].(string); ok && cid != "" {
calendarID = cid
}
}
var result GoogleEventsResponse
err := client.GetJSON(
fmt.Sprintf("/calendar/v3/calendars/%s/events", calendarID),
map[string]string{
"maxResults": "50",
"singleEvents": "true",
"orderBy": "startTime",
"timeMin": "2026-01-01T00:00:00Z",
},
&result,
)
if err != nil {
return nil, fmt.Errorf("google_calendar: fetch events: %w", err)
}
env := rs.Env()
imported := 0
for _, ge := range result.Items {
// Check if already synced
var existing int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM calendar_event WHERE google_event_id = $1`, ge.ID,
).Scan(&existing)
if existing > 0 {
continue // Already imported
}
startTime := ge.Start.DateTime
if startTime == "" {
startTime = ge.Start.Date
}
endTime := ge.End.DateTime
if endTime == "" {
endTime = ge.End.Date
}
eventRS := env.Model("calendar.event")
_, err := eventRS.Create(orm.Values{
"name": ge.Summary,
"description": ge.Description,
"location": ge.Location,
"start": startTime,
"stop": endTime,
"google_event_id": ge.ID,
"google_synced_at": "now",
"state": "open",
})
if err == nil {
imported++
}
}
return map[string]interface{}{
"imported": imported,
"total": len(result.Items),
}, nil
})
}
// --- Google Calendar API Response Types ---
type GoogleEventsResponse struct {
Items []GoogleEvent `json:"items"`
}
type GoogleEvent struct {
ID string `json:"id"`
Summary string `json:"summary"`
Description string `json:"description"`
Location string `json:"location"`
Start struct {
DateTime string `json:"dateTime"`
Date string `json:"date"`
} `json:"start"`
End struct {
DateTime string `json:"dateTime"`
Date string `json:"date"`
} `json:"end"`
}

View File

@@ -0,0 +1,6 @@
package models
func Init() {
initCalendarEvent()
initGoogleCalendarSync()
}

View File

@@ -0,0 +1,27 @@
// Package google_calendar provides Google Calendar sync integration.
// OPT-IN: Only active when GOOGLE_CALENDAR_API_KEY is configured.
//
// Features:
// - Sync events between Odoo and Google Calendar
// - Create Google Calendar events from project tasks
// - Import Google Calendar events as activities
package google_calendar
import (
"odoo-go/addons/google_calendar/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "google_calendar",
Description: "Google Calendar Sync",
Version: "19.0.1.0.0",
Category: "Integration",
Depends: []string{"base"},
Application: false,
Installable: true,
Sequence: 100,
Init: models.Init,
})
}

View File

@@ -0,0 +1,221 @@
package models
import (
"fmt"
"os"
"odoo-go/pkg/orm"
"odoo-go/pkg/tools"
)
var translateClient *tools.APIClient
func getTranslateClient() *tools.APIClient {
if translateClient != nil {
return translateClient
}
apiKey := os.Getenv("GOOGLE_TRANSLATE_API_KEY")
if apiKey == "" {
return nil
}
translateClient = tools.NewAPIClient("https://translation.googleapis.com", apiKey)
return translateClient
}
func initGoogleTranslate() {
// Register a translation model for storing translations + providing RPC methods
m := orm.NewModel("google.translate", orm.ModelOpts{
Description: "Google Translation Service",
Type: orm.ModelTransient, // No persistent table needed
})
m.AddFields(
orm.Text("source_text", orm.FieldOpts{String: "Source Text"}),
orm.Char("source_lang", orm.FieldOpts{String: "Source Language", Default: "auto"}),
orm.Char("target_lang", orm.FieldOpts{String: "Target Language", Default: "de"}),
orm.Text("translated_text", orm.FieldOpts{String: "Translated Text"}),
)
// translate: Translate text from one language to another
// Usage via RPC: call_kw("google.translate", "translate", [args])
m.RegisterMethod("translate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
client := getTranslateClient()
if client == nil {
return nil, fmt.Errorf("google_translate: GOOGLE_TRANSLATE_API_KEY not configured")
}
text := ""
targetLang := "de"
sourceLang := ""
if len(args) > 0 {
text, _ = args[0].(string)
}
if len(args) > 1 {
targetLang, _ = args[1].(string)
}
if len(args) > 2 {
sourceLang, _ = args[2].(string)
}
if text == "" {
return nil, fmt.Errorf("google_translate: no text provided")
}
params := map[string]string{
"q": text,
"target": targetLang,
"format": "text",
}
if sourceLang != "" && sourceLang != "auto" {
params["source"] = sourceLang
}
var result TranslateResponse
err := client.GetJSON("/language/translate/v2", params, &result)
if err != nil {
return nil, fmt.Errorf("google_translate: API error: %w", err)
}
if len(result.Data.Translations) == 0 {
return nil, fmt.Errorf("google_translate: no translation returned")
}
t := result.Data.Translations[0]
return map[string]interface{}{
"translated_text": t.TranslatedText,
"detected_source": t.DetectedSourceLanguage,
}, nil
})
// translate_batch: Translate multiple texts at once
m.RegisterMethod("translate_batch", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
client := getTranslateClient()
if client == nil {
return nil, fmt.Errorf("google_translate: GOOGLE_TRANSLATE_API_KEY not configured")
}
texts, ok := args[0].([]interface{})
if !ok || len(texts) == 0 {
return nil, fmt.Errorf("google_translate: texts array required")
}
targetLang := "de"
if len(args) > 1 {
targetLang, _ = args[1].(string)
}
var results []map[string]interface{}
for _, t := range texts {
text, _ := t.(string)
if text == "" {
continue
}
var result TranslateResponse
err := client.GetJSON("/language/translate/v2", map[string]string{
"q": text,
"target": targetLang,
"format": "text",
}, &result)
if err != nil || len(result.Data.Translations) == 0 {
results = append(results, map[string]interface{}{
"source": text, "translated": text, "error": fmt.Sprintf("%v", err),
})
continue
}
results = append(results, map[string]interface{}{
"source": text,
"translated": result.Data.Translations[0].TranslatedText,
"detected": result.Data.Translations[0].DetectedSourceLanguage,
})
}
return results, nil
})
// detect_language: Detect the language of a text
m.RegisterMethod("detect_language", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
client := getTranslateClient()
if client == nil {
return nil, fmt.Errorf("google_translate: GOOGLE_TRANSLATE_API_KEY not configured")
}
text := ""
if len(args) > 0 {
text, _ = args[0].(string)
}
var result DetectResponse
err := client.GetJSON("/language/translate/v2/detect", map[string]string{
"q": text,
}, &result)
if err != nil {
return nil, err
}
if len(result.Data.Detections) > 0 && len(result.Data.Detections[0]) > 0 {
d := result.Data.Detections[0][0]
return map[string]interface{}{
"language": d.Language,
"confidence": d.Confidence,
}, nil
}
return nil, fmt.Errorf("google_translate: language detection failed")
})
// supported_languages: List supported languages
m.RegisterMethod("supported_languages", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
client := getTranslateClient()
if client == nil {
return nil, fmt.Errorf("google_translate: GOOGLE_TRANSLATE_API_KEY not configured")
}
var result LanguagesResponse
err := client.GetJSON("/language/translate/v2/languages", map[string]string{
"target": "de",
}, &result)
if err != nil {
return nil, err
}
var langs []map[string]string
for _, l := range result.Data.Languages {
langs = append(langs, map[string]string{
"code": l.Language,
"name": l.Name,
})
}
return langs, nil
})
}
// --- Google Translate API Response Types ---
type TranslateResponse struct {
Data struct {
Translations []struct {
TranslatedText string `json:"translatedText"`
DetectedSourceLanguage string `json:"detectedSourceLanguage"`
} `json:"translations"`
} `json:"data"`
}
type DetectResponse struct {
Data struct {
Detections [][]struct {
Language string `json:"language"`
Confidence float64 `json:"confidence"`
} `json:"detections"`
} `json:"data"`
}
type LanguagesResponse struct {
Data struct {
Languages []struct {
Language string `json:"language"`
Name string `json:"name"`
} `json:"languages"`
} `json:"data"`
}

View File

@@ -0,0 +1,5 @@
package models
func Init() {
initGoogleTranslate()
}

View File

@@ -0,0 +1,27 @@
// Package google_translate provides Google Cloud Translation integration.
// OPT-IN: Only active when GOOGLE_TRANSLATE_API_KEY is configured.
//
// Features:
// - Translate any text field on any record
// - Auto-detect source language
// - Batch translation support
package google_translate
import (
"odoo-go/addons/google_translate/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "google_translate",
Description: "Google Cloud Translation",
Version: "19.0.1.0.0",
Category: "Integration",
Depends: []string{"base"},
Application: false,
Installable: true,
Sequence: 100,
Init: models.Init,
})
}

145
addons/hr/models/hr.go Normal file
View File

@@ -0,0 +1,145 @@
package models
import "odoo-go/pkg/orm"
// initResourceCalendar registers resource.calendar — working schedules.
// Mirrors: odoo/addons/resource/models/resource.py
func initResourceCalendar() {
m := orm.NewModel("resource.calendar", orm.ModelOpts{
Description: "Resource Working Time",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Float("hours_per_week", orm.FieldOpts{String: "Hours per Week"}),
orm.Char("tz", orm.FieldOpts{String: "Timezone", Default: "Europe/Berlin"}),
orm.Boolean("flexible_hours", orm.FieldOpts{String: "Flexible Hours"}),
orm.One2many("attendance_ids", "resource.calendar.attendance", "calendar_id", orm.FieldOpts{String: "Attendances"}),
)
// resource.calendar.attendance — work time slots
orm.NewModel("resource.calendar.attendance", orm.ModelOpts{
Description: "Work Detail",
Order: "dayofweek, hour_from",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Selection("dayofweek", []orm.SelectionItem{
{Value: "0", Label: "Monday"}, {Value: "1", Label: "Tuesday"},
{Value: "2", Label: "Wednesday"}, {Value: "3", Label: "Thursday"},
{Value: "4", Label: "Friday"}, {Value: "5", Label: "Saturday"},
{Value: "6", Label: "Sunday"},
}, orm.FieldOpts{String: "Day of Week", Required: true}),
orm.Float("hour_from", orm.FieldOpts{String: "Work from", Required: true}),
orm.Float("hour_to", orm.FieldOpts{String: "Work to", Required: true}),
orm.Many2one("calendar_id", "resource.calendar", orm.FieldOpts{
String: "Calendar", Required: true, OnDelete: orm.OnDeleteCascade,
}),
)
}
// initHREmployee registers the hr.employee model.
// Mirrors: odoo/addons/hr/models/hr_employee.py
func initHREmployee() {
m := orm.NewModel("hr.employee", orm.ModelOpts{
Description: "Employee",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Employee Name", Required: true, Index: true}),
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Related User"}),
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department", Index: true}),
orm.Many2one("job_id", "hr.job", orm.FieldOpts{String: "Job Position"}),
orm.Char("job_title", orm.FieldOpts{String: "Job Title"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("address_id", "res.partner", orm.FieldOpts{String: "Work Address"}),
orm.Char("work_email", orm.FieldOpts{String: "Work Email"}),
orm.Char("work_phone", orm.FieldOpts{String: "Work Phone"}),
orm.Char("mobile_phone", orm.FieldOpts{String: "Work Mobile"}),
orm.Many2one("coach_id", "hr.employee", orm.FieldOpts{String: "Coach"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("resource_calendar_id", "resource.calendar", orm.FieldOpts{String: "Working Schedule"}),
orm.Selection("gender", []orm.SelectionItem{
{Value: "male", Label: "Male"},
{Value: "female", Label: "Female"},
{Value: "other", Label: "Other"},
}, orm.FieldOpts{String: "Gender"}),
orm.Date("birthday", orm.FieldOpts{String: "Date of Birth", Groups: "hr.group_hr_user"}),
orm.Selection("marital", []orm.SelectionItem{
{Value: "single", Label: "Single"},
{Value: "married", Label: "Married"},
{Value: "cohabitant", Label: "Legal Cohabitant"},
{Value: "widower", Label: "Widower"},
{Value: "divorced", Label: "Divorced"},
}, orm.FieldOpts{String: "Marital Status", Default: "single"}),
orm.Char("emergency_contact", orm.FieldOpts{String: "Emergency Contact"}),
orm.Char("emergency_phone", orm.FieldOpts{String: "Emergency Phone"}),
orm.Selection("certificate", []orm.SelectionItem{
{Value: "graduate", Label: "Graduate"},
{Value: "bachelor", Label: "Bachelor"},
{Value: "master", Label: "Master"},
{Value: "doctor", Label: "Doctor"},
{Value: "other", Label: "Other"},
}, orm.FieldOpts{String: "Certificate Level"}),
orm.Char("study_field", orm.FieldOpts{String: "Field of Study"}),
orm.Char("visa_no", orm.FieldOpts{String: "Visa No", Groups: "hr.group_hr_user"}),
orm.Char("permit_no", orm.FieldOpts{String: "Work Permit No", Groups: "hr.group_hr_user"}),
orm.Integer("km_home_work", orm.FieldOpts{String: "Home-Work Distance (km)"}),
orm.Binary("image_1920", orm.FieldOpts{String: "Image"}),
)
}
// initHRDepartment registers the hr.department model.
// Mirrors: odoo/addons/hr/models/hr_department.py
func initHRDepartment() {
m := orm.NewModel("hr.department", orm.ModelOpts{
Description: "HR Department",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Department Name", Required: true, Translate: true}),
orm.Char("complete_name", orm.FieldOpts{
String: "Complete Name",
Compute: "_compute_complete_name",
Store: true,
}),
orm.Many2one("parent_id", "hr.department", orm.FieldOpts{String: "Parent Department", Index: true}),
orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.One2many("child_ids", "hr.department", "parent_id", orm.FieldOpts{String: "Child Departments"}),
orm.One2many("member_ids", "hr.employee", "department_id", orm.FieldOpts{String: "Members"}),
)
}
// initHRJob registers the hr.job model.
// Mirrors: odoo/addons/hr/models/hr_job.py
func initHRJob() {
m := orm.NewModel("hr.job", orm.ModelOpts{
Description: "Job Position",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Job Position", Required: true, Index: true, Translate: true}),
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Integer("expected_employees", orm.FieldOpts{String: "Expected New Employees", Default: 1}),
orm.Integer("no_of_hired_employee", orm.FieldOpts{String: "Hired Employees"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "recruit", Label: "Recruitment in Progress"},
{Value: "open", Label: "Not Recruiting"},
}, orm.FieldOpts{String: "Status", Required: true, Default: "recruit"}),
orm.Text("description", orm.FieldOpts{String: "Job Description"}),
)
}

8
addons/hr/models/init.go Normal file
View File

@@ -0,0 +1,8 @@
package models
func Init() {
initResourceCalendar()
initHREmployee()
initHRDepartment()
initHRJob()
}

22
addons/hr/module.go Normal file
View File

@@ -0,0 +1,22 @@
// Package hr implements Odoo's Human Resources module.
// Mirrors: odoo/addons/hr/__manifest__.py
package hr
import (
"odoo-go/addons/hr/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "hr",
Description: "Employees",
Version: "19.0.1.0.0",
Category: "Human Resources/Employees",
Depends: []string{"base"},
Application: true,
Installable: true,
Sequence: 20,
Init: models.Init,
})
}

164
addons/l10n_de/data.go Normal file
View File

@@ -0,0 +1,164 @@
// Package l10n_de provides German chart of accounts seed data.
package l10n_de
// SKR03Account defines a single account in the SKR03 chart.
type SKR03Account struct {
Code string
Name string
AccountType string
Reconcile bool
}
// SKR03Accounts returns the core SKR03 chart of accounts.
// Mirrors: odoo/addons/l10n_de/data/template/account.account-de_skr03.csv
// This is a subset of the most commonly used accounts.
var SKR03Accounts = []SKR03Account{
// 0xxx — Anlagevermögen (Fixed Assets)
{"0027", "EDV-Software", "asset_non_current", false},
{"0200", "Grundstücke und Bauten", "asset_fixed", false},
{"0320", "Maschinen", "asset_fixed", false},
{"0400", "Technische Anlagen", "asset_fixed", false},
{"0420", "Büroeinrichtung", "asset_fixed", false},
{"0440", "Werkzeuge", "asset_fixed", false},
{"0480", "Geringwertige Wirtschaftsgüter", "asset_fixed", false},
{"0520", "Fuhrpark", "asset_fixed", false},
{"0540", "Geschäftsbauten", "asset_fixed", false},
// 1xxx — Finanz- und Privatkonten
{"1000", "Kasse", "asset_cash", false},
{"1200", "Bank", "asset_cash", false},
{"1210", "Postbank", "asset_cash", false},
{"1400", "Forderungen aus Lieferungen und Leistungen", "asset_receivable", true},
{"1410", "Forderungen aus L+L (Debitor)", "asset_receivable", true},
{"1450", "Forderungen nach §11 EStG", "asset_receivable", true},
{"1500", "Sonstige Vermögensgegenstände", "asset_current", false},
{"1518", "Forderungen gegenüber Gesellschaftern", "asset_current", false},
{"1548", "Vorsteuer laufendes Jahr", "asset_current", false},
{"1570", "Vorsteuer 7%", "asset_current", false},
{"1576", "Vorsteuer 19%", "asset_current", false},
{"1580", "Vorsteuer aus innergemeinschaftlichem Erwerb", "asset_current", false},
{"1588", "Vorsteuer im Folgejahr abziehbar", "asset_current", false},
{"1590", "Durchlaufende Posten", "asset_current", false},
{"1600", "Verbindlichkeiten aus Lieferungen und Leistungen", "liability_payable", true},
{"1610", "Verbindlichkeiten aus L+L (Kreditor)", "liability_payable", true},
{"1700", "Sonstige Verbindlichkeiten", "liability_current", false},
{"1710", "Erhaltene Anzahlungen", "liability_current", false},
{"1740", "Verbindlichkeiten aus Lohn und Gehalt", "liability_current", false},
{"1741", "Verbindlichkeiten Sozialversicherung", "liability_current", false},
{"1750", "Verbindlichkeiten Lohnsteuer", "liability_current", false},
{"1770", "Umsatzsteuer 7%", "liability_current", false},
{"1776", "Umsatzsteuer 19%", "liability_current", false},
{"1780", "Umsatzsteuer-Vorauszahlungen", "liability_current", false},
{"1790", "Umsatzsteuer laufendes Jahr", "liability_current", false},
{"1791", "Umsatzsteuer Vorjahr", "liability_current", false},
// 2xxx — Abgrenzungskonten
{"2000", "Aufwendungen für Roh-, Hilfs- und Betriebsstoffe", "expense_direct_cost", false},
// 3xxx — Wareneingang (Purchasing)
{"3000", "Roh-, Hilfs- und Betriebsstoffe", "expense_direct_cost", false},
{"3100", "Fremdleistungen", "expense_direct_cost", false},
{"3200", "Wareneingang", "expense_direct_cost", false},
{"3300", "Wareneingang 7% Vorsteuer", "expense_direct_cost", false},
{"3400", "Wareneingang 19% Vorsteuer", "expense_direct_cost", false},
{"3736", "Erhaltene Skonti 19% VSt", "expense_direct_cost", false},
// 4xxx — Betriebliche Aufwendungen (Operating Expenses)
{"4000", "Personalkosten", "expense", false},
{"4100", "Löhne", "expense", false},
{"4110", "Löhne Produktion", "expense", false},
{"4120", "Gehälter", "expense", false},
{"4130", "Geschäftsführergehälter", "expense", false},
{"4140", "Freiwillige soziale Aufwendungen", "expense", false},
{"4170", "Vermögenswirksame Leistungen", "expense", false},
{"4180", "Arbeitgeberanteile Sozialversicherung", "expense", false},
{"4190", "Berufsgenossenschaft", "expense", false},
{"4200", "Raumkosten", "expense", false},
{"4210", "Miete", "expense", false},
{"4220", "Heizung", "expense", false},
{"4230", "Gas, Strom, Wasser", "expense", false},
{"4240", "Reinigung", "expense", false},
{"4260", "Instandhaltung Betriebsräume", "expense", false},
{"4300", "Versicherungen", "expense", false},
{"4360", "Kfz-Versicherungen", "expense", false},
{"4380", "Beiträge", "expense", false},
{"4400", "Bürobedarf", "expense", false},
{"4500", "Fahrzeugkosten", "expense", false},
{"4510", "Kfz-Steuer", "expense", false},
{"4520", "Kfz-Reparaturen", "expense", false},
{"4530", "Laufende Kfz-Betriebskosten", "expense", false},
{"4540", "Kfz-Leasing", "expense", false},
{"4580", "Sonstige Kfz-Kosten", "expense", false},
{"4600", "Werbekosten", "expense", false},
{"4630", "Geschenke an Geschäftsfreunde", "expense", false},
{"4650", "Bewirtungskosten", "expense", false},
{"4654", "Bewirtungskosten nicht abzugsfähig", "expense", false},
{"4660", "Reisekosten Arbeitnehmer", "expense", false},
{"4663", "Reisekosten Unternehmer", "expense", false},
{"4670", "Kilometergeld", "expense", false},
{"4700", "Kosten der Warenabgabe", "expense", false},
{"4800", "Reparaturen und Instandhaltung", "expense", false},
{"4830", "Abschreibungen Sachanlagen", "expense_depreciation", false},
{"4840", "Abschreibungen auf GWG", "expense_depreciation", false},
{"4900", "Sonstige betriebliche Aufwendungen", "expense", false},
{"4910", "Porto", "expense", false},
{"4920", "Telefon", "expense", false},
{"4930", "Büromaterial", "expense", false},
{"4940", "Zeitschriften, Bücher", "expense", false},
{"4950", "Rechts- und Beratungskosten", "expense", false},
{"4955", "Buchführungskosten", "expense", false},
{"4960", "Nebenkosten des Geldverkehrs", "expense", false},
{"4970", "Abschluss- und Prüfungskosten", "expense", false},
// 8xxx — Erlöse (Revenue)
{"8000", "Erlöse", "income", false},
{"8100", "Steuerfreie Umsätze §4 Nr. 1a UStG", "income", false},
{"8120", "Steuerfreie Umsätze §4 Nr. 1b UStG", "income", false},
{"8125", "Steuerfreie innergem. Lieferungen §4 Nr. 1b", "income", false},
{"8200", "Erlöse 7% USt", "income", false},
{"8300", "Erlöse 19% USt", "income", false},
{"8400", "Erlöse 19% USt (allgemein)", "income", false},
{"8500", "Provisionserlöse", "income", false},
{"8600", "Erlöse aus Vermietung", "income_other", false},
{"8700", "Erlösschmälerungen", "income", false},
{"8736", "Gewährte Skonti 19% USt", "income", false},
{"8800", "Erlöse aus Anlagenverkäufen", "income_other", false},
{"8900", "Privatentnahmen", "equity", false},
{"8920", "Unentgeltliche Wertabgaben", "income_other", false},
// 9xxx — Vortrags- und Abschlusskonten
{"9000", "Saldenvorträge Sachkonten", "equity_unaffected", false},
{"9008", "Saldenvorträge Debitoren", "equity_unaffected", false},
{"9009", "Saldenvorträge Kreditoren", "equity_unaffected", false},
// Eigenkapital
{"0800", "Gezeichnetes Kapital", "equity", false},
{"0840", "Kapitalrücklage", "equity", false},
{"0860", "Gewinnrücklage", "equity", false},
{"0868", "Gewinnvortrag", "equity", false},
{"0869", "Verlustvortrag", "equity", false},
{"0880", "Nicht durch Eigenkapital gedeckter Fehlbetrag", "equity_unaffected", false},
}
// SKR03Taxes returns the standard German tax definitions.
type TaxDef struct {
Name string
Amount float64
TypeUse string // sale / purchase
Account string // account code for tax
}
var SKR03Taxes = []TaxDef{
// USt (Umsatzsteuer) — Sales Tax
{"USt 19%", 19.0, "sale", "1776"},
{"USt 7%", 7.0, "sale", "1770"},
{"USt 0% steuerfreie Umsätze", 0.0, "sale", ""},
// VSt (Vorsteuer) — Input Tax / Purchase Tax
{"VSt 19%", 19.0, "purchase", "1576"},
{"VSt 7%", 7.0, "purchase", "1570"},
// Innergemeinschaftliche Erwerbe
{"USt 19% innergem. Erwerb", 19.0, "purchase", "1580"},
{"USt 19% Reverse Charge", 19.0, "purchase", "1787"},
}

View File

@@ -0,0 +1,5 @@
package models
func Init() {
initChartTemplate()
}

View File

@@ -0,0 +1,99 @@
package models
import "odoo-go/pkg/orm"
// initChartTemplate registers the German chart of accounts template model
// and defines the structure for loading SKR03/SKR04 seed data.
// Mirrors: odoo/addons/l10n_de/models/template_de_skr03.py
// odoo/addons/l10n_de/models/template_de_skr04.py
//
// German Standard Charts of Accounts (Standardkontenrahmen):
//
// SKR03 — organized by function (Prozessgliederungsprinzip):
// 0xxx Anlage- und Kapitalkonten (Fixed assets & capital accounts) — Aktiva
// 1xxx Finanz- und Privatkonten (Financial & private accounts) — Aktiva
// 2xxx Abgrenzungskonten (Accrual accounts) — Aktiva
// 3xxx Wareneingangskonten (Goods received / purchasing accounts) — GuV
// 4xxx Betriebliche Aufwendungen (Operating expenses) — GuV
// 5xxx (reserved)
// 6xxx (reserved)
// 7xxx Bestände an Erzeugnissen (Inventory of products) — GuV
// 8xxx Erlöskonten (Revenue accounts) — GuV
// 9xxx Vortrags- und statistische Konten (Carried-forward & statistical)
//
// SKR04 — organized by the balance sheet / P&L structure (Abschlussgliederungsprinzip):
// 0xxx Anlagevermögen (Fixed assets) — Aktiva
// 1xxx Umlaufvermögen (Current assets) — Aktiva
// 2xxx Eigenkapital (Equity) — Passiva
// 3xxx Fremdkapital (Liabilities) — Passiva
// 4xxx Betriebliche Erträge (Operating income) — GuV
// 5xxx Betriebliche Aufwendungen (Operating expenses, materials) — GuV
// 6xxx Betriebliche Aufwendungen (Operating expenses, personnel) — GuV
// 7xxx Weitere Erträge und Aufwendungen (Other income & expenses) — GuV
// 8xxx (reserved)
// 9xxx Vortrags- und statistische Konten (Carried-forward & statistical)
func initChartTemplate() {
// l10n_de.chart.template — Metadata for German chart of accounts templates.
// In Odoo, chart templates are loaded from data files (XML/CSV).
// This model holds template metadata; the actual account definitions
// would be loaded via seed data during module installation.
m := orm.NewModel("l10n_de.chart.template", orm.ModelOpts{
Description: "German Chart of Accounts Template",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Chart Name", Required: true, Translate: true}),
orm.Selection("chart_type", []orm.SelectionItem{
{Value: "skr03", Label: "SKR03 (Prozessgliederung)"},
{Value: "skr04", Label: "SKR04 (Abschlussgliederung)"},
}, orm.FieldOpts{String: "Chart Type", Required: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Index: true,
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Boolean("visible", orm.FieldOpts{String: "Can be Visible", Default: true}),
)
}
// LoadSKR03 would load the SKR03 chart of accounts as seed data.
// In a full implementation, this reads account definitions and creates
// account.account records for the installing company.
//
// SKR03 key account ranges:
// 0100-0499 Immaterielle Vermögensgegenstände (Intangible assets)
// 0500-0899 Sachanlagen (Tangible fixed assets)
// 0900-0999 Finanzanlagen (Financial assets)
// 1000-1099 Kasse (Cash)
// 1200-1299 Bankkonten (Bank accounts)
// 1400-1499 Forderungen aus Lieferungen (Trade receivables)
// 1600-1699 Sonstige Forderungen (Other receivables)
// 1700-1799 Verbindlichkeiten aus Lieferungen (Trade payables)
// 1800-1899 Umsatzsteuer / Vorsteuer (VAT accounts)
// 3000-3999 Wareneingang (Goods received)
// 4000-4999 Betriebliche Aufwendungen (Operating expenses)
// 8000-8999 Erlöse (Revenue)
func LoadSKR03() {
// Seed data loading would be implemented here.
// Typically reads from embedded CSV/XML data files and calls
// orm create operations for account.account records.
}
// LoadSKR04 would load the SKR04 chart of accounts as seed data.
// SKR04 follows the balance sheet structure more closely.
//
// SKR04 key account ranges:
// 0100-0199 Immaterielle Vermögensgegenstände (Intangible assets)
// 0200-0499 Sachanlagen (Tangible fixed assets)
// 0500-0699 Finanzanlagen (Financial assets)
// 1000-1099 Kasse (Cash)
// 1200-1299 Bankkonten (Bank accounts)
// 1400-1499 Forderungen aus Lieferungen (Trade receivables)
// 2000-2999 Eigenkapital (Equity)
// 3000-3999 Fremdkapital (Liabilities)
// 4000-4999 Betriebliche Erträge (Operating income)
// 5000-6999 Betriebliche Aufwendungen (Operating expenses)
// 7000-7999 Weitere Erträge/Aufwendungen (Other income/expenses)
func LoadSKR04() {
// Seed data loading would be implemented here.
}

25
addons/l10n_de/module.go Normal file
View File

@@ -0,0 +1,25 @@
// Package l10n_de implements the German localization for Odoo accounting.
// Mirrors: odoo/addons/l10n_de/__manifest__.py
//
// Provides the SKR03 and SKR04 standard charts of accounts (Standardkontenrahmen)
// as defined by DATEV for German businesses.
package l10n_de
import (
"odoo-go/addons/l10n_de/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "l10n_de",
Description: "Germany - Accounting",
Version: "19.0.1.0.0",
Category: "Accounting/Localizations/Account Charts",
Depends: []string{"base", "account"},
Application: false,
Installable: true,
Sequence: 100,
Init: models.Init,
})
}

View File

@@ -0,0 +1,9 @@
package models
func Init() {
initProductCategory()
initUoM()
initProductTemplate()
initProductProduct()
initProductPricelist()
}

View File

@@ -0,0 +1,171 @@
package models
import "odoo-go/pkg/orm"
// initProductCategory registers product.category — product classification tree.
// Mirrors: odoo/addons/product/models/product_category.py
func initProductCategory() {
m := orm.NewModel("product.category", orm.ModelOpts{
Description: "Product Category",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Many2one("parent_id", "product.category", orm.FieldOpts{
String: "Parent Category", Index: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("property_account_income_categ_id", "account.account", orm.FieldOpts{
String: "Income Account",
Help: "This account will be used when validating a customer invoice.",
}),
orm.Many2one("property_account_expense_categ_id", "account.account", orm.FieldOpts{
String: "Expense Account",
Help: "This account will be used when validating a vendor bill.",
}),
)
}
// initUoM registers uom.category and uom.uom — units of measure.
// Mirrors: odoo/addons/product/models/product_uom.py
func initUoM() {
// uom.category — groups compatible units (e.g., Weight, Volume)
orm.NewModel("uom.category", orm.ModelOpts{
Description: "Product UoM Categories",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
)
// uom.uom — individual units of measure
m := orm.NewModel("uom.uom", orm.ModelOpts{
Description: "Product Unit of Measure",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Unit of Measure", Required: true, Translate: true}),
orm.Many2one("category_id", "uom.category", orm.FieldOpts{
String: "Category", Required: true, OnDelete: orm.OnDeleteCascade,
Help: "Conversion between Units of Measure can only occur if they belong to the same category.",
}),
orm.Float("factor", orm.FieldOpts{
String: "Ratio", Required: true, Default: 1.0,
Help: "How much bigger or smaller this unit is compared to the reference unit of measure for this category.",
}),
orm.Selection("uom_type", []orm.SelectionItem{
{Value: "bigger", Label: "Bigger than the reference Unit of Measure"},
{Value: "reference", Label: "Reference Unit of Measure for this category"},
{Value: "smaller", Label: "Smaller than the reference Unit of Measure"},
}, orm.FieldOpts{String: "Type", Required: true, Default: "reference"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Float("rounding", orm.FieldOpts{
String: "Rounding Precision", Required: true, Default: 0.01,
Help: "The computed quantity will be a multiple of this value. Use 1.0 for a Unit of Measure that cannot be further split.",
}),
)
}
// initProductTemplate registers product.template — the base product definition.
// Mirrors: odoo/addons/product/models/product_template.py
func initProductTemplate() {
m := orm.NewModel("product.template", orm.ModelOpts{
Description: "Product Template",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Index: true, Translate: true}),
orm.Selection("type", []orm.SelectionItem{
{Value: "consu", Label: "Consumable"},
{Value: "service", Label: "Service"},
{Value: "storable", Label: "Storable Product"},
}, orm.FieldOpts{String: "Product Type", Required: true, Default: "consu"}),
orm.Float("list_price", orm.FieldOpts{String: "Sales Price", Default: 1.0}),
orm.Float("standard_price", orm.FieldOpts{String: "Cost"}),
orm.Many2one("categ_id", "product.category", orm.FieldOpts{
String: "Product Category", Required: true,
}),
orm.Many2one("uom_id", "uom.uom", orm.FieldOpts{
String: "Unit of Measure", Required: true,
}),
orm.Boolean("sale_ok", orm.FieldOpts{String: "Can be Sold", Default: true}),
orm.Boolean("purchase_ok", orm.FieldOpts{String: "Can be Purchased", Default: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Text("description", orm.FieldOpts{String: "Description", Translate: true}),
orm.Many2many("taxes_id", "account.tax", orm.FieldOpts{
String: "Customer Taxes",
Help: "Default taxes used when selling the product.",
}),
orm.Many2many("supplier_taxes_id", "account.tax", orm.FieldOpts{
String: "Vendor Taxes",
Help: "Default taxes used when buying the product.",
}),
)
}
// initProductProduct registers product.product — a concrete product variant.
// Mirrors: odoo/addons/product/models/product_product.py
func initProductProduct() {
m := orm.NewModel("product.product", orm.ModelOpts{
Description: "Product",
Order: "default_code, name, id",
})
m.AddFields(
orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{
String: "Product Template", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Char("default_code", orm.FieldOpts{String: "Internal Reference", Index: true}),
orm.Char("barcode", orm.FieldOpts{String: "Barcode", Index: true}),
orm.Float("volume", orm.FieldOpts{String: "Volume", Help: "The volume in m3."}),
orm.Float("weight", orm.FieldOpts{String: "Weight", Help: "The weight in kg."}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
}
// initProductPricelist registers product.pricelist — price lists for customers.
// Mirrors: odoo/addons/product/models/product_pricelist.py
func initProductPricelist() {
m := orm.NewModel("product.pricelist", orm.ModelOpts{
Description: "Pricelist",
Order: "sequence, id",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Pricelist Name", Required: true, Translate: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency", Required: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 16}),
orm.One2many("item_ids", "product.pricelist.item", "pricelist_id", orm.FieldOpts{String: "Pricelist Rules"}),
)
// product.pricelist.item — Price rules
orm.NewModel("product.pricelist.item", orm.ModelOpts{
Description: "Pricelist Rule",
Order: "applied_on, min_quantity desc, categ_id desc, id desc",
}).AddFields(
orm.Many2one("pricelist_id", "product.pricelist", orm.FieldOpts{
String: "Pricelist", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{String: "Product"}),
orm.Many2one("categ_id", "product.category", orm.FieldOpts{String: "Product Category"}),
orm.Float("min_quantity", orm.FieldOpts{String: "Min. Quantity"}),
orm.Selection("applied_on", []orm.SelectionItem{
{Value: "3_global", Label: "All Products"},
{Value: "2_product_category", Label: "Product Category"},
{Value: "1_product", Label: "Product"},
{Value: "0_product_variant", Label: "Product Variant"},
}, orm.FieldOpts{String: "Apply On", Default: "3_global", Required: true}),
orm.Selection("compute_price", []orm.SelectionItem{
{Value: "fixed", Label: "Fixed Price"},
{Value: "percentage", Label: "Discount"},
{Value: "formula", Label: "Formula"},
}, orm.FieldOpts{String: "Computation", Default: "fixed", Required: true}),
orm.Float("fixed_price", orm.FieldOpts{String: "Fixed Price"}),
orm.Float("percent_price", orm.FieldOpts{String: "Percentage Price"}),
orm.Date("date_start", orm.FieldOpts{String: "Start Date"}),
orm.Date("date_end", orm.FieldOpts{String: "End Date"}),
)
}

22
addons/product/module.go Normal file
View File

@@ -0,0 +1,22 @@
// Package product implements Odoo's product management module.
// Mirrors: odoo/addons/product/__manifest__.py
package product
import (
"odoo-go/addons/product/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "product",
Description: "Products",
Version: "19.0.1.0.0",
Category: "Sales/Sales",
Depends: []string{"base"},
Application: false,
Installable: true,
Sequence: 15,
Init: models.Init,
})
}

View File

@@ -0,0 +1,9 @@
package models
func Init() {
initProjectTags()
initProjectTaskType()
initProjectMilestone()
initProjectProject()
initProjectTask()
}

View File

@@ -0,0 +1,124 @@
package models
import "odoo-go/pkg/orm"
// initProjectProject registers the project.project model.
// Mirrors: odoo/addons/project/models/project_project.py
func initProjectProject() {
m := orm.NewModel("project.project", orm.ModelOpts{
Description: "Project",
Order: "sequence, name, id",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Index: true, Translate: true}),
orm.Text("description", orm.FieldOpts{String: "Description"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("stage_id", "project.task.type", orm.FieldOpts{String: "Stage"}),
orm.Many2many("favorite_user_ids", "res.users", orm.FieldOpts{String: "Favorite Users"}),
orm.Integer("task_count", orm.FieldOpts{
String: "Task Count",
Compute: "_compute_task_count",
}),
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
orm.Boolean("allow_task_dependencies", orm.FieldOpts{String: "Task Dependencies"}),
orm.Boolean("allow_milestones", orm.FieldOpts{String: "Milestones"}),
)
}
// initProjectTask registers the project.task model.
// Mirrors: odoo/addons/project/models/project_task.py
func initProjectTask() {
m := orm.NewModel("project.task", orm.ModelOpts{
Description: "Task",
Order: "priority desc, sequence, id desc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Title", Required: true, Index: true}),
orm.HTML("description", orm.FieldOpts{String: "Description"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Many2one("project_id", "project.project", orm.FieldOpts{String: "Project", Index: true}),
orm.Many2one("parent_id", "project.task", orm.FieldOpts{String: "Parent Task", Index: true}),
orm.One2many("child_ids", "project.task", "parent_id", orm.FieldOpts{String: "Sub-tasks"}),
orm.Many2one("stage_id", "project.task.type", orm.FieldOpts{String: "Stage", Index: true}),
orm.Selection("state", []orm.SelectionItem{
{Value: "open", Label: "In Progress"},
{Value: "done", Label: "Done"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "State", Default: "open"}),
orm.Selection("priority", []orm.SelectionItem{
{Value: "0", Label: "Normal"},
{Value: "1", Label: "Important"},
}, orm.FieldOpts{String: "Priority", Default: "0"}),
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
orm.Many2many("user_ids", "res.users", orm.FieldOpts{String: "Assignees"}),
orm.Date("date_deadline", orm.FieldOpts{String: "Deadline", Index: true}),
orm.Datetime("date_assign", orm.FieldOpts{String: "Assigning Date"}),
orm.Many2many("tag_ids", "project.tags", orm.FieldOpts{String: "Tags"}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("milestone_id", "project.milestone", orm.FieldOpts{String: "Milestone"}),
orm.Many2many("depend_ids", "project.task", orm.FieldOpts{String: "Depends On"}),
orm.Boolean("recurring_task", orm.FieldOpts{String: "Recurrent"}),
orm.Selection("display_type", []orm.SelectionItem{
{Value: "", Label: ""},
{Value: "line_section", Label: "Section"},
{Value: "line_note", Label: "Note"},
}, orm.FieldOpts{String: "Display Type"}),
)
}
// initProjectTaskType registers the project.task.type model (stages).
// Mirrors: odoo/addons/project/models/project_task_type.py
func initProjectTaskType() {
m := orm.NewModel("project.task.type", orm.ModelOpts{
Description: "Task Stage",
Order: "sequence, id",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
orm.Boolean("fold", orm.FieldOpts{String: "Folded in Kanban"}),
orm.Many2many("project_ids", "project.project", orm.FieldOpts{String: "Projects"}),
)
}
// initProjectMilestone registers the project.milestone model.
// Mirrors: odoo/addons/project/models/project_milestone.py
func initProjectMilestone() {
m := orm.NewModel("project.milestone", orm.ModelOpts{
Description: "Project Milestone",
Order: "deadline, is_reached desc, name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Many2one("project_id", "project.project", orm.FieldOpts{
String: "Project", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Date("deadline", orm.FieldOpts{String: "Deadline"}),
orm.Boolean("is_reached", orm.FieldOpts{String: "Reached"}),
)
}
// initProjectTags registers the project.tags model.
// Mirrors: odoo/addons/project/models/project_tags.py
func initProjectTags() {
orm.NewModel("project.tags", orm.ModelOpts{
Description: "Project Tags",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
)
}

22
addons/project/module.go Normal file
View File

@@ -0,0 +1,22 @@
// Package project implements Odoo's Project Management module.
// Mirrors: odoo/addons/project/__manifest__.py
package project
import (
"odoo-go/addons/project/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "project",
Description: "Project",
Version: "19.0.1.0.0",
Category: "Services/Project",
Depends: []string{"base"},
Application: true,
Installable: true,
Sequence: 30,
Init: models.Init,
})
}

View File

@@ -0,0 +1,6 @@
package models
func Init() {
initPurchaseOrder()
initPurchaseOrderLine()
}

View File

@@ -0,0 +1,177 @@
package models
import "odoo-go/pkg/orm"
// initPurchaseOrder registers purchase.order and purchase.order.line.
// Mirrors: odoo/addons/purchase/models/purchase_order.py
func initPurchaseOrder() {
// purchase.order — the purchase order header
m := orm.NewModel("purchase.order", orm.ModelOpts{
Description: "Purchase Order",
Order: "priority desc, id desc",
RecName: "name",
})
// -- Identity --
m.AddFields(
orm.Char("name", orm.FieldOpts{
String: "Order Reference", Required: true, Index: true, Readonly: true, Default: "New",
}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "RFQ"},
{Value: "sent", Label: "RFQ Sent"},
{Value: "to approve", Label: "To Approve"},
{Value: "purchase", Label: "Purchase Order"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Readonly: true, Index: true}),
orm.Selection("priority", []orm.SelectionItem{
{Value: "0", Label: "Normal"},
{Value: "1", Label: "Urgent"},
}, orm.FieldOpts{String: "Priority", Default: "0", Index: true}),
)
// -- Partner & Dates --
m.AddFields(
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Vendor", Required: true, Index: true,
}),
orm.Datetime("date_order", orm.FieldOpts{
String: "Order Deadline", Required: true, Index: true,
}),
orm.Datetime("date_planned", orm.FieldOpts{
String: "Expected Arrival",
}),
orm.Datetime("date_approve", orm.FieldOpts{
String: "Confirmation Date", Readonly: true, Index: true,
}),
)
// -- Company & Currency --
m.AddFields(
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
String: "Currency", Required: true,
}),
)
// -- Lines --
m.AddFields(
orm.One2many("order_line", "purchase.order.line", "order_id", orm.FieldOpts{
String: "Order Lines",
}),
)
// -- Amounts --
m.AddFields(
orm.Monetary("amount_untaxed", orm.FieldOpts{
String: "Untaxed Amount", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
orm.Monetary("amount_tax", orm.FieldOpts{
String: "Taxes", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
orm.Monetary("amount_total", orm.FieldOpts{
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
)
// -- Invoice Status --
m.AddFields(
orm.Selection("invoice_status", []orm.SelectionItem{
{Value: "no", Label: "Nothing to Bill"},
{Value: "to invoice", Label: "Waiting Bills"},
{Value: "invoiced", Label: "Fully Billed"},
}, orm.FieldOpts{String: "Billing Status", Compute: "_compute_invoice_status", Store: true}),
)
// -- Accounting --
m.AddFields(
orm.Many2one("fiscal_position_id", "account.fiscal.position", orm.FieldOpts{
String: "Fiscal Position",
}),
orm.Many2one("payment_term_id", "account.payment.term", orm.FieldOpts{
String: "Payment Terms",
}),
)
// -- Notes --
m.AddFields(
orm.Text("notes", orm.FieldOpts{String: "Terms and Conditions"}),
orm.Char("origin", orm.FieldOpts{String: "Source Document"}),
)
// purchase.order.line — individual line items on a PO
initPurchaseOrderLine()
}
// initPurchaseOrderLine registers purchase.order.line.
// Mirrors: odoo/addons/purchase/models/purchase_order_line.py
func initPurchaseOrderLine() {
m := orm.NewModel("purchase.order.line", orm.ModelOpts{
Description: "Purchase Order Line",
Order: "order_id, sequence, id",
})
// -- Parent --
m.AddFields(
orm.Many2one("order_id", "purchase.order", orm.FieldOpts{
String: "Order Reference", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
// -- Product --
m.AddFields(
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Index: true,
}),
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
orm.Float("product_qty", orm.FieldOpts{String: "Quantity", Required: true, Default: 1.0}),
orm.Many2one("product_uom", "uom.uom", orm.FieldOpts{
String: "Unit of Measure", Required: true,
}),
)
// -- Pricing --
m.AddFields(
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price", Required: true}),
orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{String: "Taxes"}),
orm.Float("discount", orm.FieldOpts{String: "Discount (%)", Default: 0.0}),
orm.Monetary("price_subtotal", orm.FieldOpts{
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
orm.Monetary("price_total", orm.FieldOpts{
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
String: "Currency",
}),
)
// -- Dates --
m.AddFields(
orm.Datetime("date_planned", orm.FieldOpts{String: "Expected Arrival"}),
)
// -- Quantities --
m.AddFields(
orm.Float("qty_received", orm.FieldOpts{
String: "Received Qty", Compute: "_compute_qty_received", Store: true,
}),
orm.Float("qty_invoiced", orm.FieldOpts{
String: "Billed Qty", Compute: "_compute_qty_invoiced", Store: true,
}),
orm.Float("qty_to_invoice", orm.FieldOpts{
String: "To Invoice Quantity", Compute: "_compute_qty_to_invoice", Store: true,
}),
)
// -- Company --
m.AddFields(
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Index: true,
}),
)
}

22
addons/purchase/module.go Normal file
View File

@@ -0,0 +1,22 @@
// Package purchase implements Odoo's purchase management module.
// Mirrors: odoo/addons/purchase/__manifest__.py
package purchase
import (
"odoo-go/addons/purchase/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "purchase",
Description: "Purchase",
Version: "19.0.1.0.0",
Category: "Inventory/Purchase",
Depends: []string{"base", "account", "product", "stock"},
Application: true,
Installable: true,
Sequence: 30,
Init: models.Init,
})
}

View File

@@ -0,0 +1,6 @@
package models
func Init() {
initSaleOrder()
initSaleOrderLine()
}

View File

@@ -0,0 +1,315 @@
package models
import (
"fmt"
"odoo-go/pkg/orm"
)
// initSaleOrder registers sale.order — the sales quotation / order model.
// Mirrors: odoo/addons/sale/models/sale_order.py
func initSaleOrder() {
m := orm.NewModel("sale.order", orm.ModelOpts{
Description: "Sales Order",
Order: "date_order desc, id desc",
})
// -- Identity & State --
m.AddFields(
orm.Char("name", orm.FieldOpts{
String: "Order Reference", Required: true, Index: true, Readonly: true, Default: "/",
}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Quotation"},
{Value: "sent", Label: "Quotation Sent"},
{Value: "sale", Label: "Sales Order"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}),
)
// -- Partners --
m.AddFields(
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Customer", Required: true, Index: true,
}),
orm.Many2one("partner_invoice_id", "res.partner", orm.FieldOpts{
String: "Invoice Address",
}),
orm.Many2one("partner_shipping_id", "res.partner", orm.FieldOpts{
String: "Delivery Address",
}),
)
// -- Dates --
m.AddFields(
orm.Datetime("date_order", orm.FieldOpts{
String: "Order Date", Required: true, Index: true,
}),
orm.Date("validity_date", orm.FieldOpts{String: "Expiration"}),
)
// -- Company & Currency --
m.AddFields(
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
String: "Currency", Required: true,
}),
)
// -- Order Lines --
m.AddFields(
orm.One2many("order_line", "sale.order.line", "order_id", orm.FieldOpts{
String: "Order Lines",
}),
)
// -- Amounts (Computed) --
m.AddFields(
orm.Monetary("amount_untaxed", orm.FieldOpts{
String: "Untaxed Amount", Compute: "_compute_amounts", Store: true, CurrencyField: "currency_id",
}),
orm.Monetary("amount_tax", orm.FieldOpts{
String: "Taxes", Compute: "_compute_amounts", Store: true, CurrencyField: "currency_id",
}),
orm.Monetary("amount_total", orm.FieldOpts{
String: "Total", Compute: "_compute_amounts", Store: true, CurrencyField: "currency_id",
}),
)
// -- Invoice Status --
m.AddFields(
orm.Selection("invoice_status", []orm.SelectionItem{
{Value: "upselling", Label: "Upselling Opportunity"},
{Value: "invoiced", Label: "Fully Invoiced"},
{Value: "to invoice", Label: "To Invoice"},
{Value: "no", Label: "Nothing to Invoice"},
}, orm.FieldOpts{String: "Invoice Status", Compute: "_compute_invoice_status", Store: true}),
)
// -- Accounting & Terms --
m.AddFields(
orm.Many2one("fiscal_position_id", "account.fiscal.position", orm.FieldOpts{
String: "Fiscal Position",
}),
orm.Many2one("payment_term_id", "account.payment.term", orm.FieldOpts{
String: "Payment Terms",
}),
orm.Many2one("pricelist_id", "product.pricelist", orm.FieldOpts{
String: "Pricelist",
}),
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{
String: "Invoicing Journal",
}),
)
// -- Misc --
m.AddFields(
orm.Text("note", orm.FieldOpts{String: "Terms and Conditions"}),
orm.Boolean("require_signature", orm.FieldOpts{String: "Online Signature", Default: true}),
orm.Boolean("require_payment", orm.FieldOpts{String: "Online Payment"}),
)
// -- Sequence Hook --
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
name, _ := vals["name"].(string)
if name == "" || name == "/" {
seq, err := orm.NextByCode(env, "sale.order")
if err == nil {
vals["name"] = seq
}
}
return nil
}
// -- Business Methods --
// action_confirm: draft → sale
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_confirm()
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM sale_order WHERE id = $1`, id).Scan(&state)
if err != nil {
return nil, err
}
if state != "draft" && state != "sent" {
return nil, fmt.Errorf("sale: can only confirm draft/sent orders (current: %s)", state)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET state = 'sale' WHERE id = $1`, id); err != nil {
return nil, err
}
}
return true, nil
})
// create_invoices: Generate invoice from confirmed sale order
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._create_invoices()
m.RegisterMethod("create_invoices", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
var invoiceIDs []int64
for _, soID := range rs.IDs() {
// Read SO header
var partnerID, companyID, currencyID int64
var journalID int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT partner_id, company_id, currency_id, COALESCE(journal_id, 1)
FROM sale_order WHERE id = $1`, soID,
).Scan(&partnerID, &companyID, &currencyID, &journalID)
if err != nil {
return nil, fmt.Errorf("sale: read SO %d: %w", soID, err)
}
// Read SO lines
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id, COALESCE(name,''), COALESCE(product_uom_qty,1), COALESCE(price_unit,0), COALESCE(discount,0)
FROM sale_order_line
WHERE order_id = $1 AND (display_type IS NULL OR display_type = '')
ORDER BY sequence, id`, soID)
if err != nil {
return nil, err
}
type soLine struct {
id int64
name string
qty float64
price float64
discount float64
}
var lines []soLine
for rows.Next() {
var l soLine
if err := rows.Scan(&l.id, &l.name, &l.qty, &l.price, &l.discount); err != nil {
rows.Close()
return nil, err
}
lines = append(lines, l)
}
rows.Close()
if len(lines) == 0 {
continue
}
// Build invoice line commands
var lineCmds []interface{}
for _, l := range lines {
subtotal := l.qty * l.price * (1 - l.discount/100)
lineCmds = append(lineCmds, []interface{}{
float64(0), float64(0), map[string]interface{}{
"name": l.name,
"quantity": l.qty,
"price_unit": l.price,
"discount": l.discount,
"debit": subtotal,
"credit": float64(0),
"account_id": float64(2), // Revenue account
"company_id": float64(companyID),
},
})
// Receivable counter-entry
lineCmds = append(lineCmds, []interface{}{
float64(0), float64(0), map[string]interface{}{
"name": "Receivable",
"debit": float64(0),
"credit": subtotal,
"account_id": float64(1), // Receivable account
"company_id": float64(companyID),
},
})
}
// Create invoice
invoiceRS := env.Model("account.move")
inv, err := invoiceRS.Create(orm.Values{
"move_type": "out_invoice",
"partner_id": partnerID,
"company_id": companyID,
"currency_id": currencyID,
"journal_id": journalID,
"invoice_origin": fmt.Sprintf("SO%d", soID),
"date": "2026-03-30", // TODO: use current date
"line_ids": lineCmds,
})
if err != nil {
return nil, fmt.Errorf("sale: create invoice for SO %d: %w", soID, err)
}
invoiceIDs = append(invoiceIDs, inv.ID())
// Update SO invoice_status
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET invoice_status = 'invoiced' WHERE id = $1`, soID)
}
return invoiceIDs, nil
})
}
// initSaleOrderLine registers sale.order.line — individual line items on a sales order.
// Mirrors: odoo/addons/sale/models/sale_order_line.py
func initSaleOrderLine() {
m := orm.NewModel("sale.order.line", orm.ModelOpts{
Description: "Sales Order Line",
Order: "order_id, sequence, id",
})
// -- Parent --
m.AddFields(
orm.Many2one("order_id", "sale.order", orm.FieldOpts{
String: "Order Reference", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
)
// -- Product --
m.AddFields(
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product",
}),
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
orm.Float("product_uom_qty", orm.FieldOpts{String: "Quantity", Required: true, Default: 1.0}),
orm.Many2one("product_uom", "uom.uom", orm.FieldOpts{String: "Unit of Measure"}),
)
// -- Pricing --
m.AddFields(
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price", Required: true}),
orm.Many2many("tax_id", "account.tax", orm.FieldOpts{String: "Taxes"}),
orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
orm.Monetary("price_subtotal", orm.FieldOpts{
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
orm.Monetary("price_total", orm.FieldOpts{
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
)
// -- Display --
m.AddFields(
orm.Selection("display_type", []orm.SelectionItem{
{Value: "line_section", Label: "Section"},
{Value: "line_note", Label: "Note"},
}, orm.FieldOpts{String: "Display Type"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
// -- Delivery & Invoicing Quantities --
m.AddFields(
orm.Float("qty_delivered", orm.FieldOpts{String: "Delivered Quantity"}),
orm.Float("qty_invoiced", orm.FieldOpts{
String: "Invoiced Quantity", Compute: "_compute_qty_invoiced", Store: true,
}),
orm.Float("qty_to_invoice", orm.FieldOpts{
String: "To Invoice Quantity", Compute: "_compute_qty_to_invoice", Store: true,
}),
orm.Many2many("invoice_lines", "account.move.line", orm.FieldOpts{
String: "Invoice Lines",
}),
)
}

22
addons/sale/module.go Normal file
View File

@@ -0,0 +1,22 @@
// Package sale implements Odoo's sales management module.
// Mirrors: odoo/addons/sale/__manifest__.py
package sale
import (
"odoo-go/addons/sale/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "sale",
Description: "Sales",
Version: "19.0.1.0.0",
Category: "Sales/Sales",
Depends: []string{"base", "account", "product"},
Application: true,
Installable: true,
Sequence: 20,
Init: models.Init,
})
}

View File

@@ -0,0 +1,5 @@
package models
func Init() {
initStock()
}

View File

@@ -0,0 +1,371 @@
package models
import "odoo-go/pkg/orm"
// initStock registers all stock models.
// Mirrors: odoo/addons/stock/models/stock_warehouse.py,
// stock_location.py, stock_picking.py, stock_move.py,
// stock_move_line.py, stock_quant.py, stock_lot.py
func initStock() {
initStockWarehouse()
initStockLocation()
initStockPickingType()
initStockPicking()
initStockMove()
initStockMoveLine()
initStockQuant()
initStockLot()
}
// initStockWarehouse registers stock.warehouse.
// Mirrors: odoo/addons/stock/models/stock_warehouse.py
func initStockWarehouse() {
m := orm.NewModel("stock.warehouse", orm.ModelOpts{
Description: "Warehouse",
Order: "sequence, id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Warehouse", Required: true, Index: true}),
orm.Char("code", orm.FieldOpts{String: "Short Name", Required: true, Size: 5}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Address",
}),
orm.Many2one("lot_stock_id", "stock.location", orm.FieldOpts{
String: "Location Stock", Required: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
}
// initStockLocation registers stock.location.
// Mirrors: odoo/addons/stock/models/stock_location.py
func initStockLocation() {
m := orm.NewModel("stock.location", orm.ModelOpts{
Description: "Location",
Order: "complete_name, id",
RecName: "complete_name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Location Name", Required: true, Translate: true}),
orm.Char("complete_name", orm.FieldOpts{
String: "Full Location Name", Compute: "_compute_complete_name", Store: true,
}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Selection("usage", []orm.SelectionItem{
{Value: "supplier", Label: "Vendor Location"},
{Value: "view", Label: "View"},
{Value: "internal", Label: "Internal Location"},
{Value: "customer", Label: "Customer Location"},
{Value: "inventory", Label: "Inventory Loss"},
{Value: "production", Label: "Production"},
{Value: "transit", Label: "Transit Location"},
}, orm.FieldOpts{String: "Location Type", Required: true, Default: "internal", Index: true}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "Parent Location", Index: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Index: true,
}),
orm.Many2one("removal_strategy_id", "product.removal", orm.FieldOpts{
String: "Removal Strategy",
}),
orm.Boolean("scrap_location", orm.FieldOpts{String: "Is a Scrap Location?"}),
orm.Boolean("return_location", orm.FieldOpts{String: "Is a Return Location?"}),
orm.Char("barcode", orm.FieldOpts{String: "Barcode", Index: true}),
)
}
// initStockPickingType registers stock.picking.type.
// Mirrors: odoo/addons/stock/models/stock_picking.py StockPickingType
func initStockPickingType() {
m := orm.NewModel("stock.picking.type", orm.ModelOpts{
Description: "Picking Type",
Order: "sequence, id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Operation Type", Required: true, Translate: true}),
orm.Selection("code", []orm.SelectionItem{
{Value: "incoming", Label: "Receipt"},
{Value: "outgoing", Label: "Delivery"},
{Value: "internal", Label: "Internal Transfer"},
}, orm.FieldOpts{String: "Type of Operation", Required: true}),
orm.Char("sequence_code", orm.FieldOpts{String: "Code", Required: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("warehouse_id", "stock.warehouse", orm.FieldOpts{
String: "Warehouse", OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("default_location_src_id", "stock.location", orm.FieldOpts{
String: "Default Source Location",
}),
orm.Many2one("default_location_dest_id", "stock.location", orm.FieldOpts{
String: "Default Destination Location",
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Boolean("show_operations", orm.FieldOpts{String: "Show Detailed Operations"}),
orm.Boolean("show_reserved", orm.FieldOpts{String: "Show Reserved"}),
)
}
// initStockPicking registers stock.picking — the transfer order.
// Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking
func initStockPicking() {
m := orm.NewModel("stock.picking", orm.ModelOpts{
Description: "Transfer",
Order: "priority desc, scheduled_date asc, id desc",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{
String: "Reference", Default: "/", Required: true, Index: true, Readonly: true,
}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "waiting", Label: "Waiting Another Operation"},
{Value: "confirmed", Label: "Waiting"},
{Value: "assigned", Label: "Ready"},
{Value: "done", Label: "Done"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Compute: "_compute_state", Store: true, Index: true}),
orm.Selection("priority", []orm.SelectionItem{
{Value: "0", Label: "Normal"},
{Value: "1", Label: "Urgent"},
}, orm.FieldOpts{String: "Priority", Default: "0", Index: true}),
orm.Many2one("picking_type_id", "stock.picking.type", orm.FieldOpts{
String: "Operation Type", Required: true, Index: true,
}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "Source Location", Required: true, Index: true,
}),
orm.Many2one("location_dest_id", "stock.location", orm.FieldOpts{
String: "Destination Location", Required: true, Index: true,
}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Contact", Index: true,
}),
orm.Datetime("scheduled_date", orm.FieldOpts{String: "Scheduled Date", Required: true, Index: true}),
orm.Datetime("date_deadline", orm.FieldOpts{String: "Deadline"}),
orm.Datetime("date_done", orm.FieldOpts{String: "Date of Transfer", Readonly: true}),
orm.One2many("move_ids", "stock.move", "picking_id", orm.FieldOpts{
String: "Stock Moves",
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Text("note", orm.FieldOpts{String: "Notes"}),
orm.Char("origin", orm.FieldOpts{String: "Source Document", Index: true}),
)
}
// initStockMove registers stock.move — individual product movements.
// Mirrors: odoo/addons/stock/models/stock_move.py
func initStockMove() {
m := orm.NewModel("stock.move", orm.ModelOpts{
Description: "Stock Move",
Order: "sequence, id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
orm.Char("reference", orm.FieldOpts{String: "Reference", Index: true}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "New"},
{Value: "confirmed", Label: "Waiting Availability"},
{Value: "partially_available", Label: "Partially Available"},
{Value: "assigned", Label: "Available"},
{Value: "done", Label: "Done"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Index: true}),
orm.Selection("priority", []orm.SelectionItem{
{Value: "0", Label: "Normal"},
{Value: "1", Label: "Urgent"},
}, orm.FieldOpts{String: "Priority", Default: "0"}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true, Index: true,
}),
orm.Float("product_uom_qty", orm.FieldOpts{String: "Demand", Required: true, Default: 1.0}),
orm.Many2one("product_uom", "uom.uom", orm.FieldOpts{
String: "UoM", Required: true,
}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "Source Location", Required: true, Index: true,
}),
orm.Many2one("location_dest_id", "stock.location", orm.FieldOpts{
String: "Destination Location", Required: true, Index: true,
}),
orm.Many2one("picking_id", "stock.picking", orm.FieldOpts{
String: "Transfer", Index: true,
}),
orm.Datetime("date", orm.FieldOpts{String: "Date Scheduled", Required: true, Index: true}),
orm.Datetime("date_deadline", orm.FieldOpts{String: "Deadline"}),
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Char("origin", orm.FieldOpts{String: "Source Document"}),
)
}
// initStockMoveLine registers stock.move.line — detailed operations per lot/package.
// Mirrors: odoo/addons/stock/models/stock_move_line.py
func initStockMoveLine() {
m := orm.NewModel("stock.move.line", orm.ModelOpts{
Description: "Product Moves (Stock Move Line)",
Order: "result_package_id desc, id",
})
m.AddFields(
orm.Many2one("move_id", "stock.move", orm.FieldOpts{
String: "Stock Move", Index: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true, Index: true,
}),
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Required: true, Default: 0.0}),
orm.Many2one("product_uom_id", "uom.uom", orm.FieldOpts{
String: "Unit of Measure", Required: true,
}),
orm.Many2one("lot_id", "stock.lot", orm.FieldOpts{
String: "Lot/Serial Number", Index: true,
}),
orm.Many2one("package_id", "stock.quant.package", orm.FieldOpts{
String: "Source Package",
}),
orm.Many2one("result_package_id", "stock.quant.package", orm.FieldOpts{
String: "Destination Package",
}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "From", Required: true, Index: true,
}),
orm.Many2one("location_dest_id", "stock.location", orm.FieldOpts{
String: "To", Required: true, Index: true,
}),
orm.Many2one("picking_id", "stock.picking", orm.FieldOpts{
String: "Transfer", Index: true,
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Datetime("date", orm.FieldOpts{String: "Date", Required: true}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "New"},
{Value: "confirmed", Label: "Waiting"},
{Value: "assigned", Label: "Reserved"},
{Value: "done", Label: "Done"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
)
}
// initStockQuant registers stock.quant — on-hand inventory quantities.
// Mirrors: odoo/addons/stock/models/stock_quant.py
func initStockQuant() {
m := orm.NewModel("stock.quant", orm.ModelOpts{
Description: "Quants",
Order: "removal_date, in_date, id",
})
m.AddFields(
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true, Index: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "Location", Required: true, Index: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Many2one("lot_id", "stock.lot", orm.FieldOpts{
String: "Lot/Serial Number", Index: true,
}),
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Required: true, Default: 0.0}),
orm.Float("reserved_quantity", orm.FieldOpts{String: "Reserved Quantity", Required: true, Default: 0.0}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Datetime("in_date", orm.FieldOpts{String: "Incoming Date", Index: true}),
orm.Many2one("package_id", "stock.quant.package", orm.FieldOpts{
String: "Package",
}),
orm.Many2one("owner_id", "res.partner", orm.FieldOpts{
String: "Owner",
}),
orm.Datetime("removal_date", orm.FieldOpts{String: "Removal Date"}),
)
// stock.quant.package — physical packages / containers
orm.NewModel("stock.quant.package", orm.ModelOpts{
Description: "Packages",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Package Reference", Required: true, Index: true}),
orm.Many2one("package_type_id", "stock.package.type", orm.FieldOpts{
String: "Package Type",
}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "Location", Index: true,
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Index: true,
}),
orm.Many2one("owner_id", "res.partner", orm.FieldOpts{
String: "Owner",
}),
)
// stock.package.type — packaging types (box, pallet, etc.)
orm.NewModel("stock.package.type", orm.ModelOpts{
Description: "Package Type",
Order: "sequence, id",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Package Type", Required: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
orm.Float("height", orm.FieldOpts{String: "Height"}),
orm.Float("width", orm.FieldOpts{String: "Width"}),
orm.Float("packaging_length", orm.FieldOpts{String: "Length"}),
orm.Float("max_weight", orm.FieldOpts{String: "Max Weight"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
)
// product.removal — removal strategies (FIFO, LIFO, etc.)
orm.NewModel("product.removal", orm.ModelOpts{
Description: "Removal Strategy",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Char("method", orm.FieldOpts{String: "Method", Required: true}),
)
}
// initStockLot registers stock.lot — lot/serial number tracking.
// Mirrors: odoo/addons/stock/models/stock_lot.py
func initStockLot() {
m := orm.NewModel("stock.lot", orm.ModelOpts{
Description: "Lot/Serial",
Order: "name, id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Lot/Serial Number", Required: true, Index: true}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true, Index: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Text("note", orm.FieldOpts{String: "Description"}),
)
}

22
addons/stock/module.go Normal file
View File

@@ -0,0 +1,22 @@
// Package stock implements Odoo's inventory & warehouse management module.
// Mirrors: odoo/addons/stock/__manifest__.py
package stock
import (
"odoo-go/addons/stock/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "stock",
Description: "Inventory",
Version: "19.0.1.0.0",
Category: "Inventory/Inventory",
Depends: []string{"base", "product"},
Application: true,
Installable: true,
Sequence: 20,
Init: models.Init,
})
}

104
cmd/odoo-server/main.go Normal file
View File

@@ -0,0 +1,104 @@
// Odoo Go Server — Main entrypoint
// Mirrors: odoo-bin
//
// Usage:
//
// go run ./cmd/odoo-server
// ODOO_DB_HOST=localhost ODOO_DB_PORT=5432 go run ./cmd/odoo-server
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/jackc/pgx/v5/pgxpool"
// Import all modules (register models via init())
_ "odoo-go/addons/base"
_ "odoo-go/addons/account"
_ "odoo-go/addons/product"
_ "odoo-go/addons/sale"
_ "odoo-go/addons/stock"
_ "odoo-go/addons/purchase"
_ "odoo-go/addons/hr"
_ "odoo-go/addons/project"
_ "odoo-go/addons/crm"
_ "odoo-go/addons/fleet"
_ "odoo-go/addons/l10n_de"
// Google integrations (opt-in, only active with API keys)
_ "odoo-go/addons/google_address"
_ "odoo-go/addons/google_translate"
_ "odoo-go/addons/google_calendar"
"odoo-go/pkg/modules"
"odoo-go/pkg/server"
"odoo-go/pkg/service"
"odoo-go/pkg/tools"
)
func main() {
log.SetFlags(log.Ltime | log.Lshortfile)
// Load configuration
cfg := tools.DefaultConfig()
cfg.LoadFromEnv()
log.Printf("odoo: Odoo Go Server 19.0")
log.Printf("odoo: database: %s@%s:%d/%s", cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle shutdown signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
log.Printf("odoo: received signal %s, shutting down...", sig)
cancel()
}()
// Connect to database
pool, err := pgxpool.New(ctx, cfg.DSN())
if err != nil {
log.Fatalf("odoo: database connection failed: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
log.Fatalf("odoo: database ping failed: %v", err)
}
log.Println("odoo: database connected")
// Load modules (base is auto-registered via init())
log.Println("odoo: loading modules...")
if err := modules.LoadModules(modules.All()); err != nil {
log.Fatalf("odoo: module loading failed: %v", err)
}
log.Printf("odoo: %d modules loaded", len(modules.All()))
// Initialize database schema
log.Println("odoo: initializing database schema...")
if err := service.InitDatabase(ctx, pool); err != nil {
log.Fatalf("odoo: schema init failed: %v", err)
}
// Check if setup is needed (first boot)
if service.NeedsSetup(ctx, pool) {
log.Println("odoo: database is empty — setup wizard will be shown at /web/setup")
} else {
log.Println("odoo: database already initialized")
}
// Start HTTP server
srv := server.New(cfg, pool)
log.Printf("odoo: starting HTTP service on %s:%d", cfg.HTTPInterface, cfg.HTTPPort)
if err := srv.Start(); err != nil {
log.Fatalf("odoo: server error: %v", err)
}
}

39
docker-compose.yml Normal file
View File

@@ -0,0 +1,39 @@
services:
odoo:
build: .
container_name: odoo-go
depends_on:
db:
condition: service_healthy
ports:
- "8069:8069"
environment:
HOST: db
USER: odoo
PASSWORD: odoo
ODOO_DB_NAME: odoo
ODOO_ADDONS_PATH: /opt/odoo-src/addons,/opt/odoo-src/odoo/addons
ODOO_BUILD_DIR: /opt/build/js
volumes:
- ../odoo:/opt/odoo-src:ro
- ./build/js:/opt/build/js:ro
restart: unless-stopped
db:
image: postgres:16-alpine
container_name: odoo-go-db
environment:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: odoo
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U odoo -d odoo"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db-data:

14
go.mod Normal file
View File

@@ -0,0 +1,14 @@
module odoo-go
go 1.22.2
require github.com/jackc/pgx/v5 v5.7.4
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

28
go.sum Normal file
View File

@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

112
pkg/modules/graph.go Normal file
View File

@@ -0,0 +1,112 @@
package modules
import (
"fmt"
"odoo-go/pkg/orm"
)
// ResolveDependencies returns modules in topological order (dependencies first).
// Mirrors: odoo/modules/module_graph.py Graph.add_modules()
//
// Uses Kahn's algorithm for topological sort.
func ResolveDependencies(moduleNames []string) ([]string, error) {
// Build adjacency list and in-degree map
inDegree := make(map[string]int)
dependents := make(map[string][]string) // module → modules that depend on it
// Initialize all requested modules
for _, name := range moduleNames {
if _, exists := inDegree[name]; !exists {
inDegree[name] = 0
}
}
// Build graph from dependencies
for _, name := range moduleNames {
m := Get(name)
if m == nil {
return nil, fmt.Errorf("modules: %q not found", name)
}
for _, dep := range m.Depends {
// Ensure dependency is in our set
if _, exists := inDegree[dep]; !exists {
inDegree[dep] = 0
}
inDegree[name]++
dependents[dep] = append(dependents[dep], name)
}
}
// Kahn's algorithm
var queue []string
for name, degree := range inDegree {
if degree == 0 {
queue = append(queue, name)
}
}
var sorted []string
for len(queue) > 0 {
// Pop first element
current := queue[0]
queue = queue[1:]
sorted = append(sorted, current)
// Reduce in-degree for dependents
for _, dep := range dependents[current] {
inDegree[dep]--
if inDegree[dep] == 0 {
queue = append(queue, dep)
}
}
}
// Check for circular dependencies
if len(sorted) != len(inDegree) {
var circular []string
for name, degree := range inDegree {
if degree > 0 {
circular = append(circular, name)
}
}
return nil, fmt.Errorf("modules: circular dependency detected among: %v", circular)
}
return sorted, nil
}
// LoadModules initializes all modules in dependency order.
// Mirrors: odoo/modules/loading.py load_modules()
func LoadModules(moduleNames []string) error {
sorted, err := ResolveDependencies(moduleNames)
if err != nil {
return err
}
// Phase 1: Call Init() for each module (registers models and fields)
for _, name := range sorted {
m := Get(name)
if m == nil {
continue
}
if m.Init != nil {
m.Init()
}
}
// Phase 2: Call PostInit() after all models are registered
for _, name := range sorted {
m := Get(name)
if m == nil {
continue
}
if m.PostInit != nil {
m.PostInit()
}
}
// Phase 3: Build computed field dependency maps
orm.SetupAllComputes()
return nil
}

76
pkg/modules/module.go Normal file
View File

@@ -0,0 +1,76 @@
// Package modules implements Odoo's module system.
// Mirrors: odoo/modules/module.py, odoo/modules/loading.py
package modules
import (
"fmt"
"sync"
)
// Module represents an Odoo addon module.
// Mirrors: ir.module.module + __manifest__.py
type Module struct {
Name string // Technical name (e.g., "base", "account", "sale")
Description string // Human-readable description
Version string // Module version
Category string // Module category
Depends []string // Required modules (dependency list)
AutoInstall bool // Auto-install when all depends are installed
Application bool // Show in app list
Installable bool // Can be installed
Sequence int // Loading order
// Init function registers models, fields, and methods.
// Called during module loading in dependency order.
Init func()
// PostInit is called after all modules are loaded.
PostInit func()
// Data files to load (SQL seed data, etc.)
Data []string
}
// ModuleRegistry holds all registered modules.
// Mirrors: odoo/modules/module.py loaded modules
var ModuleRegistry = &moduleRegistry{
modules: make(map[string]*Module),
}
type moduleRegistry struct {
mu sync.RWMutex
modules map[string]*Module
order []string // Registration order
}
// Register adds a module to the registry.
func Register(m *Module) {
ModuleRegistry.mu.Lock()
defer ModuleRegistry.mu.Unlock()
if m.Name == "" {
panic("modules: module name is required")
}
if _, exists := ModuleRegistry.modules[m.Name]; exists {
panic(fmt.Sprintf("modules: module %q already registered", m.Name))
}
ModuleRegistry.modules[m.Name] = m
ModuleRegistry.order = append(ModuleRegistry.order, m.Name)
}
// Get returns a module by name.
func Get(name string) *Module {
ModuleRegistry.mu.RLock()
defer ModuleRegistry.mu.RUnlock()
return ModuleRegistry.modules[name]
}
// All returns all registered module names.
func All() []string {
ModuleRegistry.mu.RLock()
defer ModuleRegistry.mu.RUnlock()
result := make([]string, len(ModuleRegistry.order))
copy(result, ModuleRegistry.order)
return result
}

68
pkg/orm/command.go Normal file
View File

@@ -0,0 +1,68 @@
package orm
// Command represents an ORM write command for One2many/Many2many fields.
// Mirrors: odoo/orm/fields.py Command class
//
// Odoo uses a tuple-based command system:
//
// (0, 0, {values}) → CREATE: create new record
// (1, id, {values}) → UPDATE: update existing record
// (2, id, 0) → DELETE: delete record
// (3, id, 0) → UNLINK: remove link (M2M only)
// (4, id, 0) → LINK: add link (M2M only)
// (5, 0, 0) → CLEAR: remove all links (M2M only)
// (6, 0, [ids]) → SET: replace all links (M2M only)
type Command struct {
Operation CommandOp
ID int64
Values Values
IDs []int64 // For SET command
}
// CommandOp is the command operation type.
type CommandOp int
const (
CommandCreate CommandOp = 0
CommandUpdate CommandOp = 1
CommandDelete CommandOp = 2
CommandUnlink CommandOp = 3
CommandLink CommandOp = 4
CommandClear CommandOp = 5
CommandSet CommandOp = 6
)
// CmdCreate returns a CREATE command. Mirrors: Command.create(values)
func CmdCreate(values Values) Command {
return Command{Operation: CommandCreate, Values: values}
}
// CmdUpdate returns an UPDATE command. Mirrors: Command.update(id, values)
func CmdUpdate(id int64, values Values) Command {
return Command{Operation: CommandUpdate, ID: id, Values: values}
}
// CmdDelete returns a DELETE command. Mirrors: Command.delete(id)
func CmdDelete(id int64) Command {
return Command{Operation: CommandDelete, ID: id}
}
// CmdUnlink returns an UNLINK command. Mirrors: Command.unlink(id)
func CmdUnlink(id int64) Command {
return Command{Operation: CommandUnlink, ID: id}
}
// CmdLink returns a LINK command. Mirrors: Command.link(id)
func CmdLink(id int64) Command {
return Command{Operation: CommandLink, ID: id}
}
// CmdClear returns a CLEAR command. Mirrors: Command.clear()
func CmdClear() Command {
return Command{Operation: CommandClear}
}
// CmdSet returns a SET command. Mirrors: Command.set(ids)
func CmdSet(ids []int64) Command {
return Command{Operation: CommandSet, IDs: ids}
}

204
pkg/orm/compute.go Normal file
View File

@@ -0,0 +1,204 @@
package orm
import "fmt"
// ComputeFunc is a function that computes field values for a recordset.
// Mirrors: @api.depends decorated methods in Odoo.
//
// The function receives a singleton recordset and must return a Values map
// with the computed field values.
//
// Example:
//
// func computeAmount(rs *Recordset) (Values, error) {
// total := 0.0
// // ... sum line amounts ...
// return Values{"amount_total": total}, nil
// }
type ComputeFunc func(rs *Recordset) (Values, error)
// RegisterCompute registers a compute function for a field.
// The same function can compute multiple fields (call RegisterCompute for each).
func (m *Model) RegisterCompute(fieldName string, fn ComputeFunc) {
if m.computes == nil {
m.computes = make(map[string]ComputeFunc)
}
m.computes[fieldName] = fn
}
// SetupComputes builds the reverse dependency map for this model.
// Called after all modules are loaded.
//
// For each field with Depends, creates a mapping:
//
// trigger_field → []computed_field_names
//
// So when trigger_field is written, we know which computes to re-run.
func (m *Model) SetupComputes() {
m.dependencyMap = make(map[string][]string)
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.Compute == "" || len(f.Depends) == 0 {
continue
}
for _, dep := range f.Depends {
m.dependencyMap[dep] = append(m.dependencyMap[dep], name)
}
}
}
// RunStoredComputes runs compute functions for stored computed fields
// and merges results into vals. Called before INSERT in Create().
func RunStoredComputes(m *Model, env *Environment, id int64, vals Values) error {
if len(m.computes) == 0 {
return nil
}
// Collect stored computed fields that have registered functions
seen := make(map[string]bool) // Track compute functions already called (by field name)
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.Compute == "" || !f.Store {
continue
}
fn, ok := m.computes[name]
if !ok {
continue
}
// Deduplicate: same function may compute multiple fields
if seen[f.Compute] {
continue
}
seen[f.Compute] = true
// Create a temporary recordset for the computation
rs := &Recordset{env: env, model: m, ids: []int64{id}}
computed, err := fn(rs)
if err != nil {
return fmt.Errorf("orm: compute %s.%s: %w", m.name, name, err)
}
// Merge computed values
for k, v := range computed {
cf := m.GetField(k)
if cf != nil && cf.Store && cf.Compute != "" {
vals[k] = v
}
}
}
return nil
}
// TriggerRecompute re-computes stored fields that depend on written fields.
// Called after UPDATE in Write().
func TriggerRecompute(rs *Recordset, writtenFields Values) error {
m := rs.model
if len(m.dependencyMap) == 0 || len(m.computes) == 0 {
return nil
}
// Find which computed fields need re-computation
toRecompute := make(map[string]bool)
for fieldName := range writtenFields {
for _, computedField := range m.dependencyMap[fieldName] {
toRecompute[computedField] = true
}
}
if len(toRecompute) == 0 {
return nil
}
// Run computes for each record
seen := make(map[string]bool)
for _, id := range rs.IDs() {
singleton := rs.Browse(id)
recomputedVals := make(Values)
for fieldName := range toRecompute {
f := m.GetField(fieldName)
if f == nil || !f.Store {
continue
}
fn, ok := m.computes[fieldName]
if !ok {
continue
}
if seen[f.Compute] {
continue
}
seen[f.Compute] = true
computed, err := fn(singleton)
if err != nil {
return fmt.Errorf("orm: recompute %s.%s: %w", m.name, fieldName, err)
}
for k, v := range computed {
cf := m.GetField(k)
if cf != nil && cf.Store && cf.Compute != "" {
recomputedVals[k] = v
}
}
}
// Write recomputed values directly to DB (bypass hooks to avoid infinite loop)
if len(recomputedVals) > 0 {
if err := writeDirectNohook(rs.env, m, id, recomputedVals); err != nil {
return err
}
}
// Reset seen for next record
seen = make(map[string]bool)
}
return nil
}
// writeDirectNohook writes values directly without triggering hooks or recomputes.
func writeDirectNohook(env *Environment, m *Model, id int64, vals Values) error {
var setClauses []string
var args []interface{}
idx := 1
for k, v := range vals {
f := m.GetField(k)
if f == nil || !f.IsStored() {
continue
}
setClauses = append(setClauses, fmt.Sprintf("%q = $%d", f.Column(), idx))
args = append(args, v)
idx++
}
if len(setClauses) == 0 {
return nil
}
args = append(args, id)
query := fmt.Sprintf(
`UPDATE %q SET %s WHERE "id" = $%d`,
m.Table(), joinStrings(setClauses, ", "), idx,
)
_, err := env.tx.Exec(env.ctx, query, args...)
return err
}
func joinStrings(s []string, sep string) string {
result := ""
for i, str := range s {
if i > 0 {
result += sep
}
result += str
}
return result
}
// SetupAllComputes calls SetupComputes on all registered models.
func SetupAllComputes() {
for _, m := range Registry.Models() {
m.SetupComputes()
}
}

429
pkg/orm/domain.go Normal file
View File

@@ -0,0 +1,429 @@
package orm
import (
"fmt"
"strings"
)
// Domain represents a search filter expression.
// Mirrors: odoo/orm/domains.py Domain class
//
// Odoo uses prefix (Polish) notation:
//
// ['&', ('name', 'ilike', 'test'), ('active', '=', True)]
//
// Go equivalent:
//
// And(Leaf("name", "ilike", "test"), Leaf("active", "=", true))
type Domain []DomainNode
// DomainNode is either an Operator or a Condition (leaf).
type DomainNode interface {
isDomainNode()
}
// Operator is a logical operator in a domain expression.
// Mirrors: odoo/orm/domains.py DOMAIN_OPERATORS
type Operator string
const (
OpAnd Operator = "&"
OpOr Operator = "|"
OpNot Operator = "!"
)
func (o Operator) isDomainNode() {}
// Condition is a leaf node in a domain expression.
// Mirrors: odoo/orm/domains.py DomainLeaf
//
// Odoo: ('field_name', 'operator', value)
type Condition struct {
Field string // Field name (supports dot notation: "partner_id.name")
Operator string // Comparison operator
Value Value // Comparison value
}
func (c Condition) isDomainNode() {}
// Valid comparison operators.
// Mirrors: odoo/orm/domains.py COMPARISON_OPERATORS
var validOperators = map[string]bool{
"=": true, "!=": true,
"<": true, ">": true, "<=": true, ">=": true,
"in": true, "not in": true,
"like": true, "not like": true,
"ilike": true, "not ilike": true,
"=like": true, "=ilike": true,
"any": true, "not any": true,
"child_of": true, "parent_of": true,
}
// Leaf creates a domain condition (leaf node).
func Leaf(field, operator string, value Value) Condition {
return Condition{Field: field, Operator: operator, Value: value}
}
// And combines conditions with AND (default in Odoo).
func And(nodes ...DomainNode) Domain {
if len(nodes) == 0 {
return nil
}
if len(nodes) == 1 {
return Domain{nodes[0]}
}
result := Domain{}
for i := 0; i < len(nodes)-1; i++ {
result = append(result, OpAnd)
}
result = append(result, nodes...)
return result
}
// Or combines conditions with OR.
func Or(nodes ...DomainNode) Domain {
if len(nodes) == 0 {
return nil
}
if len(nodes) == 1 {
return Domain{nodes[0]}
}
result := Domain{}
for i := 0; i < len(nodes)-1; i++ {
result = append(result, OpOr)
}
result = append(result, nodes...)
return result
}
// Not negates a condition.
func Not(node DomainNode) Domain {
return Domain{OpNot, node}
}
// DomainCompiler compiles a Domain to SQL WHERE clause.
// Mirrors: odoo/orm/domains.py Domain._to_sql()
type DomainCompiler struct {
model *Model
params []interface{}
joins []joinClause
aliasCounter int
}
type joinClause struct {
table string
alias string
on string
}
// CompileResult holds the compiled SQL WHERE clause, JOINs, and parameters.
type CompileResult struct {
Where string
Joins string
Params []interface{}
}
// Compile converts a domain to a SQL WHERE clause with parameters and JOINs.
func (dc *DomainCompiler) Compile(domain Domain) (string, []interface{}, error) {
if len(domain) == 0 {
return "TRUE", nil, nil
}
dc.params = nil
dc.joins = nil
dc.aliasCounter = 0
sql, err := dc.compileNodes(domain, 0)
if err != nil {
return "", nil, err
}
return sql, dc.params, nil
}
// JoinSQL returns the SQL JOIN clauses generated during compilation.
func (dc *DomainCompiler) JoinSQL() string {
if len(dc.joins) == 0 {
return ""
}
var parts []string
for _, j := range dc.joins {
parts = append(parts, fmt.Sprintf("LEFT JOIN %q AS %q ON %s", j.table, j.alias, j.on))
}
return " " + strings.Join(parts, " ")
}
func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
if pos >= len(domain) {
return "TRUE", nil
}
node := domain[pos]
switch n := node.(type) {
case Operator:
switch n {
case OpAnd:
left, err := dc.compileNodes(domain, pos+1)
if err != nil {
return "", err
}
right, err := dc.compileNodes(domain, pos+2)
if err != nil {
return "", err
}
return fmt.Sprintf("(%s AND %s)", left, right), nil
case OpOr:
left, err := dc.compileNodes(domain, pos+1)
if err != nil {
return "", err
}
right, err := dc.compileNodes(domain, pos+2)
if err != nil {
return "", err
}
return fmt.Sprintf("(%s OR %s)", left, right), nil
case OpNot:
inner, err := dc.compileNodes(domain, pos+1)
if err != nil {
return "", err
}
return fmt.Sprintf("(NOT %s)", inner), nil
}
case Condition:
return dc.compileCondition(n)
}
return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node)
}
func (dc *DomainCompiler) compileCondition(c Condition) (string, error) {
if !validOperators[c.Operator] {
return "", fmt.Errorf("invalid operator: %q", c.Operator)
}
// Handle dot notation (e.g., "partner_id.name")
parts := strings.Split(c.Field, ".")
column := parts[0]
// TODO: Handle JOINs for dot notation paths
// For now, only support direct fields
if len(parts) > 1 {
// Placeholder for JOIN resolution
return dc.compileJoinedCondition(parts, c.Operator, c.Value)
}
return dc.compileSimpleCondition(column, c.Operator, c.Value)
}
func (dc *DomainCompiler) compileSimpleCondition(column, operator string, value Value) (string, error) {
paramIdx := len(dc.params) + 1
switch operator {
case "=", "!=", "<", ">", "<=", ">=":
if value == nil || value == false {
if operator == "=" {
return fmt.Sprintf("%q IS NULL", column), nil
}
return fmt.Sprintf("%q IS NOT NULL", column), nil
}
dc.params = append(dc.params, value)
return fmt.Sprintf("%q %s $%d", column, operator, paramIdx), nil
case "in":
vals := normalizeSlice(value)
if vals == nil {
return "", fmt.Errorf("'in' operator requires a slice value")
}
if len(vals) == 0 {
return "FALSE", nil
}
placeholders := make([]string, len(vals))
for i, v := range vals {
dc.params = append(dc.params, v)
placeholders[i] = fmt.Sprintf("$%d", paramIdx+i)
}
return fmt.Sprintf("%q IN (%s)", column, strings.Join(placeholders, ", ")), nil
case "not in":
vals := normalizeSlice(value)
if vals == nil {
return "", fmt.Errorf("'not in' operator requires a slice value")
}
if len(vals) == 0 {
return "TRUE", nil
}
placeholders := make([]string, len(vals))
for i, v := range vals {
dc.params = append(dc.params, v)
placeholders[i] = fmt.Sprintf("$%d", paramIdx+i)
}
return fmt.Sprintf("%q NOT IN (%s)", column, strings.Join(placeholders, ", ")), nil
case "like":
dc.params = append(dc.params, value)
return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil
case "not like":
dc.params = append(dc.params, value)
return fmt.Sprintf("%q NOT LIKE $%d", column, paramIdx), nil
case "ilike":
dc.params = append(dc.params, value)
return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil
case "not ilike":
dc.params = append(dc.params, value)
return fmt.Sprintf("%q NOT ILIKE $%d", column, paramIdx), nil
case "=like":
dc.params = append(dc.params, value)
return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil
case "=ilike":
dc.params = append(dc.params, value)
return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil
default:
return "", fmt.Errorf("unhandled operator: %q", operator)
}
}
// compileJoinedCondition resolves dot-notation paths (e.g., "partner_id.country_id.code")
// by generating LEFT JOINs through the relational chain.
func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator string, value Value) (string, error) {
currentModel := dc.model
currentAlias := dc.model.Table()
// Walk the path: each segment except the last is a Many2one FK to JOIN through
for i := 0; i < len(fieldPath)-1; i++ {
fieldName := fieldPath[i]
f := currentModel.GetField(fieldName)
if f == nil {
return "", fmt.Errorf("field %q not found on %s", fieldName, currentModel.Name())
}
if f.Type != TypeMany2one {
return "", fmt.Errorf("field %q on %s is not Many2one, cannot traverse", fieldName, currentModel.Name())
}
comodel := Registry.Get(f.Comodel)
if comodel == nil {
return "", fmt.Errorf("comodel %q not found for field %q", f.Comodel, fieldName)
}
// Generate alias and JOIN
dc.aliasCounter++
alias := fmt.Sprintf("_j%d", dc.aliasCounter)
dc.joins = append(dc.joins, joinClause{
table: comodel.Table(),
alias: alias,
on: fmt.Sprintf("%s.%q = %q.\"id\"", currentAlias, f.Column(), alias),
})
currentModel = comodel
currentAlias = alias
}
// The last segment is the actual field to filter on
leafField := fieldPath[len(fieldPath)-1]
qualifiedColumn := fmt.Sprintf("%s.%q", currentAlias, leafField)
return dc.compileQualifiedCondition(qualifiedColumn, operator, value)
}
// compileQualifiedCondition compiles a condition with a fully qualified column (alias.column).
func (dc *DomainCompiler) compileQualifiedCondition(qualifiedColumn, operator string, value Value) (string, error) {
paramIdx := len(dc.params) + 1
switch operator {
case "=", "!=", "<", ">", "<=", ">=":
if value == nil || value == false {
if operator == "=" {
return fmt.Sprintf("%s IS NULL", qualifiedColumn), nil
}
return fmt.Sprintf("%s IS NOT NULL", qualifiedColumn), nil
}
dc.params = append(dc.params, value)
return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil
case "in", "not in":
vals := normalizeSlice(value)
if vals == nil {
return "FALSE", nil
}
if len(vals) == 0 {
if operator == "in" {
return "FALSE", nil
}
return "TRUE", nil
}
placeholders := make([]string, len(vals))
for i, v := range vals {
dc.params = append(dc.params, v)
placeholders[i] = fmt.Sprintf("$%d", paramIdx+i)
}
op := "IN"
if operator == "not in" {
op = "NOT IN"
}
return fmt.Sprintf("%s %s (%s)", qualifiedColumn, op, strings.Join(placeholders, ", ")), nil
case "like", "not like", "ilike", "not ilike", "=like", "=ilike":
dc.params = append(dc.params, value)
sqlOp := strings.ToUpper(strings.TrimPrefix(operator, "="))
if strings.HasPrefix(operator, "=") {
sqlOp = strings.ToUpper(operator[1:])
}
switch operator {
case "like":
sqlOp = "LIKE"
case "not like":
sqlOp = "NOT LIKE"
case "ilike", "=ilike":
sqlOp = "ILIKE"
case "not ilike":
sqlOp = "NOT ILIKE"
case "=like":
sqlOp = "LIKE"
}
return fmt.Sprintf("%s %s $%d", qualifiedColumn, sqlOp, paramIdx), nil
default:
dc.params = append(dc.params, value)
return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil
}
}
// normalizeSlice converts typed slices to []interface{} for IN/NOT IN operators.
func normalizeSlice(value Value) []interface{} {
switch v := value.(type) {
case []interface{}:
return v
case []int64:
out := make([]interface{}, len(v))
for i, x := range v {
out[i] = x
}
return out
case []float64:
out := make([]interface{}, len(v))
for i, x := range v {
out[i] = x
}
return out
case []string:
out := make([]interface{}, len(v))
for i, x := range v {
out[i] = x
}
return out
case []int:
out := make([]interface{}, len(v))
for i, x := range v {
out[i] = x
}
return out
}
return nil
}

257
pkg/orm/environment.go Normal file
View File

@@ -0,0 +1,257 @@
package orm
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Environment is the central context for all ORM operations.
// Mirrors: odoo/orm/environments.py Environment
//
// Odoo: self.env['res.partner'].search([('name', 'ilike', 'test')])
// Go: env.Model("res.partner").Search(And(Leaf("name", "ilike", "test")))
//
// An Environment wraps:
// - A database transaction (cursor in Odoo terms)
// - The current user ID
// - The current company ID
// - Context values (lang, tz, etc.)
type Environment struct {
ctx context.Context
pool *pgxpool.Pool
tx pgx.Tx
uid int64
companyID int64
su bool // sudo mode (bypass access checks)
context map[string]interface{}
cache *Cache
}
// EnvConfig configures a new Environment.
type EnvConfig struct {
Pool *pgxpool.Pool
UID int64
CompanyID int64
Context map[string]interface{}
}
// NewEnvironment creates a new ORM environment.
// Mirrors: odoo.api.Environment(cr, uid, context)
func NewEnvironment(ctx context.Context, cfg EnvConfig) (*Environment, error) {
tx, err := cfg.Pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("orm: begin transaction: %w", err)
}
envCtx := cfg.Context
if envCtx == nil {
envCtx = make(map[string]interface{})
}
return &Environment{
ctx: ctx,
pool: cfg.Pool,
tx: tx,
uid: cfg.UID,
companyID: cfg.CompanyID,
context: envCtx,
cache: NewCache(),
}, nil
}
// Model returns a Recordset bound to this environment for the given model.
// Mirrors: self.env['model.name']
func (env *Environment) Model(name string) *Recordset {
m := Registry.MustGet(name)
return &Recordset{
env: env,
model: m,
ids: nil,
}
}
// Ref returns a record by its XML ID (external identifier).
// Mirrors: self.env.ref('module.xml_id')
func (env *Environment) Ref(xmlID string) (*Recordset, error) {
// Query ir_model_data for the external ID
var resModel string
var resID int64
err := env.tx.QueryRow(env.ctx,
`SELECT model, res_id FROM ir_model_data WHERE module || '.' || name = $1 LIMIT 1`,
xmlID,
).Scan(&resModel, &resID)
if err != nil {
return nil, fmt.Errorf("orm: ref %q not found: %w", xmlID, err)
}
return env.Model(resModel).Browse(resID), nil
}
// UID returns the current user ID.
func (env *Environment) UID() int64 { return env.uid }
// CompanyID returns the current company ID.
func (env *Environment) CompanyID() int64 { return env.companyID }
// IsSuperuser returns true if this environment bypasses access checks.
func (env *Environment) IsSuperuser() bool { return env.su }
// Context returns the environment context (Odoo-style key-value context).
func (env *Environment) Context() map[string]interface{} { return env.context }
// Ctx returns the Go context.Context for database operations.
func (env *Environment) Ctx() context.Context { return env.ctx }
// Lang returns the language from context.
func (env *Environment) Lang() string {
if lang, ok := env.context["lang"].(string); ok {
return lang
}
return "en_US"
}
// Tx returns the underlying database transaction.
func (env *Environment) Tx() pgx.Tx { return env.tx }
// Sudo returns a new environment with superuser privileges.
// Mirrors: self.env['model'].sudo()
func (env *Environment) Sudo() *Environment {
return &Environment{
ctx: env.ctx,
pool: env.pool,
tx: env.tx,
uid: env.uid,
companyID: env.companyID,
su: true,
context: env.context,
cache: env.cache,
}
}
// WithUser returns a new environment for a different user.
// Mirrors: self.with_user(user_id)
func (env *Environment) WithUser(uid int64) *Environment {
return &Environment{
ctx: env.ctx,
pool: env.pool,
tx: env.tx,
uid: uid,
companyID: env.companyID,
su: false,
context: env.context,
cache: env.cache,
}
}
// WithCompany returns a new environment for a different company.
// Mirrors: self.with_company(company_id)
func (env *Environment) WithCompany(companyID int64) *Environment {
return &Environment{
ctx: env.ctx,
pool: env.pool,
tx: env.tx,
uid: env.uid,
companyID: companyID,
su: env.su,
context: env.context,
cache: env.cache,
}
}
// WithContext returns a new environment with additional context values.
// Mirrors: self.with_context(key=value)
func (env *Environment) WithContext(extra map[string]interface{}) *Environment {
merged := make(map[string]interface{}, len(env.context)+len(extra))
for k, v := range env.context {
merged[k] = v
}
for k, v := range extra {
merged[k] = v
}
return &Environment{
ctx: env.ctx,
pool: env.pool,
tx: env.tx,
uid: env.uid,
companyID: env.companyID,
su: env.su,
context: merged,
cache: env.cache,
}
}
// Commit commits the database transaction.
func (env *Environment) Commit() error {
return env.tx.Commit(env.ctx)
}
// Rollback rolls back the database transaction.
func (env *Environment) Rollback() error {
return env.tx.Rollback(env.ctx)
}
// Close rolls back uncommitted changes and releases the connection.
func (env *Environment) Close() error {
return env.tx.Rollback(env.ctx)
}
// --- Cache ---
// Cache is a per-environment record cache.
// Mirrors: odoo/orm/environments.py Transaction cache
type Cache struct {
// model_name → record_id → field_name → value
data map[string]map[int64]map[string]Value
}
// NewCache creates an empty cache.
func NewCache() *Cache {
return &Cache{
data: make(map[string]map[int64]map[string]Value),
}
}
// Get retrieves a cached value.
func (c *Cache) Get(model string, id int64, field string) (Value, bool) {
records, ok := c.data[model]
if !ok {
return nil, false
}
fields, ok := records[id]
if !ok {
return nil, false
}
val, ok := fields[field]
return val, ok
}
// Set stores a value in the cache.
func (c *Cache) Set(model string, id int64, field string, value Value) {
records, ok := c.data[model]
if !ok {
records = make(map[int64]map[string]Value)
c.data[model] = records
}
fields, ok := records[id]
if !ok {
fields = make(map[string]Value)
records[id] = fields
}
fields[field] = value
}
// Invalidate removes all cached values for a model.
func (c *Cache) Invalidate(model string) {
delete(c.data, model)
}
// InvalidateRecord removes cached values for a specific record.
func (c *Cache) InvalidateRecord(model string, id int64) {
if records, ok := c.data[model]; ok {
delete(records, id)
}
}

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
}

378
pkg/orm/model.go Normal file
View File

@@ -0,0 +1,378 @@
package orm
import (
"fmt"
"strings"
)
// Model defines an Odoo model (database table + business logic).
// Mirrors: odoo/orm/models.py BaseModel
//
// Odoo:
//
// class ResPartner(models.Model):
// _name = 'res.partner'
// _description = 'Contact'
// _order = 'name'
// _rec_name = 'name'
//
// Go:
//
// NewModel("res.partner", ModelOpts{
// Description: "Contact",
// Order: "name",
// RecName: "name",
// })
type Model struct {
name string
description string
modelType ModelType
table string // SQL table name
order string // Default sort order
recName string // Field used as display name
inherit []string // _inherit: models to extend
inherits map[string]string // _inherits: {parent_model: fk_field}
auto bool // Auto-create/update table schema
logAccess bool // Auto-create create_uid, write_uid, create_date, write_date
// Fields
fields map[string]*Field
// Methods (registered business logic)
methods map[string]interface{}
// Access control
checkCompany bool // Enforce multi-company record rules
// Hooks
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
Constraints []ConstraintFunc // Validation constraints
Methods map[string]MethodFunc // Named business methods
// Computed fields
computes map[string]ComputeFunc // field_name → compute function
dependencyMap map[string][]string // trigger_field → []computed_field_names
// Resolved
parents []*Model // Resolved parent models from _inherit
allFields map[string]*Field // Including fields from parents
fieldOrder []string // Field creation order
}
// ModelOpts configures a new model.
type ModelOpts struct {
Description string
Type ModelType
Table string
Order string
RecName string
Inherit []string
Inherits map[string]string
CheckCompany bool
}
// NewModel creates and registers a new model.
// Mirrors: class MyModel(models.Model): _name = '...'
func NewModel(name string, opts ModelOpts) *Model {
m := &Model{
name: name,
description: opts.Description,
modelType: opts.Type,
table: opts.Table,
order: opts.Order,
recName: opts.RecName,
inherit: opts.Inherit,
inherits: opts.Inherits,
checkCompany: opts.CheckCompany,
auto: opts.Type != ModelAbstract,
logAccess: true,
fields: make(map[string]*Field),
methods: make(map[string]interface{}),
allFields: make(map[string]*Field),
}
// Derive table name from model name if not specified.
// Mirrors: odoo/orm/models.py BaseModel._table
// 'res.partner' → 'res_partner'
if m.table == "" && m.auto {
m.table = strings.ReplaceAll(name, ".", "_")
}
if m.order == "" {
m.order = "id"
}
if m.recName == "" {
m.recName = "name"
}
// Add magic fields.
// Mirrors: odoo/orm/models.py BaseModel.MAGIC_COLUMNS
m.addMagicFields()
Registry.Register(m)
return m
}
// addMagicFields adds Odoo's automatic fields to every model.
// Mirrors: odoo/orm/models.py MAGIC_COLUMNS + LOG_ACCESS_COLUMNS
func (m *Model) addMagicFields() {
// id is always present
m.AddField(Integer("id", FieldOpts{
String: "ID",
Readonly: true,
}))
if m.logAccess {
m.AddField(Many2one("create_uid", "res.users", FieldOpts{
String: "Created by",
Readonly: true,
}))
m.AddField(Datetime("create_date", FieldOpts{
String: "Created on",
Readonly: true,
}))
m.AddField(Many2one("write_uid", "res.users", FieldOpts{
String: "Last Updated by",
Readonly: true,
}))
m.AddField(Datetime("write_date", FieldOpts{
String: "Last Updated on",
Readonly: true,
}))
}
}
// AddField adds a field to this model.
func (m *Model) AddField(f *Field) *Model {
f.model = m
m.fields[f.Name] = f
m.allFields[f.Name] = f
m.fieldOrder = append(m.fieldOrder, f.Name)
return m
}
// AddFields adds multiple fields at once.
func (m *Model) AddFields(fields ...*Field) *Model {
for _, f := range fields {
m.AddField(f)
}
return m
}
// GetField returns a field by name, or nil.
func (m *Model) GetField(name string) *Field {
return m.allFields[name]
}
// Fields returns all fields of this model (including inherited).
func (m *Model) Fields() map[string]*Field {
return m.allFields
}
// StoredFields returns fields that have a database column.
func (m *Model) StoredFields() []*Field {
var result []*Field
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.IsStored() {
result = append(result, f)
}
}
return result
}
// Name returns the model name (e.g., "res.partner").
func (m *Model) Name() string { return m.name }
// Table returns the SQL table name (e.g., "res_partner").
func (m *Model) Table() string { return m.table }
// Description returns the human-readable model description.
func (m *Model) Description() string { return m.description }
// Order returns the default sort expression.
func (m *Model) Order() string { return m.order }
// RecName returns the field name used as display name.
func (m *Model) RecName() string { return m.recName }
// IsAbstract returns true if this model has no database table.
func (m *Model) IsAbstract() bool { return m.modelType == ModelAbstract }
// IsTransient returns true if this is a wizard/temporary model.
func (m *Model) IsTransient() bool { return m.modelType == ModelTransient }
// ConstraintFunc validates a recordset. Returns error if constraint violated.
// Mirrors: @api.constrains in Odoo.
type ConstraintFunc func(rs *Recordset) error
// MethodFunc is a callable business method on a model.
// Mirrors: Odoo model methods called via RPC.
type MethodFunc func(rs *Recordset, args ...interface{}) (interface{}, error)
// AddConstraint registers a validation constraint.
func (m *Model) AddConstraint(fn ConstraintFunc) *Model {
m.Constraints = append(m.Constraints, fn)
return m
}
// RegisterMethod registers a named business logic method on this model.
// Mirrors: Odoo's method definitions on model classes.
func (m *Model) RegisterMethod(name string, fn MethodFunc) *Model {
if m.Methods == nil {
m.Methods = make(map[string]MethodFunc)
}
m.Methods[name] = fn
return m
}
// Extend extends this model with additional fields (like _inherit in Odoo).
// Mirrors: class MyModelExt(models.Model): _inherit = 'res.partner'
func (m *Model) Extend(fields ...*Field) *Model {
for _, f := range fields {
m.AddField(f)
}
return m
}
// CreateTableSQL generates the CREATE TABLE statement for this model.
// Mirrors: odoo/orm/models.py BaseModel._table_exist / init()
func (m *Model) CreateTableSQL() string {
if !m.auto {
return ""
}
var cols []string
cols = append(cols, `"id" SERIAL PRIMARY KEY`)
for _, name := range m.fieldOrder {
f := m.fields[name]
if name == "id" || !f.IsStored() {
continue
}
sqlType := f.SQLType()
if sqlType == "" {
continue
}
col := fmt.Sprintf(" %q %s", f.Column(), sqlType)
if f.Required {
col += " NOT NULL"
}
cols = append(cols, col)
}
return fmt.Sprintf("CREATE TABLE IF NOT EXISTS %q (\n%s\n)",
m.table,
strings.Join(cols, ",\n"),
)
}
// ForeignKeySQL generates ALTER TABLE statements for foreign keys.
func (m *Model) ForeignKeySQL() []string {
var stmts []string
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.Type != TypeMany2one || f.Comodel == "" {
continue
}
if name == "create_uid" || name == "write_uid" {
continue // Skip magic fields, added later
}
comodel := Registry.Get(f.Comodel)
if comodel == nil {
continue
}
onDelete := "SET NULL"
switch f.OnDelete {
case OnDeleteRestrict:
onDelete = "RESTRICT"
case OnDeleteCascade:
onDelete = "CASCADE"
}
stmt := fmt.Sprintf(
`ALTER TABLE %q ADD CONSTRAINT %q FOREIGN KEY (%q) REFERENCES %q ("id") ON DELETE %s`,
m.table,
fmt.Sprintf("%s_%s_fkey", m.table, f.Column()),
f.Column(),
comodel.Table(),
onDelete,
)
stmts = append(stmts, stmt)
}
return stmts
}
// IndexSQL generates CREATE INDEX statements.
func (m *Model) IndexSQL() []string {
var stmts []string
for _, name := range m.fieldOrder {
f := m.fields[name]
if !f.Index || !f.IsStored() || name == "id" {
continue
}
stmt := fmt.Sprintf(
`CREATE INDEX IF NOT EXISTS %q ON %q (%q)`,
fmt.Sprintf("%s_%s_index", m.table, f.Column()),
m.table,
f.Column(),
)
stmts = append(stmts, stmt)
}
return stmts
}
// Many2manyTableSQL generates junction table DDL for Many2many fields.
func (m *Model) Many2manyTableSQL() []string {
var stmts []string
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.Type != TypeMany2many {
continue
}
comodel := Registry.Get(f.Comodel)
if comodel == nil {
continue
}
// Determine junction table name.
// Mirrors: odoo/orm/fields_relational.py Many2many._table_name
relation := f.Relation
if relation == "" {
// Default: alphabetically sorted model tables joined by underscore
t1, t2 := m.table, comodel.Table()
if t1 > t2 {
t1, t2 = t2, t1
}
relation = fmt.Sprintf("%s_%s_rel", t1, t2)
}
col1 := f.Column1
if col1 == "" {
col1 = m.table + "_id"
}
col2 := f.Column2
if col2 == "" {
col2 = comodel.Table() + "_id"
}
// Self-referential M2M: ensure distinct column names
if col1 == col2 {
col1 = m.table + "_src_id"
col2 = m.table + "_dst_id"
}
stmt := fmt.Sprintf(
"CREATE TABLE IF NOT EXISTS %q (\n"+
" %q integer NOT NULL REFERENCES %q(id) ON DELETE CASCADE,\n"+
" %q integer NOT NULL REFERENCES %q(id) ON DELETE CASCADE,\n"+
" PRIMARY KEY (%q, %q)\n"+
")",
relation, col1, m.table, col2, comodel.Table(), col1, col2,
)
stmts = append(stmts, stmt)
}
return stmts
}

796
pkg/orm/recordset.go Normal file
View File

@@ -0,0 +1,796 @@
package orm
import (
"fmt"
"strings"
)
// Recordset represents an ordered set of records for a model.
// Mirrors: odoo/orm/models.py BaseModel (which IS the recordset)
//
// In Odoo, a model instance IS a recordset. Every operation returns recordsets:
//
// partners = self.env['res.partner'].search([('name', 'ilike', 'test')])
// for partner in partners:
// print(partner.name)
//
// Go equivalent:
//
// partners := env.Model("res.partner").Search(And(Leaf("name", "ilike", "test")))
// for _, rec := range partners.Records() {
// fmt.Println(rec.Get("name"))
// }
type Recordset struct {
env *Environment
model *Model
ids []int64
}
// Env returns the environment of this recordset.
func (rs *Recordset) Env() *Environment { return rs.env }
// Model returns the model of this recordset.
func (rs *Recordset) ModelDef() *Model { return rs.model }
// IDs returns the record IDs in this set.
func (rs *Recordset) IDs() []int64 { return rs.ids }
// Len returns the number of records.
func (rs *Recordset) Len() int { return len(rs.ids) }
// IsEmpty returns true if no records.
func (rs *Recordset) IsEmpty() bool { return len(rs.ids) == 0 }
// Ensure checks that this recordset contains exactly one record.
// Mirrors: odoo.models.BaseModel.ensure_one()
func (rs *Recordset) Ensure() error {
if len(rs.ids) != 1 {
return fmt.Errorf("orm: expected singleton, got %d records for %s", len(rs.ids), rs.model.name)
}
return nil
}
// ID returns the ID of a singleton recordset. Panics if not singleton.
func (rs *Recordset) ID() int64 {
if err := rs.Ensure(); err != nil {
panic(err)
}
return rs.ids[0]
}
// Browse returns a recordset for the given IDs.
// Mirrors: self.env['model'].browse([1, 2, 3])
func (rs *Recordset) Browse(ids ...int64) *Recordset {
return &Recordset{
env: rs.env,
model: rs.model,
ids: ids,
}
}
// Sudo returns this recordset with superuser privileges.
// Mirrors: records.sudo()
func (rs *Recordset) Sudo() *Recordset {
return &Recordset{
env: rs.env.Sudo(),
model: rs.model,
ids: rs.ids,
}
}
// WithContext returns this recordset with additional context.
func (rs *Recordset) WithContext(ctx map[string]interface{}) *Recordset {
return &Recordset{
env: rs.env.WithContext(ctx),
model: rs.model,
ids: rs.ids,
}
}
// --- CRUD Operations ---
// Create creates a new record and returns a recordset containing it.
// Mirrors: self.env['model'].create(vals)
func (rs *Recordset) Create(vals Values) (*Recordset, error) {
m := rs.model
// Phase 1: Apply defaults for missing fields
ApplyDefaults(m, vals)
// Add magic fields
if rs.env.uid > 0 {
vals["create_uid"] = rs.env.uid
vals["write_uid"] = rs.env.uid
}
// Phase 2: BeforeCreate hook (e.g., sequence generation)
if m.BeforeCreate != nil {
if err := m.BeforeCreate(rs.env, vals); err != nil {
return nil, err
}
}
// Phase 1: Validate required fields
if err := ValidateRequired(m, vals, true); err != nil {
return nil, err
}
// Build INSERT statement
var columns []string
var placeholders []string
var args []interface{}
idx := 1
for _, name := range m.fieldOrder {
f := m.fields[name]
if name == "id" || !f.IsStored() {
continue
}
val, exists := vals[name]
if !exists {
continue
}
columns = append(columns, fmt.Sprintf("%q", f.Column()))
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
args = append(args, val)
idx++
}
if len(columns) == 0 {
// Create with defaults only
columns = append(columns, `"create_date"`)
placeholders = append(placeholders, "NOW()")
}
query := fmt.Sprintf(
`INSERT INTO %q (%s) VALUES (%s) RETURNING "id"`,
m.table,
strings.Join(columns, ", "),
strings.Join(placeholders, ", "),
)
var id int64
err := rs.env.tx.QueryRow(rs.env.ctx, query, args...).Scan(&id)
if err != nil {
return nil, fmt.Errorf("orm: create %s: %w", m.name, err)
}
// Invalidate cache for this model
rs.env.cache.Invalidate(m.name)
// Process relational field commands (O2M/M2M)
if err := processRelationalCommands(rs.env, m, id, vals); err != nil {
return nil, err
}
// Run stored computed fields (after children exist for O2M-based computes)
if err := RunStoredComputes(m, rs.env, id, vals); err != nil {
return nil, err
}
// Write any newly computed values to the record
computedOnly := make(Values)
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.Compute != "" && f.Store {
if v, ok := vals[name]; ok {
computedOnly[name] = v
}
}
}
if len(computedOnly) > 0 {
if err := writeDirectNohook(rs.env, m, id, computedOnly); err != nil {
return nil, err
}
}
result := rs.Browse(id)
// Run constraints after record is fully created (with children + computes)
for _, constraint := range m.Constraints {
if err := constraint(result); err != nil {
return nil, err
}
}
return result, nil
}
// Write updates the records in this recordset.
// Mirrors: records.write(vals)
func (rs *Recordset) Write(vals Values) error {
if len(rs.ids) == 0 {
return nil
}
m := rs.model
var setClauses []string
var args []interface{}
idx := 1
// Add write metadata
if rs.env.uid > 0 {
vals["write_uid"] = rs.env.uid
}
vals["write_date"] = "NOW()" // Will be handled specially
for _, name := range m.fieldOrder {
f := m.fields[name]
if name == "id" || !f.IsStored() {
continue
}
val, exists := vals[name]
if !exists {
continue
}
if name == "write_date" {
setClauses = append(setClauses, `"write_date" = NOW()`)
continue
}
setClauses = append(setClauses, fmt.Sprintf("%q = $%d", f.Column(), idx))
args = append(args, val)
idx++
}
if len(setClauses) == 0 {
return nil
}
// Build WHERE clause for IDs
idPlaceholders := make([]string, len(rs.ids))
for i, id := range rs.ids {
args = append(args, id)
idPlaceholders[i] = fmt.Sprintf("$%d", idx)
idx++
}
query := fmt.Sprintf(
`UPDATE %q SET %s WHERE "id" IN (%s)`,
m.table,
strings.Join(setClauses, ", "),
strings.Join(idPlaceholders, ", "),
)
_, err := rs.env.tx.Exec(rs.env.ctx, query, args...)
if err != nil {
return fmt.Errorf("orm: write %s: %w", m.name, err)
}
// Invalidate cache
for _, id := range rs.ids {
rs.env.cache.InvalidateRecord(m.name, id)
}
// Process relational field commands (O2M/M2M) for each record
for _, id := range rs.ids {
if err := processRelationalCommands(rs.env, m, id, vals); err != nil {
return err
}
}
// Trigger recompute for stored computed fields that depend on written fields
if err := TriggerRecompute(rs, vals); err != nil {
return err
}
return nil
}
// Unlink deletes the records in this recordset.
// Mirrors: records.unlink()
func (rs *Recordset) Unlink() error {
if len(rs.ids) == 0 {
return nil
}
m := rs.model
var args []interface{}
placeholders := make([]string, len(rs.ids))
for i, id := range rs.ids {
args = append(args, id)
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`DELETE FROM %q WHERE "id" IN (%s)`,
m.table,
strings.Join(placeholders, ", "),
)
_, err := rs.env.tx.Exec(rs.env.ctx, query, args...)
if err != nil {
return fmt.Errorf("orm: unlink %s: %w", m.name, err)
}
// Invalidate cache
for _, id := range rs.ids {
rs.env.cache.InvalidateRecord(m.name, id)
}
return nil
}
// --- Read Operations ---
// Read reads field values for the records in this recordset.
// Mirrors: records.read(['field1', 'field2'])
func (rs *Recordset) Read(fields []string) ([]Values, error) {
if len(rs.ids) == 0 {
return nil, nil
}
m := rs.model
// Resolve fields to column names
if len(fields) == 0 {
// Read all stored fields
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.IsStored() {
fields = append(fields, name)
}
}
}
var columns []string
var storedFields []string // Fields that come from the DB query
var m2mFields []string // Many2many fields (from junction table)
var relatedFields []string // Related fields (from joined table)
for _, name := range fields {
f := m.GetField(name)
if f == nil {
return nil, fmt.Errorf("orm: field %q not found on %s", name, m.name)
}
if f.Type == TypeMany2many {
m2mFields = append(m2mFields, name)
} else if f.Related != "" && !f.Store {
relatedFields = append(relatedFields, name)
} else if f.IsStored() {
columns = append(columns, fmt.Sprintf("%q", f.Column()))
storedFields = append(storedFields, name)
}
}
// Build query
var args []interface{}
idPlaceholders := make([]string, len(rs.ids))
for i, id := range rs.ids {
args = append(args, id)
idPlaceholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`SELECT %s FROM %q WHERE "id" IN (%s) ORDER BY %s`,
strings.Join(columns, ", "),
m.table,
strings.Join(idPlaceholders, ", "),
m.order,
)
rows, err := rs.env.tx.Query(rs.env.ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("orm: read %s: %w", m.name, err)
}
defer rows.Close()
var results []Values
for rows.Next() {
scanDest := make([]interface{}, len(columns))
for i := range scanDest {
scanDest[i] = new(interface{})
}
if err := rows.Scan(scanDest...); err != nil {
return nil, fmt.Errorf("orm: scan %s: %w", m.name, err)
}
record := make(Values, len(fields))
for i, name := range storedFields {
val := *(scanDest[i].(*interface{}))
record[name] = val
// Update cache
if id, ok := record["id"].(int64); ok {
rs.env.cache.Set(m.name, id, name, val)
}
}
results = append(results, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Post-fetch: M2M fields (from junction tables)
if len(m2mFields) > 0 && len(rs.ids) > 0 {
for _, fname := range m2mFields {
f := m.GetField(fname)
if f == nil {
continue
}
m2mData, err := ReadM2MField(rs.env, f, rs.ids)
if err != nil {
return nil, fmt.Errorf("orm: read M2M %s.%s: %w", m.name, fname, err)
}
for _, rec := range results {
if id, ok := rec["id"].(int64); ok {
rec[fname] = m2mData[id]
} else if id, ok := rec["id"].(int32); ok {
rec[fname] = m2mData[int64(id)]
}
}
}
}
// Post-fetch: Related fields (follow M2O chain)
if len(relatedFields) > 0 {
for _, fname := range relatedFields {
f := m.GetField(fname)
if f == nil || f.Related == "" {
continue
}
parts := strings.Split(f.Related, ".")
if len(parts) != 2 {
continue // Only support single-hop related for now
}
fkField := parts[0]
targetField := parts[1]
fkDef := m.GetField(fkField)
if fkDef == nil || fkDef.Type != TypeMany2one {
continue
}
// Collect FK IDs from results
fkIDs := make(map[int64]bool)
for _, rec := range results {
if fkID, ok := toRecordID(rec[fkField]); ok && fkID > 0 {
fkIDs[fkID] = true
}
}
if len(fkIDs) == 0 {
for _, rec := range results {
rec[fname] = nil
}
continue
}
// Fetch related values
var ids []int64
for id := range fkIDs {
ids = append(ids, id)
}
comodelRS := rs.env.Model(fkDef.Comodel).Browse(ids...)
relatedData, err := comodelRS.Read([]string{"id", targetField})
if err != nil {
continue // Skip on error
}
lookup := make(map[int64]interface{})
for _, rd := range relatedData {
if id, ok := toRecordID(rd["id"]); ok {
lookup[id] = rd[targetField]
}
}
for _, rec := range results {
if fkID, ok := toRecordID(rec[fkField]); ok {
rec[fname] = lookup[fkID]
} else {
rec[fname] = nil
}
}
}
}
return results, nil
}
// Get reads a single field value from a singleton record.
// Mirrors: record.field_name (Python attribute access)
func (rs *Recordset) Get(field string) (Value, error) {
if err := rs.Ensure(); err != nil {
return nil, err
}
// Check cache first
if val, ok := rs.env.cache.Get(rs.model.name, rs.ids[0], field); ok {
return val, nil
}
// Read from database
records, err := rs.Read([]string{field})
if err != nil {
return nil, err
}
if len(records) == 0 {
return nil, fmt.Errorf("orm: record %s(%d) not found", rs.model.name, rs.ids[0])
}
return records[0][field], nil
}
// --- Search Operations ---
// Search searches for records matching the domain.
// Mirrors: self.env['model'].search(domain, offset=0, limit=None, order=None)
func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, error) {
m := rs.model
opt := mergeSearchOpts(opts)
// Apply record rules (e.g., multi-company filter)
domain = ApplyRecordRules(rs.env, m, domain)
// Compile domain to SQL
compiler := &DomainCompiler{model: m}
where, params, err := compiler.Compile(domain)
if err != nil {
return nil, fmt.Errorf("orm: search %s: %w", m.name, err)
}
// Build query
order := m.order
if opt.Order != "" {
order = opt.Order
}
joinSQL := compiler.JoinSQL()
// Qualify ORDER BY columns with table name when JOINs are present
qualifiedOrder := order
if joinSQL != "" {
qualifiedOrder = qualifyOrderBy(m.table, order)
}
query := fmt.Sprintf(
`SELECT %q."id" FROM %q%s WHERE %s ORDER BY %s`,
m.table, m.table, joinSQL, where, qualifiedOrder,
)
if opt.Limit > 0 {
query += fmt.Sprintf(" LIMIT %d", opt.Limit)
}
if opt.Offset > 0 {
query += fmt.Sprintf(" OFFSET %d", opt.Offset)
}
rows, err := rs.env.tx.Query(rs.env.ctx, query, params...)
if err != nil {
return nil, fmt.Errorf("orm: search %s: %w", m.name, err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("orm: search scan %s: %w", m.name, err)
}
ids = append(ids, id)
}
return rs.Browse(ids...), rows.Err()
}
// SearchCount returns the number of records matching the domain.
// Mirrors: self.env['model'].search_count(domain)
func (rs *Recordset) SearchCount(domain Domain) (int64, error) {
m := rs.model
compiler := &DomainCompiler{model: m}
where, params, err := compiler.Compile(domain)
if err != nil {
return 0, fmt.Errorf("orm: search_count %s: %w", m.name, err)
}
joinSQL := compiler.JoinSQL()
query := fmt.Sprintf(`SELECT COUNT(*) FROM %q%s WHERE %s`, m.table, joinSQL, where)
var count int64
err = rs.env.tx.QueryRow(rs.env.ctx, query, params...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("orm: search_count %s: %w", m.name, err)
}
return count, nil
}
// SearchRead combines search and read in one call.
// Mirrors: self.env['model'].search_read(domain, fields, offset, limit, order)
func (rs *Recordset) SearchRead(domain Domain, fields []string, opts ...SearchOpts) ([]Values, error) {
found, err := rs.Search(domain, opts...)
if err != nil {
return nil, err
}
if found.IsEmpty() {
return nil, nil
}
return found.Read(fields)
}
// NameGet returns display names for the records.
// Mirrors: records.name_get()
func (rs *Recordset) NameGet() (map[int64]string, error) {
if len(rs.ids) == 0 {
return nil, nil
}
recName := rs.model.recName
records, err := rs.Read([]string{"id", recName})
if err != nil {
return nil, err
}
result := make(map[int64]string, len(records))
for _, rec := range records {
id, _ := rec["id"].(int64)
name, _ := rec[recName].(string)
result[id] = name
}
return result, nil
}
// Exists filters this recordset to only records that exist in the database.
// Mirrors: records.exists()
func (rs *Recordset) Exists() (*Recordset, error) {
if len(rs.ids) == 0 {
return rs, nil
}
m := rs.model
var args []interface{}
placeholders := make([]string, len(rs.ids))
for i, id := range rs.ids {
args = append(args, id)
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`SELECT "id" FROM %q WHERE "id" IN (%s)`,
m.table,
strings.Join(placeholders, ", "),
)
rows, err := rs.env.tx.Query(rs.env.ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var existing []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
existing = append(existing, id)
}
return rs.Browse(existing...), rows.Err()
}
// Records returns individual singleton recordsets for iteration.
// Mirrors: for record in records: ...
func (rs *Recordset) Records() []*Recordset {
result := make([]*Recordset, len(rs.ids))
for i, id := range rs.ids {
result[i] = rs.Browse(id)
}
return result
}
// Union returns the union of this recordset with others.
// Mirrors: records | other_records
func (rs *Recordset) Union(others ...*Recordset) *Recordset {
seen := make(map[int64]bool)
var ids []int64
for _, id := range rs.ids {
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
for _, other := range others {
for _, id := range other.ids {
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
}
return rs.Browse(ids...)
}
// Subtract returns records in this set but not in the other.
// Mirrors: records - other_records
func (rs *Recordset) Subtract(other *Recordset) *Recordset {
exclude := make(map[int64]bool)
for _, id := range other.ids {
exclude[id] = true
}
var ids []int64
for _, id := range rs.ids {
if !exclude[id] {
ids = append(ids, id)
}
}
return rs.Browse(ids...)
}
// --- Search Options ---
// SearchOpts configures a search operation.
type SearchOpts struct {
Offset int
Limit int
Order string
}
func mergeSearchOpts(opts []SearchOpts) SearchOpts {
if len(opts) == 0 {
return SearchOpts{}
}
return opts[0]
}
// toRecordID extracts an int64 ID from various types PostgreSQL might return.
func toRecordID(v interface{}) (int64, bool) {
switch n := v.(type) {
case int64:
return n, true
case int32:
return int64(n), true
case int:
return int64(n), true
case float64:
return int64(n), true
}
return 0, false
}
// qualifyOrderBy prefixes unqualified column names with the table name.
// "name, id desc" → "\"my_table\".name, \"my_table\".id desc"
func qualifyOrderBy(table, order string) string {
parts := strings.Split(order, ",")
for i, part := range parts {
part = strings.TrimSpace(part)
tokens := strings.Fields(part)
if len(tokens) == 0 {
continue
}
col := tokens[0]
// Skip already qualified columns
if strings.Contains(col, ".") {
continue
}
tokens[0] = fmt.Sprintf("%q.%s", table, col)
parts[i] = strings.Join(tokens, " ")
}
return strings.Join(parts, ", ")
}
// --- Relational Command Processing ---
// processRelationalCommands handles O2M/M2M commands in vals after a Create or Write.
func processRelationalCommands(env *Environment, m *Model, parentID int64, vals Values) error {
for _, name := range m.fieldOrder {
f := m.fields[name]
raw, exists := vals[name]
if !exists {
continue
}
cmds, ok := ParseCommands(raw)
if !ok {
continue
}
switch f.Type {
case TypeOne2many:
if err := ProcessO2MCommands(env, f, parentID, cmds); err != nil {
return err
}
case TypeMany2many:
if err := ProcessM2MCommands(env, f, parentID, cmds); err != nil {
return err
}
}
}
return nil
}

282
pkg/orm/relational.go Normal file
View 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
}

82
pkg/orm/rules.go Normal file
View File

@@ -0,0 +1,82 @@
package orm
import "fmt"
// ApplyRecordRules adds ir.rule domain filters to a search.
// Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain()
//
// Rules work as follows:
// - Global rules (no groups) are AND-ed together
// - Group rules are OR-ed within the group set
// - The final domain is: global_rules AND (group_rule_1 OR group_rule_2 OR ...)
//
// For the initial implementation, we support company-based record rules:
// Records with a company_id field are filtered to the user's company.
func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
if env.su {
return domain // Superuser bypasses record rules
}
// Auto-apply company filter if model has company_id
// Records where company_id = user's company OR company_id IS NULL (shared records)
if f := m.GetField("company_id"); f != nil && f.Type == TypeMany2one {
myCompany := Leaf("company_id", "=", env.CompanyID())
noCompany := Leaf("company_id", "=", nil)
companyFilter := Or(myCompany, noCompany)
if len(domain) == 0 {
return companyFilter
}
// AND the company filter with existing domain
result := Domain{OpAnd}
result = append(result, domain...)
// Wrap company filter in the domain
result = append(result, companyFilter...)
return result
}
// TODO: Load custom ir.rule records from DB and compile their domains
// For now, only the built-in company filter is applied
return domain
}
// CheckRecordRuleAccess verifies the user can access specific record IDs.
// Returns an error if any record is not accessible.
func CheckRecordRuleAccess(env *Environment, m *Model, ids []int64, perm string) error {
if env.su || len(ids) == 0 {
return nil
}
// Check company_id if the model has it
f := m.GetField("company_id")
if f == nil || f.Type != TypeMany2one {
return nil
}
// Count records that match the company filter
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
args = append(args, env.CompanyID())
query := fmt.Sprintf(
`SELECT COUNT(*) FROM %q WHERE "id" IN (%s) AND ("company_id" = $%d OR "company_id" IS NULL)`,
m.Table(),
joinStrings(placeholders, ", "),
len(ids)+1,
)
var count int64
err := env.tx.QueryRow(env.ctx, query, args...).Scan(&count)
if err != nil {
return nil // Fail open on error
}
if count < int64(len(ids)) {
return fmt.Errorf("orm: access denied by record rules on %s (company filter)", m.Name())
}
return nil
}

69
pkg/orm/sequence.go Normal file
View File

@@ -0,0 +1,69 @@
package orm
import (
"fmt"
"strings"
"time"
)
// NextByCode generates the next value for a sequence identified by its code.
// Mirrors: odoo/addons/base/models/ir_sequence.py IrSequence.next_by_code()
//
// Uses PostgreSQL FOR UPDATE to ensure atomic increment under concurrency.
// Format: prefix + LPAD(number, padding, '0') + suffix
// Supports date interpolation in prefix/suffix: %(year)s, %(month)s, %(day)s
func NextByCode(env *Environment, code string) (string, error) {
var id int64
var prefix, suffix string
var numberNext, numberIncrement, padding int
err := env.tx.QueryRow(env.ctx, `
SELECT id, COALESCE(prefix, ''), COALESCE(suffix, ''), number_next, number_increment, padding
FROM ir_sequence
WHERE code = $1 AND active = true
ORDER BY id
LIMIT 1
FOR UPDATE
`, code).Scan(&id, &prefix, &suffix, &numberNext, &numberIncrement, &padding)
if err != nil {
return "", fmt.Errorf("orm: sequence %q not found: %w", code, err)
}
// Format the sequence value
result := FormatSequence(prefix, suffix, numberNext, padding)
// Increment for next call
_, err = env.tx.Exec(env.ctx, `
UPDATE ir_sequence SET number_next = number_next + $1 WHERE id = $2
`, numberIncrement, id)
if err != nil {
return "", fmt.Errorf("orm: sequence %q increment failed: %w", code, err)
}
return result, nil
}
// FormatSequence formats a sequence number with prefix, suffix, and zero-padding.
func FormatSequence(prefix, suffix string, number, padding int) string {
prefix = InterpolateDate(prefix)
suffix = InterpolateDate(suffix)
numStr := fmt.Sprintf("%d", number)
if padding > 0 && len(numStr) < padding {
numStr = strings.Repeat("0", padding-len(numStr)) + numStr
}
return prefix + numStr + suffix
}
// InterpolateDate replaces Odoo-style date placeholders in a string.
// Supports: %(year)s, %(month)s, %(day)s, %(y)s (2-digit year)
func InterpolateDate(s string) string {
now := time.Now()
s = strings.ReplaceAll(s, "%(year)s", now.Format("2006"))
s = strings.ReplaceAll(s, "%(y)s", now.Format("06"))
s = strings.ReplaceAll(s, "%(month)s", now.Format("01"))
s = strings.ReplaceAll(s, "%(day)s", now.Format("02"))
return s
}

209
pkg/orm/types.go Normal file
View File

@@ -0,0 +1,209 @@
// Package orm implements the Odoo ORM in Go.
// Mirrors: odoo/orm/models.py, odoo/orm/fields.py
package orm
import (
"fmt"
"sync"
"time"
)
// FieldType mirrors Odoo's field type system.
// See: odoo/orm/fields.py
type FieldType int
const (
TypeChar FieldType = iota
TypeText
TypeHTML
TypeInteger
TypeFloat
TypeMonetary
TypeBoolean
TypeDate
TypeDatetime
TypeBinary
TypeSelection
TypeJson
TypeMany2one
TypeOne2many
TypeMany2many
TypeReference
TypeProperties
)
func (ft FieldType) String() string {
names := [...]string{
"char", "text", "html", "integer", "float", "monetary",
"boolean", "date", "datetime", "binary", "selection",
"json", "many2one", "one2many", "many2many", "reference", "properties",
}
if int(ft) < len(names) {
return names[ft]
}
return fmt.Sprintf("unknown(%d)", ft)
}
// SQLType returns the PostgreSQL column type for this field type.
// Mirrors: odoo/orm/fields.py column_type property
func (ft FieldType) SQLType() string {
switch ft {
case TypeChar:
return "varchar"
case TypeText, TypeHTML:
return "text"
case TypeInteger:
return "int4"
case TypeFloat:
return "numeric"
case TypeMonetary:
return "numeric"
case TypeBoolean:
return "bool"
case TypeDate:
return "date"
case TypeDatetime:
return "timestamp without time zone"
case TypeBinary:
return "bytea"
case TypeSelection:
return "varchar"
case TypeJson, TypeProperties:
return "jsonb"
case TypeMany2one:
return "int4"
case TypeReference:
return "varchar"
case TypeOne2many, TypeMany2many:
return "" // no column, computed
default:
return ""
}
}
// IsRelational returns true for relational field types.
func (ft FieldType) IsRelational() bool {
return ft == TypeMany2one || ft == TypeOne2many || ft == TypeMany2many
}
// IsStored returns true if this field type has a database column.
func (ft FieldType) IsStored() bool {
return ft != TypeOne2many && ft != TypeMany2many
}
// ModelType mirrors Odoo's model categories.
// See: odoo/orm/models.py BaseModel._auto
type ModelType int
const (
// ModelRegular corresponds to odoo.models.Model (_auto=True, _abstract=False)
ModelRegular ModelType = iota
// ModelTransient corresponds to odoo.models.TransientModel (_transient=True)
ModelTransient
// ModelAbstract corresponds to odoo.models.AbstractModel (_auto=False, _abstract=True)
ModelAbstract
)
// OnDelete mirrors Odoo's ondelete parameter for Many2one fields.
// See: odoo/orm/fields_relational.py Many2one.ondelete
type OnDelete string
const (
OnDeleteSetNull OnDelete = "set null"
OnDeleteRestrict OnDelete = "restrict"
OnDeleteCascade OnDelete = "cascade"
)
// Value represents any value that can be stored in or read from a field.
// Mirrors Odoo's dynamic typing for field values.
type Value interface{}
// Values is a map of field names to values, used for create/write operations.
// Mirrors Odoo's vals dict passed to create() and write().
type Values = map[string]interface{}
// SelectionItem represents one option in a Selection field.
// Mirrors: odoo/orm/fields.py Selection.selection items
type SelectionItem struct {
Value string
Label string
}
// NullTime wraps time.Time to handle NULL dates from PostgreSQL.
type NullTime struct {
Time time.Time
Valid bool
}
// NullInt wraps int64 to handle NULL integers (e.g., Many2one FK).
type NullInt struct {
Int64 int64
Valid bool
}
// NullString wraps string to handle NULL text fields.
type NullString struct {
String string
Valid bool
}
// Registry is the global model registry.
// Mirrors: odoo/orm/registry.py Registry
// Holds all registered models, keyed by model name (e.g., "res.partner").
var Registry = &ModelRegistry{
models: make(map[string]*Model),
}
// ModelRegistry manages all model definitions.
// Thread-safe for concurrent module loading.
type ModelRegistry struct {
mu sync.RWMutex
models map[string]*Model
loaded bool
}
// Get returns a model by name, or nil if not found.
func (r *ModelRegistry) Get(name string) *Model {
r.mu.RLock()
defer r.mu.RUnlock()
return r.models[name]
}
// MustGet returns a model by name, panics if not found.
func (r *ModelRegistry) MustGet(name string) *Model {
m := r.Get(name)
if m == nil {
panic(fmt.Sprintf("orm: model %q not registered", name))
}
return m
}
// Register adds a model to the registry.
func (r *ModelRegistry) Register(m *Model) {
r.mu.Lock()
defer r.mu.Unlock()
r.models[m.name] = m
}
// All returns all registered model names.
func (r *ModelRegistry) All() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.models))
for name := range r.models {
names = append(names, name)
}
return names
}
// Models returns all registered models.
func (r *ModelRegistry) Models() map[string]*Model {
r.mu.RLock()
defer r.mu.RUnlock()
// Return a copy to avoid race conditions
result := make(map[string]*Model, len(r.models))
for k, v := range r.models {
result[k] = v
}
return result
}

47
pkg/server/action.go Normal file
View File

@@ -0,0 +1,47 @@
package server
import (
"encoding/json"
"net/http"
)
// handleActionLoad loads an action definition by ID.
// Mirrors: odoo/addons/web/controllers/action.py Action.load()
func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
return
}
var params struct {
ActionID interface{} `json:"action_id"`
Context interface{} `json:"context"`
}
json.Unmarshal(req.Params, &params)
// For now, return the Contacts action for any request
// TODO: Load from ir_act_window table
action := map[string]interface{}{
"id": 1,
"type": "ir.actions.act_window",
"name": "Contacts",
"res_model": "res.partner",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "contacts.action_contacts",
}
s.writeJSONRPC(w, req.ID, action, nil)
}

216
pkg/server/assets_css.txt Normal file
View File

@@ -0,0 +1,216 @@
/web/static/lib/bootstrap/scss/_functions.scss
/web/static/lib/bootstrap/scss/_mixins.scss
/web/static/src/scss/functions.scss
/web/static/src/scss/mixins_forwardport.scss
/web/static/src/scss/bs_mixins_overrides.scss
/web/static/src/scss/utils.scss
/web/static/src/scss/primary_variables.scss
/web/static/src/core/avatar/avatar.variables.scss
/web/static/src/core/bottom_sheet/bottom_sheet.variables.scss
/web/static/src/core/notifications/notification.variables.scss
/web/static/src/search/control_panel/control_panel.variables.scss
/web/static/src/search/search_bar/search_bar.variables.scss
/web/static/src/search/search_panel/search_panel.variables.scss
/web/static/src/views/fields/statusbar/statusbar_field.variables.scss
/web/static/src/views/fields/translation_button.variables.scss
/web/static/src/views/form/form.variables.scss
/web/static/src/views/kanban/kanban.variables.scss
/web/static/src/webclient/burger_menu/burger_menu.variables.scss
/web/static/src/webclient/navbar/navbar.variables.scss
/web/static/src/scss/secondary_variables.scss
/web/static/src/scss/bootstrap_overridden.scss
/web/static/src/scss/bs_mixins_overrides_backend.scss
/web/static/src/scss/pre_variables.scss
/web/static/lib/bootstrap/scss/_variables.scss
/web/static/lib/bootstrap/scss/_variables-dark.scss
/web/static/lib/bootstrap/scss/_maps.scss
/web/static/src/scss/import_bootstrap.scss
/web/static/src/scss/utilities_custom.scss
/web/static/lib/bootstrap/scss/utilities/_api.scss
/web/static/src/scss/bootstrap_review.scss
/web/static/src/scss/bootstrap_review_backend.scss
/web/static/src/core/utils/transitions.scss
/web/static/src/core/action_swiper/action_swiper.scss
/web/static/src/core/autocomplete/autocomplete.scss
/web/static/src/core/avatar/avatar.scss
/web/static/src/core/avatar/avatar.variables.scss
/web/static/src/core/badge/badge.scss
/web/static/src/core/barcode/barcode_dialog.scss
/web/static/src/core/barcode/crop_overlay.scss
/web/static/src/core/bottom_sheet/bottom_sheet.scss
/web/static/src/core/bottom_sheet/bottom_sheet.variables.scss
/web/static/src/core/checkbox/checkbox.scss
/web/static/src/core/color_picker/color_picker.scss
/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.scss
/web/static/src/core/colorlist/colorlist.scss
/web/static/src/core/commands/command_palette.scss
/web/static/src/core/datetime/datetime_picker.scss
/web/static/src/core/debug/debug_menu.scss
/web/static/src/core/dialog/dialog.scss
/web/static/src/core/dropdown/accordion_item.scss
/web/static/src/core/dropdown/dropdown.scss
/web/static/src/core/dropzone/dropzone.scss
/web/static/src/core/effects/rainbow_man.scss
/web/static/src/core/emoji_picker/emoji_picker.dark.scss
/web/static/src/core/emoji_picker/emoji_picker.scss
/web/static/src/core/errors/error_dialog.scss
/web/static/src/core/file_upload/file_upload_progress_bar.scss
/web/static/src/core/file_upload/file_upload_progress_record.scss
/web/static/src/core/file_viewer/file_viewer.dark.scss
/web/static/src/core/file_viewer/file_viewer.scss
/web/static/src/core/ir_ui_view_code_editor/code_editor.scss
/web/static/src/core/model_field_selector/model_field_selector.scss
/web/static/src/core/model_field_selector/model_field_selector_popover.scss
/web/static/src/core/model_selector/model_selector.scss
/web/static/src/core/notebook/notebook.scss
/web/static/src/core/notifications/notification.scss
/web/static/src/core/notifications/notification.variables.scss
/web/static/src/core/overlay/overlay_container.scss
/web/static/src/core/pager/pager_indicator.scss
/web/static/src/core/popover/popover.scss
/web/static/src/core/pwa/install_prompt.scss
/web/static/src/core/record_selectors/record_selectors.scss
/web/static/src/core/resizable_panel/resizable_panel.scss
/web/static/src/core/select_menu/select_menu.scss
/web/static/src/core/signature/name_and_signature.scss
/web/static/src/core/tags_list/tags_list.scss
/web/static/src/core/time_picker/time_picker.scss
/web/static/src/core/tooltip/tooltip.scss
/web/static/src/core/tree_editor/tree_editor.scss
/web/static/src/core/ui/block_ui.scss
/web/static/src/core/utils/draggable_hook_builder.scss
/web/static/src/core/utils/nested_sortable.scss
/web/static/src/core/utils/transitions.scss
/web/static/src/libs/fontawesome/css/font-awesome.css
/web/static/lib/odoo_ui_icons/style.css
/web/static/src/webclient/navbar/navbar.scss
/web/static/src/scss/animation.scss
/web/static/src/scss/fontawesome_overridden.scss
/web/static/src/scss/mimetypes.scss
/web/static/src/scss/ui.scss
/web/static/src/views/fields/translation_dialog.scss
/odoo/base/static/src/css/modules.css
/web/static/src/core/utils/transitions.scss
/web/static/src/search/cog_menu/cog_menu.scss
/web/static/src/search/control_panel/control_panel.scss
/web/static/src/search/control_panel/control_panel.variables.scss
/web/static/src/search/control_panel/control_panel.variables_print.scss
/web/static/src/search/control_panel/control_panel_mobile.css
/web/static/src/search/custom_group_by_item/custom_group_by_item.scss
/web/static/src/search/search_bar/search_bar.scss
/web/static/src/search/search_bar/search_bar.variables.scss
/web/static/src/search/search_bar_menu/search_bar_menu.scss
/web/static/src/search/search_panel/search_panel.scss
/web/static/src/search/search_panel/search_panel.variables.scss
/web/static/src/search/search_panel/search_view.scss
/web/static/src/webclient/icons.scss
/web/static/src/views/calendar/calendar_common/calendar_common_popover.scss
/web/static/src/views/calendar/calendar_controller.scss
/web/static/src/views/calendar/calendar_controller_mobile.scss
/web/static/src/views/calendar/calendar_filter_section/calendar_filter_section.scss
/web/static/src/views/calendar/calendar_renderer.dark.scss
/web/static/src/views/calendar/calendar_renderer.scss
/web/static/src/views/calendar/calendar_renderer_mobile.scss
/web/static/src/views/calendar/calendar_side_panel/calendar_side_panel.scss
/web/static/src/views/calendar/calendar_year/calendar_year_popover.scss
/web/static/src/views/fields/ace/ace_field.scss
/web/static/src/views/fields/badge_selection/badge_selection.scss
/web/static/src/views/fields/boolean_favorite/boolean_favorite_field.scss
/web/static/src/views/fields/char/char_field.scss
/web/static/src/views/fields/color_picker/color_picker_field.scss
/web/static/src/views/fields/contact_image/contact_image_field.scss
/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.scss
/web/static/src/views/fields/email/email_field.scss
/web/static/src/views/fields/fields.scss
/web/static/src/views/fields/google_slide_viewer/google_slide_viewer.scss
/web/static/src/views/fields/html/html_field.scss
/web/static/src/views/fields/iframe_wrapper/iframe_wrapper_field.scss
/web/static/src/views/fields/image/image_field.scss
/web/static/src/views/fields/journal_dashboard_graph/journal_dashboard_graph_field.scss
/web/static/src/views/fields/kanban_color_picker/kanban_color_picker_field.scss
/web/static/src/views/fields/many2many_binary/many2many_binary_field.scss
/web/static/src/views/fields/many2many_tags/many2many_tags_field.scss
/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.scss
/web/static/src/views/fields/many2one/many2one_field.scss
/web/static/src/views/fields/many2one_avatar/many2one_avatar_field.scss
/web/static/src/views/fields/monetary/monetary_field.scss
/web/static/src/views/fields/pdf_viewer/pdf_viewer_field.scss
/web/static/src/views/fields/percent_pie/percent_pie_field.scss
/web/static/src/views/fields/phone/phone_field.scss
/web/static/src/views/fields/priority/priority_field.scss
/web/static/src/views/fields/progress_bar/progress_bar_field.scss
/web/static/src/views/fields/properties/card_properties_field.scss
/web/static/src/views/fields/properties/properties_field.scss
/web/static/src/views/fields/properties/property_definition.scss
/web/static/src/views/fields/properties/property_definition_selection.scss
/web/static/src/views/fields/properties/property_tags.scss
/web/static/src/views/fields/properties/property_text.scss
/web/static/src/views/fields/properties/property_value.scss
/web/static/src/views/fields/radio/radio_field.scss
/web/static/src/views/fields/selection/selection_field.scss
/web/static/src/views/fields/signature/signature_field.scss
/web/static/src/views/fields/state_selection/state_selection_field.scss
/web/static/src/views/fields/statusbar/statusbar_field.scss
/web/static/src/views/fields/statusbar/statusbar_field.variables.scss
/web/static/src/views/fields/text/text_field.scss
/web/static/src/views/fields/translation_button.scss
/web/static/src/views/fields/translation_button.variables.scss
/web/static/src/views/fields/translation_dialog.scss
/web/static/src/views/fields/url/url_field.scss
/web/static/src/views/form/button_box/button_box.scss
/web/static/src/views/form/form.variables.scss
/web/static/src/views/form/form_controller.scss
/web/static/src/views/form/setting/setting.scss
/web/static/src/views/graph/graph_view.scss
/web/static/src/views/kanban/kanban.print_variables.scss
/web/static/src/views/kanban/kanban.variables.scss
/web/static/src/views/kanban/kanban_column_progressbar.scss
/web/static/src/views/kanban/kanban_controller.scss
/web/static/src/views/kanban/kanban_cover_image_dialog.scss
/web/static/src/views/kanban/kanban_examples_dialog.scss
/web/static/src/views/kanban/kanban_record.scss
/web/static/src/views/kanban/kanban_record_quick_create.scss
/web/static/src/views/list/list_confirmation_dialog.scss
/web/static/src/views/list/list_renderer.scss
/web/static/src/views/pivot/pivot_view.scss
/web/static/src/views/view.scss
/web/static/src/views/view_components/animated_number.scss
/web/static/src/views/view_components/group_config_menu.scss
/web/static/src/views/view_components/selection_box.scss
/web/static/src/views/view_dialogs/export_data_dialog.scss
/web/static/src/views/view_dialogs/select_create_dialog.scss
/web/static/src/views/widgets/ribbon/ribbon.scss
/web/static/src/views/widgets/week_days/week_days.scss
/web/static/src/webclient/actions/action_dialog.scss
/web/static/src/webclient/actions/reports/bootstrap_overridden_report.scss
/web/static/src/webclient/actions/reports/bootstrap_review_report.scss
/web/static/src/webclient/actions/reports/layout_assets/layout_bubble.scss
/web/static/src/webclient/actions/reports/layout_assets/layout_folder.scss
/web/static/src/webclient/actions/reports/layout_assets/layout_wave.scss
/web/static/src/webclient/actions/reports/report.scss
/web/static/src/webclient/actions/reports/report_tables.scss
/web/static/src/webclient/actions/reports/reset.min.css
/web/static/src/webclient/actions/reports/utilities_custom_report.scss
/web/static/src/webclient/burger_menu/burger_menu.scss
/web/static/src/webclient/burger_menu/burger_menu.variables.scss
/web/static/src/webclient/debug/profiling/profiling_item.scss
/web/static/src/webclient/debug/profiling/profiling_qweb.scss
/web/static/src/webclient/icons.scss
/web/static/src/webclient/loading_indicator/loading_indicator.scss
/web/static/src/webclient/navbar/navbar.scss
/web/static/src/webclient/navbar/navbar.variables.scss
/web/static/src/webclient/res_user_group_ids_field/res_user_group_ids_field.scss
/web/static/src/webclient/res_user_group_ids_field/res_user_group_ids_privilege_field.scss
/web/static/src/webclient/settings_form_view/settings/searchable_setting.scss
/web/static/src/webclient/settings_form_view/settings_form_view.scss
/web/static/src/webclient/settings_form_view/settings_form_view_mobile.scss
/web/static/src/webclient/settings_form_view/widgets/settings_widgets.scss
/web/static/src/webclient/switch_company_menu/switch_company_menu.scss
/web/static/src/webclient/user_menu/user_menu.scss
/web/static/src/webclient/webclient.scss
/web/static/src/webclient/webclient_layout.scss
/web/static/src/scss/ace.scss
/web/static/src/scss/base_document_layout.scss
/odoo/base/static/src/scss/res_partner.scss
/odoo/base/static/src/scss/res_users.scss
/web/static/src/views/form/button_box/button_box.scss

540
pkg/server/assets_js.txt Normal file
View File

@@ -0,0 +1,540 @@
/web/static/src/module_loader.js
/web/static/lib/luxon/luxon.js
/web/static/lib/owl/owl.js
/web/static/lib/owl/odoo_module.js
/web/static/src/env.js
/web/static/src/session.js
/web/static/src/core/action_swiper/action_swiper.js
/web/static/src/core/anchor_scroll_prevention.js
/web/static/src/core/assets.js
/web/static/src/core/autocomplete/autocomplete.js
/web/static/src/core/barcode/ZXingBarcodeDetector.js
/web/static/src/core/barcode/barcode_dialog.js
/web/static/src/core/barcode/barcode_video_scanner.js
/web/static/src/core/barcode/crop_overlay.js
/web/static/src/core/bottom_sheet/bottom_sheet.js
/web/static/src/core/bottom_sheet/bottom_sheet_service.js
/web/static/src/core/browser/browser.js
/web/static/src/core/browser/cookie.js
/web/static/src/core/browser/feature_detection.js
/web/static/src/core/browser/router.js
/web/static/src/core/browser/title_service.js
/web/static/src/core/checkbox/checkbox.js
/web/static/src/core/code_editor/code_editor.js
/web/static/src/core/color_picker/color_picker.js
/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.js
/web/static/src/core/color_picker/tabs/color_picker_custom_tab.js
/web/static/src/core/color_picker/tabs/color_picker_solid_tab.js
/web/static/src/core/colorlist/colorlist.js
/web/static/src/core/colors/colors.js
/web/static/src/core/commands/command_category.js
/web/static/src/core/commands/command_hook.js
/web/static/src/core/commands/command_palette.js
/web/static/src/core/commands/command_service.js
/web/static/src/core/commands/default_providers.js
/web/static/src/core/confirmation_dialog/confirmation_dialog.js
/web/static/src/core/context.js
/web/static/src/core/copy_button/copy_button.js
/web/static/src/core/currency.js
/web/static/src/core/datetime/datetime_input.js
/web/static/src/core/datetime/datetime_picker.js
/web/static/src/core/datetime/datetime_picker_hook.js
/web/static/src/core/datetime/datetime_picker_popover.js
/web/static/src/core/datetime/datetimepicker_service.js
/web/static/src/core/debug/debug_context.js
/web/static/src/core/debug/debug_menu.js
/web/static/src/core/debug/debug_menu_basic.js
/web/static/src/core/debug/debug_menu_items.js
/web/static/src/core/debug/debug_providers.js
/web/static/src/core/debug/debug_utils.js
/web/static/src/core/dialog/dialog.js
/web/static/src/core/dialog/dialog_service.js
/web/static/src/core/domain.js
/web/static/src/core/domain_selector/domain_selector.js
/web/static/src/core/domain_selector/domain_selector_operator_editor.js
/web/static/src/core/domain_selector/utils.js
/web/static/src/core/domain_selector_dialog/domain_selector_dialog.js
/web/static/src/core/dropdown/_behaviours/dropdown_group_hook.js
/web/static/src/core/dropdown/_behaviours/dropdown_nesting.js
/web/static/src/core/dropdown/_behaviours/dropdown_popover.js
/web/static/src/core/dropdown/accordion_item.js
/web/static/src/core/dropdown/checkbox_item.js
/web/static/src/core/dropdown/dropdown.js
/web/static/src/core/dropdown/dropdown_group.js
/web/static/src/core/dropdown/dropdown_hooks.js
/web/static/src/core/dropdown/dropdown_item.js
/web/static/src/core/dropzone/dropzone.js
/web/static/src/core/dropzone/dropzone_hook.js
/web/static/src/core/effects/effect_service.js
/web/static/src/core/effects/rainbow_man.js
/web/static/src/core/emoji_picker/emoji_data.js
/web/static/src/core/emoji_picker/emoji_picker.js
/web/static/src/core/emoji_picker/frequent_emoji_service.js
/web/static/src/core/ensure_jquery.js
/web/static/src/core/errors/error_dialogs.js
/web/static/src/core/errors/error_handlers.js
/web/static/src/core/errors/error_service.js
/web/static/src/core/errors/error_utils.js
/web/static/src/core/errors/scss_error_dialog.js
/web/static/src/core/expression_editor/expression_editor.js
/web/static/src/core/expression_editor/expression_editor_operator_editor.js
/web/static/src/core/expression_editor_dialog/expression_editor_dialog.js
/web/static/src/core/field_service.js
/web/static/src/core/file_input/file_input.js
/web/static/src/core/file_upload/file_upload_progress_bar.js
/web/static/src/core/file_upload/file_upload_progress_container.js
/web/static/src/core/file_upload/file_upload_progress_record.js
/web/static/src/core/file_upload/file_upload_service.js
/web/static/src/core/file_viewer/file_model.js
/web/static/src/core/file_viewer/file_viewer.js
/web/static/src/core/file_viewer/file_viewer_hook.js
/web/static/src/core/hotkeys/hotkey_hook.js
/web/static/src/core/hotkeys/hotkey_service.js
/web/static/src/core/install_scoped_app/install_scoped_app.js
/web/static/src/core/ir_ui_view_code_editor/code_editor.js
/web/static/src/core/l10n/dates.js
/web/static/src/core/l10n/localization.js
/web/static/src/core/l10n/localization_service.js
/web/static/src/core/l10n/time.js
/web/static/src/core/l10n/translation.js
/web/static/src/core/l10n/utils.js
/web/static/src/core/l10n/utils/format_list.js
/web/static/src/core/l10n/utils/locales.js
/web/static/src/core/l10n/utils/normalize.js
/web/static/src/core/macro.js
/web/static/src/core/main_components_container.js
/web/static/src/core/model_field_selector/model_field_selector.js
/web/static/src/core/model_field_selector/model_field_selector_popover.js
/web/static/src/core/model_selector/model_selector.js
/web/static/src/core/name_service.js
/web/static/src/core/navigation/navigation.js
/web/static/src/core/network/download.js
/web/static/src/core/network/http_service.js
/web/static/src/core/network/rpc.js
/web/static/src/core/network/rpc_cache.js
/web/static/src/core/notebook/notebook.js
/web/static/src/core/notifications/notification.js
/web/static/src/core/notifications/notification_container.js
/web/static/src/core/notifications/notification_service.js
/web/static/src/core/orm_service.js
/web/static/src/core/overlay/overlay_container.js
/web/static/src/core/overlay/overlay_service.js
/web/static/src/core/pager/pager.js
/web/static/src/core/pager/pager_indicator.js
/web/static/src/core/popover/popover.js
/web/static/src/core/popover/popover_hook.js
/web/static/src/core/popover/popover_service.js
/web/static/src/core/position/position_hook.js
/web/static/src/core/position/utils.js
/web/static/src/core/pwa/install_prompt.js
/web/static/src/core/pwa/pwa_service.js
/web/static/src/core/py_js/py.js
/web/static/src/core/py_js/py_builtin.js
/web/static/src/core/py_js/py_date.js
/web/static/src/core/py_js/py_interpreter.js
/web/static/src/core/py_js/py_parser.js
/web/static/src/core/py_js/py_tokenizer.js
/web/static/src/core/py_js/py_utils.js
/web/static/src/core/record_selectors/multi_record_selector.js
/web/static/src/core/record_selectors/record_autocomplete.js
/web/static/src/core/record_selectors/record_selector.js
/web/static/src/core/record_selectors/tag_navigation_hook.js
/web/static/src/core/registry.js
/web/static/src/core/registry_hook.js
/web/static/src/core/resizable_panel/resizable_panel.js
/web/static/src/core/select_menu/select_menu.js
/web/static/src/core/signature/name_and_signature.js
/web/static/src/core/signature/signature_dialog.js
/web/static/src/core/tags_list/tags_list.js
/web/static/src/core/template_inheritance.js
/web/static/src/core/templates.js
/web/static/src/core/time_picker/time_picker.js
/web/static/src/core/tooltip/tooltip.js
/web/static/src/core/tooltip/tooltip_hook.js
/web/static/src/core/tooltip/tooltip_service.js
/web/static/src/core/transition.js
/web/static/src/core/tree_editor/ast_utils.js
/web/static/src/core/tree_editor/condition_tree.js
/web/static/src/core/tree_editor/construct_domain_from_tree.js
/web/static/src/core/tree_editor/construct_expression_from_tree.js
/web/static/src/core/tree_editor/construct_tree_from_domain.js
/web/static/src/core/tree_editor/construct_tree_from_expression.js
/web/static/src/core/tree_editor/domain_contains_expressions.js
/web/static/src/core/tree_editor/domain_from_tree.js
/web/static/src/core/tree_editor/expression_from_tree.js
/web/static/src/core/tree_editor/operators.js
/web/static/src/core/tree_editor/tree_editor.js
/web/static/src/core/tree_editor/tree_editor_autocomplete.js
/web/static/src/core/tree_editor/tree_editor_components.js
/web/static/src/core/tree_editor/tree_editor_operator_editor.js
/web/static/src/core/tree_editor/tree_editor_value_editors.js
/web/static/src/core/tree_editor/tree_from_domain.js
/web/static/src/core/tree_editor/tree_from_expression.js
/web/static/src/core/tree_editor/tree_processor.js
/web/static/src/core/tree_editor/utils.js
/web/static/src/core/tree_editor/virtual_operators.js
/web/static/src/core/ui/block_ui.js
/web/static/src/core/ui/ui_service.js
/web/static/src/core/user.js
/web/static/src/core/user_switch/user_switch.js
/web/static/src/core/utils/arrays.js
/web/static/src/core/utils/autoresize.js
/web/static/src/core/utils/binary.js
/web/static/src/core/utils/cache.js
/web/static/src/core/utils/classname.js
/web/static/src/core/utils/colors.js
/web/static/src/core/utils/components.js
/web/static/src/core/utils/concurrency.js
/web/static/src/core/utils/draggable.js
/web/static/src/core/utils/draggable_hook_builder.js
/web/static/src/core/utils/draggable_hook_builder_owl.js
/web/static/src/core/utils/dvu.js
/web/static/src/core/utils/files.js
/web/static/src/core/utils/functions.js
/web/static/src/core/utils/hooks.js
/web/static/src/core/utils/html.js
/web/static/src/core/utils/indexed_db.js
/web/static/src/core/utils/misc.js
/web/static/src/core/utils/nested_sortable.js
/web/static/src/core/utils/numbers.js
/web/static/src/core/utils/objects.js
/web/static/src/core/utils/patch.js
/web/static/src/core/utils/pdfjs.js
/web/static/src/core/utils/reactive.js
/web/static/src/core/utils/render.js
/web/static/src/core/utils/scrolling.js
/web/static/src/core/utils/search.js
/web/static/src/core/utils/sortable.js
/web/static/src/core/utils/sortable_owl.js
/web/static/src/core/utils/sortable_service.js
/web/static/src/core/utils/strings.js
/web/static/src/core/utils/timing.js
/web/static/src/core/utils/ui.js
/web/static/src/core/utils/urls.js
/web/static/src/core/utils/xml.js
/web/static/src/core/virtual_grid_hook.js
/web/static/src/polyfills/array.js
/web/static/src/polyfills/clipboard.js
/web/static/src/polyfills/object.js
/web/static/src/polyfills/promise.js
/web/static/src/polyfills/set.js
/web/static/lib/popper/popper.js
/web/static/lib/bootstrap/js/dist/util/index.js
/web/static/lib/bootstrap/js/dist/dom/data.js
/web/static/lib/bootstrap/js/dist/dom/event-handler.js
/web/static/lib/bootstrap/js/dist/dom/manipulator.js
/web/static/lib/bootstrap/js/dist/dom/selector-engine.js
/web/static/lib/bootstrap/js/dist/util/config.js
/web/static/lib/bootstrap/js/dist/util/component-functions.js
/web/static/lib/bootstrap/js/dist/util/backdrop.js
/web/static/lib/bootstrap/js/dist/util/focustrap.js
/web/static/lib/bootstrap/js/dist/util/sanitizer.js
/web/static/lib/bootstrap/js/dist/util/scrollbar.js
/web/static/lib/bootstrap/js/dist/util/swipe.js
/web/static/lib/bootstrap/js/dist/util/template-factory.js
/web/static/lib/bootstrap/js/dist/base-component.js
/web/static/lib/bootstrap/js/dist/alert.js
/web/static/lib/bootstrap/js/dist/button.js
/web/static/lib/bootstrap/js/dist/carousel.js
/web/static/lib/bootstrap/js/dist/collapse.js
/web/static/lib/bootstrap/js/dist/dropdown.js
/web/static/lib/bootstrap/js/dist/modal.js
/web/static/lib/bootstrap/js/dist/offcanvas.js
/web/static/lib/bootstrap/js/dist/tooltip.js
/web/static/lib/bootstrap/js/dist/popover.js
/web/static/lib/bootstrap/js/dist/scrollspy.js
/web/static/lib/bootstrap/js/dist/tab.js
/web/static/lib/bootstrap/js/dist/toast.js
/web/static/src/libs/bootstrap.js
/web/static/lib/dompurify/DOMpurify.js
/web/static/src/model/model.js
/web/static/src/model/record.js
/web/static/src/model/relational_model/datapoint.js
/web/static/src/model/relational_model/dynamic_group_list.js
/web/static/src/model/relational_model/dynamic_list.js
/web/static/src/model/relational_model/dynamic_record_list.js
/web/static/src/model/relational_model/errors.js
/web/static/src/model/relational_model/group.js
/web/static/src/model/relational_model/operation.js
/web/static/src/model/relational_model/record.js
/web/static/src/model/relational_model/relational_model.js
/web/static/src/model/relational_model/static_list.js
/web/static/src/model/relational_model/utils.js
/web/static/src/model/sample_server.js
/web/static/src/search/action_hook.js
/web/static/src/search/action_menus/action_menus.js
/web/static/src/search/breadcrumbs/breadcrumbs.js
/web/static/src/search/cog_menu/cog_menu.js
/web/static/src/search/control_panel/control_panel.js
/web/static/src/search/custom_favorite_item/custom_favorite_item.js
/web/static/src/search/custom_group_by_item/custom_group_by_item.js
/web/static/src/search/layout.js
/web/static/src/search/pager_hook.js
/web/static/src/search/properties_group_by_item/properties_group_by_item.js
/web/static/src/search/search_arch_parser.js
/web/static/src/search/search_bar/search_bar.js
/web/static/src/search/search_bar/search_bar_toggler.js
/web/static/src/search/search_bar_menu/search_bar_menu.js
/web/static/src/search/search_model.js
/web/static/src/search/search_panel/search_panel.js
/web/static/src/search/utils/dates.js
/web/static/src/search/utils/group_by.js
/web/static/src/search/utils/misc.js
/web/static/src/search/utils/order_by.js
/web/static/src/search/with_search/with_search.js
/web/static/src/views/action_helper.js
/web/static/src/views/calendar/calendar_arch_parser.js
/web/static/src/views/calendar/calendar_common/calendar_common_popover.js
/web/static/src/views/calendar/calendar_common/calendar_common_renderer.js
/web/static/src/views/calendar/calendar_common/calendar_common_week_column.js
/web/static/src/views/calendar/calendar_controller.js
/web/static/src/views/calendar/calendar_filter_section/calendar_filter_section.js
/web/static/src/views/calendar/calendar_model.js
/web/static/src/views/calendar/calendar_renderer.js
/web/static/src/views/calendar/calendar_side_panel/calendar_side_panel.js
/web/static/src/views/calendar/calendar_view.js
/web/static/src/views/calendar/calendar_year/calendar_year_popover.js
/web/static/src/views/calendar/calendar_year/calendar_year_renderer.js
/web/static/src/views/calendar/hooks/calendar_popover_hook.js
/web/static/src/views/calendar/hooks/full_calendar_hook.js
/web/static/src/views/calendar/hooks/square_selection_hook.js
/web/static/src/views/calendar/mobile_filter_panel/calendar_mobile_filter_panel.js
/web/static/src/views/calendar/quick_create/calendar_quick_create.js
/web/static/src/views/calendar/utils.js
/web/static/src/views/debug_items.js
/web/static/src/views/fields/ace/ace_field.js
/web/static/src/views/fields/attachment_image/attachment_image_field.js
/web/static/src/views/fields/badge/badge_field.js
/web/static/src/views/fields/badge_selection/badge_selection_field.js
/web/static/src/views/fields/badge_selection/list_badge_selection_field.js
/web/static/src/views/fields/badge_selection_with_filter/badge_selection_field_with_filter.js
/web/static/src/views/fields/binary/binary_field.js
/web/static/src/views/fields/boolean/boolean_field.js
/web/static/src/views/fields/boolean_favorite/boolean_favorite_field.js
/web/static/src/views/fields/boolean_icon/boolean_icon_field.js
/web/static/src/views/fields/boolean_toggle/boolean_toggle_field.js
/web/static/src/views/fields/boolean_toggle/list_boolean_toggle_field.js
/web/static/src/views/fields/char/char_field.js
/web/static/src/views/fields/color/color_field.js
/web/static/src/views/fields/color_picker/color_picker_field.js
/web/static/src/views/fields/contact_image/contact_image_field.js
/web/static/src/views/fields/contact_statistics/contact_statistics.js
/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js
/web/static/src/views/fields/datetime/datetime_field.js
/web/static/src/views/fields/datetime/list_datetime_field.js
/web/static/src/views/fields/domain/domain_field.js
/web/static/src/views/fields/dynamic_placeholder_hook.js
/web/static/src/views/fields/dynamic_placeholder_popover.js
/web/static/src/views/fields/email/email_field.js
/web/static/src/views/fields/field.js
/web/static/src/views/fields/field_selector/field_selector_field.js
/web/static/src/views/fields/field_tooltip.js
/web/static/src/views/fields/file_handler.js
/web/static/src/views/fields/float/float_field.js
/web/static/src/views/fields/float_factor/float_factor_field.js
/web/static/src/views/fields/float_time/float_time_field.js
/web/static/src/views/fields/float_toggle/float_toggle_field.js
/web/static/src/views/fields/formatters.js
/web/static/src/views/fields/gauge/gauge_field.js
/web/static/src/views/fields/google_slide_viewer/google_slide_viewer.js
/web/static/src/views/fields/handle/handle_field.js
/web/static/src/views/fields/html/html_field.js
/web/static/src/views/fields/iframe_wrapper/iframe_wrapper_field.js
/web/static/src/views/fields/image/image_field.js
/web/static/src/views/fields/image_url/image_url_field.js
/web/static/src/views/fields/input_field_hook.js
/web/static/src/views/fields/integer/integer_field.js
/web/static/src/views/fields/ir_ui_view_ace/ace_field.js
/web/static/src/views/fields/journal_dashboard_graph/journal_dashboard_graph_field.js
/web/static/src/views/fields/json/json_field.js
/web/static/src/views/fields/json_checkboxes/json_checkboxes_field.js
/web/static/src/views/fields/kanban_color_picker/kanban_color_picker_field.js
/web/static/src/views/fields/label_selection/label_selection_field.js
/web/static/src/views/fields/many2many_binary/many2many_binary_field.js
/web/static/src/views/fields/many2many_checkboxes/many2many_checkboxes_field.js
/web/static/src/views/fields/many2many_tags/kanban_many2many_tags_field.js
/web/static/src/views/fields/many2many_tags/many2many_tags_field.js
/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.js
/web/static/src/views/fields/many2one/many2one.js
/web/static/src/views/fields/many2one/many2one_field.js
/web/static/src/views/fields/many2one_avatar/kanban_many2one_avatar_field.js
/web/static/src/views/fields/many2one_avatar/many2one_avatar_field.js
/web/static/src/views/fields/many2one_barcode/many2one_barcode_field.js
/web/static/src/views/fields/many2one_reference/many2one_reference_field.js
/web/static/src/views/fields/many2one_reference_integer/many2one_reference_integer_field.js
/web/static/src/views/fields/monetary/monetary_field.js
/web/static/src/views/fields/numpad_decimal_hook.js
/web/static/src/views/fields/parsers.js
/web/static/src/views/fields/pdf_viewer/pdf_viewer_field.js
/web/static/src/views/fields/percent_pie/percent_pie_field.js
/web/static/src/views/fields/percentage/percentage_field.js
/web/static/src/views/fields/phone/phone_field.js
/web/static/src/views/fields/priority/priority_field.js
/web/static/src/views/fields/progress_bar/kanban_progress_bar_field.js
/web/static/src/views/fields/progress_bar/progress_bar_field.js
/web/static/src/views/fields/properties/calendar_properties_field.js
/web/static/src/views/fields/properties/card_properties_field.js
/web/static/src/views/fields/properties/properties_field.js
/web/static/src/views/fields/properties/property_definition.js
/web/static/src/views/fields/properties/property_definition_selection.js
/web/static/src/views/fields/properties/property_tags.js
/web/static/src/views/fields/properties/property_text.js
/web/static/src/views/fields/properties/property_value.js
/web/static/src/views/fields/radio/radio_field.js
/web/static/src/views/fields/reference/reference_field.js
/web/static/src/views/fields/relational_utils.js
/web/static/src/views/fields/remaining_days/remaining_days_field.js
/web/static/src/views/fields/selection/filterable_selection_field.js
/web/static/src/views/fields/selection/selection_field.js
/web/static/src/views/fields/signature/signature_field.js
/web/static/src/views/fields/standard_field_props.js
/web/static/src/views/fields/stat_info/stat_info_field.js
/web/static/src/views/fields/state_selection/state_selection_field.js
/web/static/src/views/fields/statusbar/statusbar_field.js
/web/static/src/views/fields/text/text_field.js
/web/static/src/views/fields/timezone_mismatch/timezone_mismatch_field.js
/web/static/src/views/fields/translation_button.js
/web/static/src/views/fields/translation_dialog.js
/web/static/src/views/fields/url/url_field.js
/web/static/src/views/fields/x2many/list_x2many_field.js
/web/static/src/views/fields/x2many/x2many_field.js
/web/static/src/views/form/button_box/button_box.js
/web/static/src/views/form/form_arch_parser.js
/web/static/src/views/form/form_cog_menu/form_cog_menu.js
/web/static/src/views/form/form_compiler.js
/web/static/src/views/form/form_controller.js
/web/static/src/views/form/form_error_dialog/form_error_dialog.js
/web/static/src/views/form/form_group/form_group.js
/web/static/src/views/form/form_label.js
/web/static/src/views/form/form_renderer.js
/web/static/src/views/form/form_status_indicator/form_status_indicator.js
/web/static/src/views/form/form_view.js
/web/static/src/views/form/setting/setting.js
/web/static/src/views/form/status_bar_buttons/status_bar_buttons.js
/web/static/src/views/graph/graph_arch_parser.js
/web/static/src/views/graph/graph_controller.js
/web/static/src/views/graph/graph_model.js
/web/static/src/views/graph/graph_renderer.js
/web/static/src/views/graph/graph_search_model.js
/web/static/src/views/graph/graph_view.js
/web/static/src/views/kanban/kanban_arch_parser.js
/web/static/src/views/kanban/kanban_cog_menu.js
/web/static/src/views/kanban/kanban_column_examples_dialog.js
/web/static/src/views/kanban/kanban_column_quick_create.js
/web/static/src/views/kanban/kanban_compiler.js
/web/static/src/views/kanban/kanban_controller.js
/web/static/src/views/kanban/kanban_cover_image_dialog.js
/web/static/src/views/kanban/kanban_dropdown_menu_wrapper.js
/web/static/src/views/kanban/kanban_header.js
/web/static/src/views/kanban/kanban_record.js
/web/static/src/views/kanban/kanban_record_quick_create.js
/web/static/src/views/kanban/kanban_renderer.js
/web/static/src/views/kanban/kanban_view.js
/web/static/src/views/kanban/progress_bar_hook.js
/web/static/src/views/list/column_width_hook.js
/web/static/src/views/list/export_all/export_all.js
/web/static/src/views/list/list_arch_parser.js
/web/static/src/views/list/list_cog_menu.js
/web/static/src/views/list/list_confirmation_dialog.js
/web/static/src/views/list/list_controller.js
/web/static/src/views/list/list_renderer.js
/web/static/src/views/list/list_view.js
/web/static/src/views/pivot/pivot_arch_parser.js
/web/static/src/views/pivot/pivot_controller.js
/web/static/src/views/pivot/pivot_model.js
/web/static/src/views/pivot/pivot_renderer.js
/web/static/src/views/pivot/pivot_search_model.js
/web/static/src/views/pivot/pivot_view.js
/web/static/src/views/standard_view_props.js
/web/static/src/views/utils.js
/web/static/src/views/view.js
/web/static/src/views/view_button/multi_record_view_button.js
/web/static/src/views/view_button/view_button.js
/web/static/src/views/view_button/view_button_hook.js
/web/static/src/views/view_compiler.js
/web/static/src/views/view_components/animated_number.js
/web/static/src/views/view_components/column_progress.js
/web/static/src/views/view_components/group_config_menu.js
/web/static/src/views/view_components/multi_create_popover.js
/web/static/src/views/view_components/multi_currency_popover.js
/web/static/src/views/view_components/multi_selection_buttons.js
/web/static/src/views/view_components/report_view_measures.js
/web/static/src/views/view_components/selection_box.js
/web/static/src/views/view_components/view_scale_selector.js
/web/static/src/views/view_dialogs/export_data_dialog.js
/web/static/src/views/view_dialogs/form_view_dialog.js
/web/static/src/views/view_dialogs/select_create_dialog.js
/web/static/src/views/view_hook.js
/web/static/src/views/view_service.js
/web/static/src/views/widgets/attach_document/attach_document.js
/web/static/src/views/widgets/documentation_link/documentation_link.js
/web/static/src/views/widgets/notification_alert/notification_alert.js
/web/static/src/views/widgets/ribbon/ribbon.js
/web/static/src/views/widgets/signature/signature.js
/web/static/src/views/widgets/standard_widget_props.js
/web/static/src/views/widgets/week_days/week_days.js
/web/static/src/views/widgets/widget.js
/web/static/src/webclient/actions/action_container.js
/web/static/src/webclient/actions/action_dialog.js
/web/static/src/webclient/actions/action_install_kiosk_pwa.js
/web/static/src/webclient/actions/action_service.js
/web/static/src/webclient/actions/client_actions.js
/web/static/src/webclient/actions/debug_items.js
/web/static/src/webclient/actions/reports/report_action.js
/web/static/src/webclient/actions/reports/report_hook.js
/web/static/src/webclient/actions/reports/utils.js
/web/static/src/webclient/burger_menu/burger_menu.js
/web/static/src/webclient/burger_menu/burger_user_menu/burger_user_menu.js
/web/static/src/webclient/burger_menu/mobile_switch_company_menu/mobile_switch_company_menu.js
/web/static/src/webclient/clickbot/clickbot.js
/web/static/src/webclient/clickbot/clickbot_loader.js
/web/static/src/webclient/currency_service.js
/web/static/src/webclient/debug/debug_items.js
/web/static/src/webclient/debug/profiling/profiling_item.js
/web/static/src/webclient/debug/profiling/profiling_qweb.js
/web/static/src/webclient/debug/profiling/profiling_service.js
/web/static/src/webclient/debug/profiling/profiling_systray_item.js
/web/static/src/webclient/errors/offline_fail_to_fetch_error_handler.js
/web/static/src/webclient/loading_indicator/loading_indicator.js
/web/static/src/webclient/menus/menu_helpers.js
/web/static/src/webclient/menus/menu_providers.js
/web/static/src/webclient/menus/menu_service.js
/web/static/src/webclient/navbar/navbar.js
/web/static/src/webclient/reload_company_service.js
/web/static/src/webclient/res_user_group_ids_field/res_user_group_ids_field.js
/web/static/src/webclient/res_user_group_ids_field/res_user_group_ids_popover.js
/web/static/src/webclient/res_user_group_ids_field/res_user_group_ids_privilege_field.js
/web/static/src/webclient/session_service.js
/web/static/src/webclient/settings_form_view/fields/settings_binary_field/settings_binary_field.js
/web/static/src/webclient/settings_form_view/fields/upgrade_boolean_field.js
/web/static/src/webclient/settings_form_view/fields/upgrade_dialog.js
/web/static/src/webclient/settings_form_view/highlight_text/form_label_highlight_text.js
/web/static/src/webclient/settings_form_view/highlight_text/highlight_text.js
/web/static/src/webclient/settings_form_view/highlight_text/settings_radio_field.js
/web/static/src/webclient/settings_form_view/settings/searchable_setting.js
/web/static/src/webclient/settings_form_view/settings/setting_header.js
/web/static/src/webclient/settings_form_view/settings/settings_app.js
/web/static/src/webclient/settings_form_view/settings/settings_block.js
/web/static/src/webclient/settings_form_view/settings/settings_page.js
/web/static/src/webclient/settings_form_view/settings_confirmation_dialog.js
/web/static/src/webclient/settings_form_view/settings_form_compiler.js
/web/static/src/webclient/settings_form_view/settings_form_controller.js
/web/static/src/webclient/settings_form_view/settings_form_renderer.js
/web/static/src/webclient/settings_form_view/settings_form_view.js
/web/static/src/webclient/settings_form_view/widgets/demo_data_service.js
/web/static/src/webclient/settings_form_view/widgets/res_config_dev_tool.js
/web/static/src/webclient/settings_form_view/widgets/res_config_edition.js
/web/static/src/webclient/settings_form_view/widgets/res_config_invite_users.js
/web/static/src/webclient/settings_form_view/widgets/user_invite_service.js
/web/static/src/webclient/share_target/share_target_service.js
/web/static/src/webclient/switch_company_menu/switch_company_item.js
/web/static/src/webclient/switch_company_menu/switch_company_menu.js
/web/static/src/webclient/user_menu/user_menu.js
/web/static/src/webclient/user_menu/user_menu_items.js
/web/static/src/webclient/webclient.js
/web/static/src/webclient/actions/reports/report_action.js
/web/static/src/webclient/actions/reports/report_hook.js
/web/static/src/webclient/actions/reports/utils.js
/web/static/src/xml_templates_bundle.js
/web/static/src/main.js
/web/static/src/start.js

256
pkg/server/assets_xml.txt Normal file
View File

@@ -0,0 +1,256 @@
/web/static/src/core/action_swiper/action_swiper.xml
/web/static/src/core/autocomplete/autocomplete.xml
/web/static/src/core/barcode/barcode_dialog.xml
/web/static/src/core/barcode/barcode_video_scanner.xml
/web/static/src/core/barcode/crop_overlay.xml
/web/static/src/core/bottom_sheet/bottom_sheet.xml
/web/static/src/core/checkbox/checkbox.xml
/web/static/src/core/code_editor/code_editor.xml
/web/static/src/core/color_picker/color_picker.xml
/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.xml
/web/static/src/core/color_picker/tabs/color_picker_custom_tab.xml
/web/static/src/core/color_picker/tabs/color_picker_solid_tab.xml
/web/static/src/core/colorlist/colorlist.xml
/web/static/src/core/commands/command_items.xml
/web/static/src/core/commands/command_palette.xml
/web/static/src/core/confirmation_dialog/confirmation_dialog.xml
/web/static/src/core/copy_button/copy_button.xml
/web/static/src/core/datetime/datetime_input.xml
/web/static/src/core/datetime/datetime_picker.xml
/web/static/src/core/datetime/datetime_picker_popover.xml
/web/static/src/core/debug/debug_menu.xml
/web/static/src/core/debug/debug_menu_items.xml
/web/static/src/core/dialog/dialog.xml
/web/static/src/core/domain_selector/domain_selector.xml
/web/static/src/core/domain_selector_dialog/domain_selector_dialog.xml
/web/static/src/core/dropdown/accordion_item.xml
/web/static/src/core/dropdown/dropdown_item.xml
/web/static/src/core/dropzone/dropzone.xml
/web/static/src/core/effects/rainbow_man.xml
/web/static/src/core/emoji_picker/emoji_picker.xml
/web/static/src/core/errors/error_dialogs.xml
/web/static/src/core/expression_editor/expression_editor.xml
/web/static/src/core/expression_editor_dialog/expression_editor_dialog.xml
/web/static/src/core/file_input/file_input.xml
/web/static/src/core/file_upload/file_upload_progress_bar.xml
/web/static/src/core/file_upload/file_upload_progress_container.xml
/web/static/src/core/file_upload/file_upload_progress_record.xml
/web/static/src/core/file_viewer/file_viewer.xml
/web/static/src/core/install_scoped_app/install_scoped_app.xml
/web/static/src/core/model_field_selector/model_field_selector.xml
/web/static/src/core/model_field_selector/model_field_selector_popover.xml
/web/static/src/core/model_selector/model_selector.xml
/web/static/src/core/notebook/notebook.xml
/web/static/src/core/notifications/notification.xml
/web/static/src/core/overlay/overlay_container.xml
/web/static/src/core/pager/pager.xml
/web/static/src/core/pager/pager_indicator.xml
/web/static/src/core/popover/popover.xml
/web/static/src/core/pwa/install_prompt.xml
/web/static/src/core/record_selectors/multi_record_selector.xml
/web/static/src/core/record_selectors/record_autocomplete.xml
/web/static/src/core/record_selectors/record_selector.xml
/web/static/src/core/resizable_panel/resizable_panel.xml
/web/static/src/core/select_menu/select_menu.xml
/web/static/src/core/signature/name_and_signature.xml
/web/static/src/core/signature/signature_dialog.xml
/web/static/src/core/tags_list/tags_list.xml
/web/static/src/core/time_picker/time_picker.xml
/web/static/src/core/tooltip/tooltip.xml
/web/static/src/core/tree_editor/tree_editor.xml
/web/static/src/core/tree_editor/tree_editor_components.xml
/web/static/src/core/ui/block_ui.xml
/web/static/src/core/user_switch/user_switch.xml
/web/static/src/search/action_menus/action_menus.xml
/web/static/src/search/breadcrumbs/breadcrumbs.xml
/web/static/src/search/cog_menu/cog_menu.xml
/web/static/src/search/control_panel/control_panel.xml
/web/static/src/search/custom_favorite_item/custom_favorite_item.xml
/web/static/src/search/custom_group_by_item/custom_group_by_item.xml
/web/static/src/search/layout.xml
/web/static/src/search/properties_group_by_item/properties_group_by_item.xml
/web/static/src/search/search_bar/search_bar.xml
/web/static/src/search/search_bar/search_bar_toggler.xml
/web/static/src/search/search_bar_menu/search_bar_menu.xml
/web/static/src/search/search_panel/search_panel.xml
/web/static/src/search/with_search/with_search.xml
/web/static/src/views/action_helper.xml
/web/static/src/views/calendar/calendar_common/calendar_common_popover.xml
/web/static/src/views/calendar/calendar_common/calendar_common_renderer.xml
/web/static/src/views/calendar/calendar_controller.xml
/web/static/src/views/calendar/calendar_filter_section/calendar_filter_section.xml
/web/static/src/views/calendar/calendar_renderer.xml
/web/static/src/views/calendar/calendar_side_panel/calendar_side_panel.xml
/web/static/src/views/calendar/calendar_year/calendar_year_popover.xml
/web/static/src/views/calendar/calendar_year/calendar_year_renderer.xml
/web/static/src/views/calendar/mobile_filter_panel/calendar_mobile_filter_panel.xml
/web/static/src/views/calendar/quick_create/calendar_quick_create.xml
/web/static/src/views/fields/ace/ace_field.xml
/web/static/src/views/fields/attachment_image/attachment_image_field.xml
/web/static/src/views/fields/badge/badge_field.xml
/web/static/src/views/fields/badge_selection/badge_selection_field.xml
/web/static/src/views/fields/badge_selection/list_badge_selection_field.xml
/web/static/src/views/fields/binary/binary_field.xml
/web/static/src/views/fields/boolean/boolean_field.xml
/web/static/src/views/fields/boolean_favorite/boolean_favorite_field.xml
/web/static/src/views/fields/boolean_icon/boolean_icon_field.xml
/web/static/src/views/fields/boolean_toggle/boolean_toggle_field.xml
/web/static/src/views/fields/boolean_toggle/list_boolean_toggle_field.xml
/web/static/src/views/fields/char/char_field.xml
/web/static/src/views/fields/color/color_field.xml
/web/static/src/views/fields/color_picker/color_picker_field.xml
/web/static/src/views/fields/contact_image/contact_image_field.xml
/web/static/src/views/fields/contact_statistics/contact_statistics.xml
/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.xml
/web/static/src/views/fields/datetime/datetime_field.xml
/web/static/src/views/fields/domain/domain_field.xml
/web/static/src/views/fields/dynamic_placeholder_popover.xml
/web/static/src/views/fields/email/email_field.xml
/web/static/src/views/fields/field.xml
/web/static/src/views/fields/field_selector/field_selector_field.xml
/web/static/src/views/fields/field_tooltip.xml
/web/static/src/views/fields/file_handler.xml
/web/static/src/views/fields/float/float_field.xml
/web/static/src/views/fields/float_time/float_time_field.xml
/web/static/src/views/fields/float_toggle/float_toggle_field.xml
/web/static/src/views/fields/gauge/gauge_field.xml
/web/static/src/views/fields/google_slide_viewer/google_slide_viewer.xml
/web/static/src/views/fields/handle/handle_field.xml
/web/static/src/views/fields/html/html_field.xml
/web/static/src/views/fields/iframe_wrapper/iframe_wrapper_field.xml
/web/static/src/views/fields/image/image_field.xml
/web/static/src/views/fields/image_url/image_url_field.xml
/web/static/src/views/fields/integer/integer_field.xml
/web/static/src/views/fields/ir_ui_view_ace/ace_field.xml
/web/static/src/views/fields/journal_dashboard_graph/journal_dashboard_graph_field.xml
/web/static/src/views/fields/json/json_field.xml
/web/static/src/views/fields/json_checkboxes/json_checkboxes_field.xml
/web/static/src/views/fields/kanban_color_picker/kanban_color_picker_field.xml
/web/static/src/views/fields/label_selection/label_selection_field.xml
/web/static/src/views/fields/many2many_binary/many2many_binary_field.xml
/web/static/src/views/fields/many2many_checkboxes/many2many_checkboxes_field.xml
/web/static/src/views/fields/many2many_tags/kanban_many2many_tags_field.xml
/web/static/src/views/fields/many2many_tags/many2many_tags_field.xml
/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.xml
/web/static/src/views/fields/many2one/many2one.xml
/web/static/src/views/fields/many2one/many2one_field.xml
/web/static/src/views/fields/many2one_avatar/kanban_many2one_avatar_field.xml
/web/static/src/views/fields/many2one_avatar/many2one_avatar_field.xml
/web/static/src/views/fields/many2one_barcode/many2one_barcode_field.xml
/web/static/src/views/fields/many2one_reference/many2one_reference_field.xml
/web/static/src/views/fields/monetary/monetary_field.xml
/web/static/src/views/fields/pdf_viewer/pdf_viewer_field.xml
/web/static/src/views/fields/percent_pie/percent_pie_field.xml
/web/static/src/views/fields/percentage/percentage_field.xml
/web/static/src/views/fields/phone/phone_field.xml
/web/static/src/views/fields/priority/priority_field.xml
/web/static/src/views/fields/progress_bar/progress_bar_field.xml
/web/static/src/views/fields/properties/calendar_properties_field.xml
/web/static/src/views/fields/properties/card_properties_field.xml
/web/static/src/views/fields/properties/properties_field.xml
/web/static/src/views/fields/properties/property_definition.xml
/web/static/src/views/fields/properties/property_definition_selection.xml
/web/static/src/views/fields/properties/property_tags.xml
/web/static/src/views/fields/properties/property_text.xml
/web/static/src/views/fields/properties/property_value.xml
/web/static/src/views/fields/radio/radio_field.xml
/web/static/src/views/fields/reference/reference_field.xml
/web/static/src/views/fields/relational_utils.xml
/web/static/src/views/fields/remaining_days/remaining_days_field.xml
/web/static/src/views/fields/selection/selection_field.xml
/web/static/src/views/fields/signature/signature_field.xml
/web/static/src/views/fields/stat_info/stat_info_field.xml
/web/static/src/views/fields/state_selection/state_selection_field.xml
/web/static/src/views/fields/statusbar/statusbar_field.xml
/web/static/src/views/fields/text/text_field.xml
/web/static/src/views/fields/timezone_mismatch/timezone_mismatch_field.xml
/web/static/src/views/fields/translation_button.xml
/web/static/src/views/fields/translation_dialog.xml
/web/static/src/views/fields/url/url_field.xml
/web/static/src/views/fields/x2many/list_x2many_field.xml
/web/static/src/views/fields/x2many/x2many_field.xml
/web/static/src/views/form/button_box/button_box.xml
/web/static/src/views/form/form_cog_menu/form_cog_menu.xml
/web/static/src/views/form/form_controller.xml
/web/static/src/views/form/form_error_dialog/form_error_dialog.xml
/web/static/src/views/form/form_group/form_group.xml
/web/static/src/views/form/form_label.xml
/web/static/src/views/form/form_status_indicator/form_status_indicator.xml
/web/static/src/views/form/setting/setting.xml
/web/static/src/views/form/status_bar_buttons/status_bar_buttons.xml
/web/static/src/views/graph/graph_controller.xml
/web/static/src/views/graph/graph_renderer.xml
/web/static/src/views/kanban/kanban_cog_menu.xml
/web/static/src/views/kanban/kanban_column_examples_dialog.xml
/web/static/src/views/kanban/kanban_column_quick_create.xml
/web/static/src/views/kanban/kanban_controller.xml
/web/static/src/views/kanban/kanban_cover_image_dialog.xml
/web/static/src/views/kanban/kanban_header.xml
/web/static/src/views/kanban/kanban_record.xml
/web/static/src/views/kanban/kanban_record_quick_create.xml
/web/static/src/views/kanban/kanban_renderer.xml
/web/static/src/views/list/export_all/export_all.xml
/web/static/src/views/list/list_cog_menu.xml
/web/static/src/views/list/list_confirmation_dialog.xml
/web/static/src/views/list/list_controller.xml
/web/static/src/views/list/list_renderer.xml
/web/static/src/views/no_content_helpers.xml
/web/static/src/views/pivot/pivot_controller.xml
/web/static/src/views/pivot/pivot_renderer.xml
/web/static/src/views/view.xml
/web/static/src/views/view_button/view_button.xml
/web/static/src/views/view_components/animated_number.xml
/web/static/src/views/view_components/column_progress.xml
/web/static/src/views/view_components/group_config_menu.xml
/web/static/src/views/view_components/multi_create_popover.xml
/web/static/src/views/view_components/multi_currency_popover.xml
/web/static/src/views/view_components/multi_selection_buttons.xml
/web/static/src/views/view_components/report_view_measures.xml
/web/static/src/views/view_components/selection_box.xml
/web/static/src/views/view_components/view_scale_selector.xml
/web/static/src/views/view_dialogs/export_data_dialog.xml
/web/static/src/views/view_dialogs/form_view_dialog.xml
/web/static/src/views/view_dialogs/select_create_dialog.xml
/web/static/src/views/widgets/attach_document/attach_document.xml
/web/static/src/views/widgets/documentation_link/documentation_link.xml
/web/static/src/views/widgets/notification_alert/notification_alert.xml
/web/static/src/views/widgets/ribbon/ribbon.xml
/web/static/src/views/widgets/signature/signature.xml
/web/static/src/views/widgets/week_days/week_days.xml
/web/static/src/webclient/actions/action_dialog.xml
/web/static/src/webclient/actions/action_install_kiosk_pwa.xml
/web/static/src/webclient/actions/blank_component.xml
/web/static/src/webclient/actions/reports/report_action.xml
/web/static/src/webclient/burger_menu/burger_menu.xml
/web/static/src/webclient/burger_menu/burger_user_menu/burger_user_menu.xml
/web/static/src/webclient/burger_menu/mobile_switch_company_menu/mobile_switch_company_menu.xml
/web/static/src/webclient/debug/profiling/profiling_item.xml
/web/static/src/webclient/debug/profiling/profiling_qweb.xml
/web/static/src/webclient/debug/profiling/profiling_systray_item.xml
/web/static/src/webclient/loading_indicator/loading_indicator.xml
/web/static/src/webclient/menus/menu_command_item.xml
/web/static/src/webclient/navbar/navbar.xml
/web/static/src/webclient/res_user_group_ids_field/res_user_group_ids_field.xml
/web/static/src/webclient/res_user_group_ids_field/res_user_group_ids_popover.xml
/web/static/src/webclient/res_user_group_ids_field/res_user_group_ids_privilege_field.xml
/web/static/src/webclient/settings_form_view/fields/settings_binary_field/settings_binary_field.xml
/web/static/src/webclient/settings_form_view/fields/upgrade_dialog.xml
/web/static/src/webclient/settings_form_view/highlight_text/form_label_highlight_text.xml
/web/static/src/webclient/settings_form_view/highlight_text/highlight_text.xml
/web/static/src/webclient/settings_form_view/highlight_text/settings_radio_field.xml
/web/static/src/webclient/settings_form_view/settings/searchable_setting.xml
/web/static/src/webclient/settings_form_view/settings/setting_header.xml
/web/static/src/webclient/settings_form_view/settings/settings_app.xml
/web/static/src/webclient/settings_form_view/settings/settings_block.xml
/web/static/src/webclient/settings_form_view/settings/settings_page.xml
/web/static/src/webclient/settings_form_view/settings_confirmation_dialog.xml
/web/static/src/webclient/settings_form_view/settings_form_view.xml
/web/static/src/webclient/settings_form_view/widgets/res_config_dev_tool.xml
/web/static/src/webclient/settings_form_view/widgets/res_config_edition.xml
/web/static/src/webclient/settings_form_view/widgets/res_config_invite_users.xml
/web/static/src/webclient/switch_company_menu/switch_company_item.xml
/web/static/src/webclient/switch_company_menu/switch_company_menu.xml
/web/static/src/webclient/user_menu/user_menu.xml
/web/static/src/webclient/user_menu/user_menu_items.xml
/web/static/src/webclient/webclient.xml
/web/static/src/webclient/actions/reports/report_action.xml

57
pkg/server/fields_get.go Normal file
View File

@@ -0,0 +1,57 @@
package server
import "odoo-go/pkg/orm"
// fieldsGetForModel returns field metadata for a model.
// Mirrors: odoo/orm/models.py BaseModel.fields_get()
func fieldsGetForModel(modelName string) map[string]interface{} {
m := orm.Registry.Get(modelName)
if m == nil {
return map[string]interface{}{}
}
result := make(map[string]interface{})
for name, f := range m.Fields() {
fieldInfo := map[string]interface{}{
"name": name,
"type": f.Type.String(),
"string": f.String,
"help": f.Help,
"readonly": f.Readonly,
"required": f.Required,
"searchable": f.IsStored(),
"sortable": f.IsStored(),
"store": f.IsStored(),
"manual": false,
"depends": f.Depends,
"groupable": f.IsStored() && f.Type != orm.TypeText && f.Type != orm.TypeHTML,
"exportable": true,
"change_default": false,
}
// Relational fields
if f.Comodel != "" {
fieldInfo["relation"] = f.Comodel
}
if f.InverseField != "" {
fieldInfo["relation_field"] = f.InverseField
}
// Selection
if f.Type == orm.TypeSelection && len(f.Selection) > 0 {
sel := make([][]string, len(f.Selection))
for i, item := range f.Selection {
sel[i] = []string{item.Value, item.Label}
}
fieldInfo["selection"] = sel
}
// Domain & context defaults
fieldInfo["domain"] = "[]"
fieldInfo["context"] = "{}"
result[name] = fieldInfo
}
return result
}

80
pkg/server/login.go Normal file
View File

@@ -0,0 +1,80 @@
package server
import (
"net/http"
)
// handleLogin serves the login page.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Odoo - Login</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0eeee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 100%; max-width: 400px; }
.login-box h1 { text-align: center; color: #71639e; margin-bottom: 30px; font-size: 28px; }
.login-box label { display: block; margin-bottom: 6px; font-weight: 500; color: #333; }
.login-box input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 4px;
font-size: 14px; margin-bottom: 16px; }
.login-box input:focus { outline: none; border-color: #71639e; box-shadow: 0 0 0 2px rgba(113,99,158,0.2); }
.login-box button { width: 100%; padding: 12px; background: #71639e; color: white; border: none;
border-radius: 4px; font-size: 16px; cursor: pointer; }
.login-box button:hover { background: #5f5387; }
.error { color: #dc3545; margin-bottom: 16px; display: none; text-align: center; }
</style>
</head>
<body>
<div class="login-box">
<h1>Odoo</h1>
<div id="error" class="error"></div>
<form id="loginForm">
<label for="login">Email</label>
<input type="text" id="login" name="login" value="admin" autofocus/>
<label for="password">Password</label>
<input type="password" id="password" name="password" value="admin"/>
<button type="submit">Log in</button>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', function(e) {
e.preventDefault();
var login = document.getElementById('login').value;
var password = document.getElementById('password').value;
var errorEl = document.getElementById('error');
errorEl.style.display = 'none';
fetch('/web/session/authenticate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
id: 1,
params: {db: '` + s.config.DBName + `', login: login, password: password}
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
errorEl.textContent = data.error.message;
errorEl.style.display = 'block';
} else if (data.result && data.result.uid) {
window.location.href = '/web';
}
})
.catch(function(err) {
errorEl.textContent = 'Connection error';
errorEl.style.display = 'block';
});
});
</script>
</body>
</html>`))
}

61
pkg/server/menus.go Normal file
View File

@@ -0,0 +1,61 @@
package server
import (
"encoding/json"
"net/http"
)
// handleLoadMenus returns the menu tree for the webclient.
// Mirrors: odoo/addons/web/controllers/home.py Home.web_load_menus()
func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
// Build menu tree from database or hardcoded defaults
menus := map[string]interface{}{
"root": map[string]interface{}{
"id": "root",
"name": "root",
"children": []int{1},
"appID": false,
"xmlid": "",
"actionID": false,
"actionModel": false,
"actionPath": false,
"webIcon": nil,
"webIconData": nil,
"webIconDataMimetype": nil,
"backgroundImage": nil,
},
"1": map[string]interface{}{
"id": 1,
"name": "Contacts",
"children": []int{10},
"appID": 1,
"xmlid": "contacts.menu_contacts",
"actionID": 1,
"actionModel": "ir.actions.act_window",
"actionPath": false,
"webIcon": "fa-address-book,#71639e,#FFFFFF",
"webIconData": nil,
"webIconDataMimetype": nil,
"backgroundImage": nil,
},
"10": map[string]interface{}{
"id": 10,
"name": "Contacts",
"children": []int{},
"appID": 1,
"xmlid": "contacts.menu_contacts_list",
"actionID": 1,
"actionModel": "ir.actions.act_window",
"actionPath": false,
"webIcon": nil,
"webIconData": nil,
"webIconDataMimetype": nil,
"backgroundImage": nil,
},
}
json.NewEncoder(w).Encode(menus)
}

61
pkg/server/middleware.go Normal file
View File

@@ -0,0 +1,61 @@
package server
import (
"context"
"net/http"
"strings"
)
type contextKey string
const sessionKey contextKey = "session"
// AuthMiddleware checks for a valid session cookie on protected endpoints.
func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Public endpoints (no auth required)
path := r.URL.Path
if path == "/health" ||
path == "/web/login" ||
path == "/web/setup" ||
path == "/web/setup/install" ||
path == "/web/session/authenticate" ||
path == "/web/database/list" ||
path == "/web/webclient/version_info" ||
strings.Contains(path, "/static/") {
next.ServeHTTP(w, r)
return
}
// Check session cookie
cookie, err := r.Cookie("session_id")
if err != nil || cookie.Value == "" {
// Also check JSON-RPC params for session_id (Odoo sends it both ways)
next.ServeHTTP(w, r) // For now, allow through — UID defaults to 1
return
}
sess := store.Get(cookie.Value)
if sess == nil {
// JSON-RPC endpoints get JSON error, browser gets redirect
if r.Header.Get("Content-Type") == "application/json" ||
strings.HasPrefix(path, "/web/dataset/") ||
strings.HasPrefix(path, "/jsonrpc") {
http.Error(w, `{"jsonrpc":"2.0","error":{"code":100,"message":"Session expired"}}`, http.StatusUnauthorized)
} else {
http.Redirect(w, r, "/web/login", http.StatusFound)
}
return
}
// Inject session into context
ctx := context.WithValue(r.Context(), sessionKey, sess)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// GetSession extracts the session from request context.
func GetSession(r *http.Request) *Session {
sess, _ := r.Context().Value(sessionKey).(*Session)
return sess
}

665
pkg/server/server.go Normal file
View File

@@ -0,0 +1,665 @@
// Package server implements the HTTP server and RPC dispatch.
// Mirrors: odoo/http.py, odoo/service/server.py
package server
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"odoo-go/pkg/orm"
"odoo-go/pkg/tools"
)
// Server is the main Odoo HTTP server.
// Mirrors: odoo/service/server.py ThreadedServer
type Server struct {
config *tools.Config
pool *pgxpool.Pool
mux *http.ServeMux
sessions *SessionStore
}
// New creates a new server instance.
func New(cfg *tools.Config, pool *pgxpool.Pool) *Server {
s := &Server{
config: cfg,
pool: pool,
mux: http.NewServeMux(),
sessions: NewSessionStore(24 * time.Hour),
}
s.registerRoutes()
return s
}
// registerRoutes sets up HTTP routes.
// Mirrors: odoo/http.py Application._setup_routes()
func (s *Server) registerRoutes() {
// Webclient HTML shell
s.mux.HandleFunc("/web", s.handleWebClient)
s.mux.HandleFunc("/web/", s.handleWebRoute)
s.mux.HandleFunc("/odoo", s.handleWebClient)
s.mux.HandleFunc("/odoo/", s.handleWebClient)
// Login page
s.mux.HandleFunc("/web/login", s.handleLogin)
// JSON-RPC endpoint (main API)
s.mux.HandleFunc("/jsonrpc", s.handleJSONRPC)
s.mux.HandleFunc("/web/dataset/call_kw", s.handleCallKW)
s.mux.HandleFunc("/web/dataset/call_kw/", s.handleCallKW)
// Session endpoints
s.mux.HandleFunc("/web/session/authenticate", s.handleAuthenticate)
s.mux.HandleFunc("/web/session/get_session_info", s.handleSessionInfo)
s.mux.HandleFunc("/web/session/check", s.handleSessionCheck)
s.mux.HandleFunc("/web/session/modules", s.handleSessionModules)
// Webclient endpoints
s.mux.HandleFunc("/web/webclient/load_menus", s.handleLoadMenus)
s.mux.HandleFunc("/web/webclient/translations", s.handleTranslations)
s.mux.HandleFunc("/web/webclient/version_info", s.handleVersionInfo)
s.mux.HandleFunc("/web/webclient/bootstrap_translations", s.handleBootstrapTranslations)
// Action loading
s.mux.HandleFunc("/web/action/load", s.handleActionLoad)
// Database endpoints
s.mux.HandleFunc("/web/database/list", s.handleDBList)
// Setup wizard
s.mux.HandleFunc("/web/setup", s.handleSetup)
s.mux.HandleFunc("/web/setup/install", s.handleSetupInstall)
// PWA manifest
s.mux.HandleFunc("/web/manifest.webmanifest", s.handleManifest)
// Health check
s.mux.HandleFunc("/health", s.handleHealth)
// Static files (catch-all for /<addon>/static/...)
// NOTE: must be last since it's a broad pattern
}
// handleWebRoute dispatches /web/* sub-routes or falls back to static files.
func (s *Server) handleWebRoute(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Known sub-routes are handled by specific handlers above.
// Anything under /web/static/ is a static file request.
if strings.HasPrefix(path, "/web/static/") {
s.handleStatic(w, r)
return
}
// For all other /web/* paths, serve the webclient (SPA routing)
s.handleWebClient(w, r)
}
// Start starts the HTTP server.
func (s *Server) Start() error {
addr := fmt.Sprintf("%s:%d", s.config.HTTPInterface, s.config.HTTPPort)
log.Printf("odoo: HTTP service running on %s", addr)
srv := &http.Server{
Addr: addr,
Handler: AuthMiddleware(s.sessions, s.mux),
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
return srv.ListenAndServe()
}
// --- JSON-RPC ---
// Mirrors: odoo/http.py JsonRPCDispatcher
// JSONRPCRequest is the JSON-RPC 2.0 request format.
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
ID interface{} `json:"id"`
Params json.RawMessage `json:"params"`
}
// JSONRPCResponse is the JSON-RPC 2.0 response format.
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
// RPCError represents a JSON-RPC error.
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// CallKWParams mirrors the /web/dataset/call_kw parameters.
type CallKWParams struct {
Model string `json:"model"`
Method string `json:"method"`
Args []interface{} `json:"args"`
KW Values `json:"kwargs"`
}
// Values is a generic key-value map for RPC parameters.
type Values = map[string]interface{}
func (s *Server) handleJSONRPC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32700, Message: "Parse error",
})
return
}
// Dispatch based on method
s.writeJSONRPC(w, req.ID, map[string]string{"status": "ok"}, nil)
}
// handleCallKW handles ORM method calls via JSON-RPC.
// Mirrors: odoo/service/model.py execute_kw()
func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32700, Message: "Parse error",
})
return
}
var params CallKWParams
if err := json.Unmarshal(req.Params, &params); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32602, Message: "Invalid params",
})
return
}
// Extract UID from session, default to 1 (admin) if no session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
// Create environment for this request
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
})
if err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32000, Message: err.Error(),
})
return
}
defer env.Close()
// Dispatch ORM method
result, rpcErr := s.dispatchORM(env, params)
if rpcErr != nil {
s.writeJSONRPC(w, req.ID, nil, rpcErr)
return
}
if err := env.Commit(); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32000, Message: err.Error(),
})
return
}
s.writeJSONRPC(w, req.ID, result, nil)
}
// checkAccess verifies the current user has permission for the operation.
// Mirrors: odoo/addons/base/models/ir_model.py IrModelAccess.check()
func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCError {
if env.IsSuperuser() || env.UID() == 1 {
return nil // Superuser bypasses all checks
}
perm := "perm_read"
switch method {
case "create":
perm = "perm_create"
case "write":
perm = "perm_write"
case "unlink":
perm = "perm_unlink"
}
// Check if any ACL exists for this model
var count int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM ir_model_access a
JOIN ir_model m ON m.id = a.model_id
WHERE m.model = $1`, model).Scan(&count)
if err != nil || count == 0 {
return nil // No ACLs defined → open access (like Odoo superuser mode)
}
// Check if user's groups grant permission
var granted bool
err = env.Tx().QueryRow(env.Ctx(), fmt.Sprintf(`
SELECT EXISTS(
SELECT 1 FROM ir_model_access a
JOIN ir_model m ON m.id = a.model_id
LEFT JOIN res_groups_res_users_rel gu ON gu.res_groups_id = a.group_id
WHERE m.model = $1
AND a.active = true
AND a.%s = true
AND (a.group_id IS NULL OR gu.res_users_id = $2)
)`, perm), model, env.UID()).Scan(&granted)
if err != nil {
return nil // On error, allow (fail-open for now)
}
if !granted {
return &RPCError{
Code: 403,
Message: fmt.Sprintf("Access Denied: %s on %s", method, model),
}
}
return nil
}
// dispatchORM dispatches an ORM method call.
// Mirrors: odoo/service/model.py call_kw()
func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interface{}, *RPCError) {
// Check access control
if err := s.checkAccess(env, params.Model, params.Method); err != nil {
return nil, err
}
rs := env.Model(params.Model)
switch params.Method {
case "has_group":
// Always return true for admin user, stub for now
return true, nil
case "check_access_rights":
return true, nil
case "fields_get":
return fieldsGetForModel(params.Model), nil
case "web_search_read":
return handleWebSearchRead(env, params.Model, params)
case "web_read":
return handleWebRead(env, params.Model, params)
case "get_views":
return handleGetViews(env, params.Model, params)
case "onchange":
// Basic onchange: return empty value dict
return map[string]interface{}{"value": map[string]interface{}{}}, nil
case "search_read":
domain := parseDomain(params.Args)
fields := parseFields(params.KW)
records, err := rs.SearchRead(domain, fields)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return records, nil
case "read":
ids := parseIDs(params.Args)
fields := parseFields(params.KW)
records, err := rs.Browse(ids...).Read(fields)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return records, nil
case "create":
vals := parseValues(params.Args)
record, err := rs.Create(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return record.ID(), nil
case "write":
ids := parseIDs(params.Args)
vals := parseValuesAt(params.Args, 1)
err := rs.Browse(ids...).Write(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return true, nil
case "unlink":
ids := parseIDs(params.Args)
err := rs.Browse(ids...).Unlink()
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return true, nil
case "search_count":
domain := parseDomain(params.Args)
count, err := rs.SearchCount(domain)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return count, nil
case "name_get":
ids := parseIDs(params.Args)
names, err := rs.Browse(ids...).NameGet()
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
// Convert map to Odoo format: [[id, "name"], ...]
var result [][]interface{}
for id, name := range names {
result = append(result, []interface{}{id, name})
}
return result, nil
case "name_search":
// Basic name_search: search by name, return [[id, "name"], ...]
nameStr := ""
if len(params.Args) > 0 {
nameStr, _ = params.Args[0].(string)
}
limit := 8
domain := orm.Domain{}
if nameStr != "" {
domain = orm.And(orm.Leaf("name", "ilike", nameStr))
}
found, err := rs.Search(domain, orm.SearchOpts{Limit: limit})
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
names, err := found.NameGet()
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
var nameResult [][]interface{}
for id, name := range names {
nameResult = append(nameResult, []interface{}{id, name})
}
return nameResult, nil
default:
// Try registered business methods on the model
model := orm.Registry.Get(params.Model)
if model != nil && model.Methods != nil {
if method, ok := model.Methods[params.Method]; ok {
ids := parseIDs(params.Args)
result, err := method(rs.Browse(ids...), params.Args[1:]...)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return result, nil
}
}
return nil, &RPCError{
Code: -32601,
Message: fmt.Sprintf("Method %q not found on %s", params.Method, params.Model),
}
}
}
// --- Session / Auth Endpoints ---
func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
return
}
var params struct {
DB string `json:"db"`
Login string `json:"login"`
Password string `json:"password"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
return
}
// Query user by login
var uid int64
var companyID int64
var partnerID int64
var hashedPw string
var userName string
err := s.pool.QueryRow(r.Context(),
`SELECT u.id, u.password, u.company_id, u.partner_id, p.name
FROM res_users u
JOIN res_partner p ON p.id = u.partner_id
WHERE u.login = $1 AND u.active = true`,
params.Login,
).Scan(&uid, &hashedPw, &companyID, &partnerID, &userName)
if err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: 100, Message: "Access Denied: invalid login or password",
})
return
}
// Check password (support both bcrypt and plaintext for migration)
if !tools.CheckPassword(hashedPw, params.Password) && hashedPw != params.Password {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: 100, Message: "Access Denied: invalid login or password",
})
return
}
// Create session
sess := s.sessions.New(uid, companyID, params.Login)
// Set session cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sess.ID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
s.writeJSONRPC(w, req.ID, map[string]interface{}{
"uid": uid,
"session_id": sess.ID,
"company_id": companyID,
"partner_id": partnerID,
"is_admin": uid == 1,
"name": userName,
"username": params.Login,
"server_version": "19.0-go",
"server_version_info": []interface{}{19, 0, 0, "final", 0, "g"},
"db": s.config.DBName,
}, nil)
}
func (s *Server) handleSessionInfo(w http.ResponseWriter, r *http.Request) {
s.writeJSONRPC(w, nil, map[string]interface{}{
"uid": 1,
"is_admin": true,
"server_version": "19.0-go",
"server_version_info": []interface{}{19, 0, 0, "final", 0, "g"},
"db": s.config.DBName,
}, nil)
}
func (s *Server) handleDBList(w http.ResponseWriter, r *http.Request) {
s.writeJSONRPC(w, nil, []string{s.config.DBName}, nil)
}
func (s *Server) handleVersionInfo(w http.ResponseWriter, r *http.Request) {
s.writeJSONRPC(w, nil, map[string]interface{}{
"server_version": "19.0-go",
"server_version_info": []interface{}{19, 0, 0, "final", 0, "g"},
"server_serie": "19.0",
"protocol_version": 1,
}, nil)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
err := s.pool.Ping(context.Background())
if err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
}
// --- Helpers ---
func (s *Server) writeJSONRPC(w http.ResponseWriter, id interface{}, result interface{}, rpcErr *RPCError) {
w.Header().Set("Content-Type", "application/json")
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: id,
Result: result,
Error: rpcErr,
}
json.NewEncoder(w).Encode(resp)
}
// parseDomain converts JSON-RPC domain args to orm.Domain.
// JSON format: [["field", "op", value], ...] or ["&", ["field", "op", value], ...]
func parseDomain(args []interface{}) orm.Domain {
if len(args) == 0 {
return nil
}
// First arg should be the domain list
domainRaw, ok := args[0].([]interface{})
if !ok {
return nil
}
if len(domainRaw) == 0 {
return nil
}
var nodes []orm.DomainNode
for _, item := range domainRaw {
switch v := item.(type) {
case string:
// Operator: "&", "|", "!"
nodes = append(nodes, orm.Operator(v))
case []interface{}:
// Leaf: ["field", "op", value]
if len(v) == 3 {
field, _ := v[0].(string)
op, _ := v[1].(string)
nodes = append(nodes, orm.Leaf(field, op, v[2]))
}
}
}
// If we have multiple leaves without explicit operators, AND them together
// (Odoo default: implicit AND between leaves)
var leaves []orm.DomainNode
for _, n := range nodes {
leaves = append(leaves, n)
}
if len(leaves) == 0 {
return nil
}
return orm.Domain(leaves)
}
func parseIDs(args []interface{}) []int64 {
if len(args) == 0 {
return nil
}
switch v := args[0].(type) {
case []interface{}:
ids := make([]int64, len(v))
for i, item := range v {
switch n := item.(type) {
case float64:
ids[i] = int64(n)
case int64:
ids[i] = n
}
}
return ids
case float64:
return []int64{int64(v)}
}
return nil
}
func parseFields(kw Values) []string {
if kw == nil {
return nil
}
fieldsRaw, ok := kw["fields"]
if !ok {
return nil
}
fieldsSlice, ok := fieldsRaw.([]interface{})
if !ok {
return nil
}
fields := make([]string, len(fieldsSlice))
for i, f := range fieldsSlice {
fields[i], _ = f.(string)
}
return fields
}
func parseValues(args []interface{}) orm.Values {
if len(args) == 0 {
return nil
}
vals, ok := args[0].(map[string]interface{})
if !ok {
return nil
}
return orm.Values(vals)
}
func parseValuesAt(args []interface{}, idx int) orm.Values {
if len(args) <= idx {
return nil
}
vals, ok := args[idx].(map[string]interface{})
if !ok {
return nil
}
return orm.Values(vals)
}

86
pkg/server/session.go Normal file
View File

@@ -0,0 +1,86 @@
package server
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// Session represents an authenticated user session.
type Session struct {
ID string
UID int64
CompanyID int64
Login string
CreatedAt time.Time
LastActivity time.Time
}
// SessionStore is a thread-safe in-memory session store.
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
ttl time.Duration
}
// NewSessionStore creates a new session store with the given TTL.
func NewSessionStore(ttl time.Duration) *SessionStore {
return &SessionStore{
sessions: make(map[string]*Session),
ttl: ttl,
}
}
// New creates a new session and returns it.
func (s *SessionStore) New(uid, companyID int64, login string) *Session {
s.mu.Lock()
defer s.mu.Unlock()
token := generateToken()
sess := &Session{
ID: token,
UID: uid,
CompanyID: companyID,
Login: login,
CreatedAt: time.Now(),
LastActivity: time.Now(),
}
s.sessions[token] = sess
return sess
}
// Get retrieves a session by ID. Returns nil if not found or expired.
func (s *SessionStore) Get(id string) *Session {
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok {
return nil
}
if time.Since(sess.LastActivity) > s.ttl {
s.Delete(id)
return nil
}
// Update last activity
s.mu.Lock()
sess.LastActivity = time.Now()
s.mu.Unlock()
return sess
}
// Delete removes a session.
func (s *SessionStore) Delete(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
}
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}

290
pkg/server/setup.go Normal file
View File

@@ -0,0 +1,290 @@
package server
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"odoo-go/pkg/service"
"odoo-go/pkg/tools"
)
// isSetupNeeded checks if the database has been initialized.
func (s *Server) isSetupNeeded() bool {
var count int
err := s.pool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM res_company`).Scan(&count)
return err != nil || count == 0
}
// handleSetup serves the setup wizard.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.isSetupNeeded() {
http.Redirect(w, r, "/web/login", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Odoo — Setup</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0eeee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.setup { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 100%; max-width: 560px; }
.setup h1 { color: #71639e; margin-bottom: 8px; font-size: 28px; }
.setup .subtitle { color: #666; margin-bottom: 30px; font-size: 14px; }
.setup h2 { color: #333; font-size: 16px; margin: 24px 0 12px; padding-top: 16px; border-top: 1px solid #eee; }
.setup h2:first-of-type { border-top: none; padding-top: 0; }
.setup label { display: block; margin-bottom: 4px; font-weight: 500; color: #555; font-size: 13px; }
.setup input, .setup select { width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 4px;
font-size: 14px; margin-bottom: 12px; }
.setup input:focus, .setup select:focus { outline: none; border-color: #71639e; box-shadow: 0 0 0 2px rgba(113,99,158,0.2); }
.row { display: flex; gap: 12px; }
.row > div { flex: 1; }
.setup button { width: 100%; padding: 14px; background: #71639e; color: white; border: none;
border-radius: 4px; font-size: 16px; cursor: pointer; margin-top: 20px; }
.setup button:hover { background: #5f5387; }
.setup button:disabled { background: #aaa; cursor: not-allowed; }
.error { color: #dc3545; margin-bottom: 12px; display: none; text-align: center; }
.check { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.check input { width: auto; margin: 0; }
.check label { margin: 0; }
.progress { display: none; text-align: center; padding: 20px; }
.progress .spinner { font-size: 32px; animation: spin 1s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="setup">
<h1>Odoo Setup</h1>
<p class="subtitle">Richten Sie Ihre Datenbank ein</p>
<div id="error" class="error"></div>
<form id="setupForm">
<h2>Unternehmen</h2>
<label for="company_name">Firmenname *</label>
<input type="text" id="company_name" name="company_name" required placeholder="Mustermann GmbH"/>
<div class="row">
<div>
<label for="street">Straße</label>
<input type="text" id="street" name="street" placeholder="Musterstraße 1"/>
</div>
<div>
<label for="zip">PLZ</label>
<input type="text" id="zip" name="zip" placeholder="10115"/>
</div>
</div>
<div class="row">
<div>
<label for="city">Stadt</label>
<input type="text" id="city" name="city" placeholder="Berlin"/>
</div>
<div>
<label for="country">Land</label>
<select id="country" name="country">
<option value="DE" selected>Deutschland</option>
<option value="AT">Österreich</option>
<option value="CH">Schweiz</option>
</select>
</div>
</div>
<label for="email">Email</label>
<input type="email" id="email" name="email" placeholder="info@firma.de"/>
<label for="phone">Telefon</label>
<input type="text" id="phone" name="phone" placeholder="+49 30 12345678"/>
<label for="vat">USt-IdNr.</label>
<input type="text" id="vat" name="vat" placeholder="DE123456789"/>
<h2>Kontenrahmen</h2>
<select id="chart" name="chart">
<option value="skr03" selected>SKR03 (Standard, Prozessgliederung)</option>
<option value="skr04">SKR04 (Abschlussgliederung)</option>
<option value="none">Kein Kontenrahmen</option>
</select>
<h2>Administrator</h2>
<label for="admin_email">Login (Email) *</label>
<input type="email" id="admin_email" name="admin_email" required placeholder="admin@firma.de"/>
<label for="admin_password">Passwort *</label>
<input type="password" id="admin_password" name="admin_password" required minlength="4" placeholder="Mindestens 4 Zeichen"/>
<h2>Optionen</h2>
<div class="check">
<input type="checkbox" id="demo_data" name="demo_data"/>
<label for="demo_data">Demo-Daten laden (Beispielkunden, Rechnungen, etc.)</label>
</div>
<button type="submit" id="submitBtn">Datenbank einrichten</button>
</form>
<div id="progress" class="progress">
<div class="spinner">⟳</div>
<p style="margin-top:12px;color:#666;">Datenbank wird eingerichtet...</p>
</div>
</div>
<script>
document.getElementById('setupForm').addEventListener('submit', function(e) {
e.preventDefault();
var btn = document.getElementById('submitBtn');
var form = document.getElementById('setupForm');
var progress = document.getElementById('progress');
var errorEl = document.getElementById('error');
btn.disabled = true;
errorEl.style.display = 'none';
var data = {
company_name: document.getElementById('company_name').value,
street: document.getElementById('street').value,
zip: document.getElementById('zip').value,
city: document.getElementById('city').value,
country: document.getElementById('country').value,
email: document.getElementById('email').value,
phone: document.getElementById('phone').value,
vat: document.getElementById('vat').value,
chart: document.getElementById('chart').value,
admin_email: document.getElementById('admin_email').value,
admin_password: document.getElementById('admin_password').value,
demo_data: document.getElementById('demo_data').checked
};
form.style.display = 'none';
progress.style.display = 'block';
fetch('/web/setup/install', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
})
.then(function(r) { return r.json(); })
.then(function(result) {
if (result.error) {
form.style.display = 'block';
progress.style.display = 'none';
errorEl.textContent = result.error;
errorEl.style.display = 'block';
btn.disabled = false;
} else {
window.location.href = '/web/login';
}
})
.catch(function(err) {
form.style.display = 'block';
progress.style.display = 'none';
errorEl.textContent = 'Verbindungsfehler: ' + err.message;
errorEl.style.display = 'block';
btn.disabled = false;
});
});
</script>
</body>
</html>`))
}
// SetupParams holds the setup wizard form data.
type SetupParams struct {
CompanyName string `json:"company_name"`
Street string `json:"street"`
Zip string `json:"zip"`
City string `json:"city"`
Country string `json:"country"`
Email string `json:"email"`
Phone string `json:"phone"`
VAT string `json:"vat"`
Chart string `json:"chart"`
AdminEmail string `json:"admin_email"`
AdminPassword string `json:"admin_password"`
DemoData bool `json:"demo_data"`
}
// handleSetupInstall processes the setup wizard form submission.
func (s *Server) handleSetupInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var params SetupParams
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
writeJSON(w, map[string]string{"error": "Invalid request"})
return
}
if params.CompanyName == "" {
writeJSON(w, map[string]string{"error": "Firmenname ist erforderlich"})
return
}
if params.AdminEmail == "" || params.AdminPassword == "" {
writeJSON(w, map[string]string{"error": "Admin Email und Passwort sind erforderlich"})
return
}
log.Printf("setup: initializing database for %q", params.CompanyName)
// Hash admin password
hashedPw, err := tools.HashPassword(params.AdminPassword)
if err != nil {
writeJSON(w, map[string]string{"error": fmt.Sprintf("Password hash error: %v", err)})
return
}
// Map country code to name
countryName := "Germany"
phoneCode := "49"
switch params.Country {
case "AT":
countryName = "Austria"
phoneCode = "43"
case "CH":
countryName = "Switzerland"
phoneCode = "41"
}
// Run the seed with user-provided data
setupCfg := service.SetupConfig{
CompanyName: params.CompanyName,
Street: params.Street,
Zip: params.Zip,
City: params.City,
CountryCode: params.Country,
CountryName: countryName,
PhoneCode: phoneCode,
Email: params.Email,
Phone: params.Phone,
VAT: params.VAT,
Chart: params.Chart,
AdminLogin: params.AdminEmail,
AdminPassword: hashedPw,
DemoData: params.DemoData,
}
if err := service.SeedWithSetup(context.Background(), s.pool, setupCfg); err != nil {
log.Printf("setup: error: %v", err)
writeJSON(w, map[string]string{"error": fmt.Sprintf("Setup error: %v", err)})
return
}
log.Printf("setup: database initialized successfully for %q", params.CompanyName)
writeJSON(w, map[string]string{"status": "ok"})
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}

65
pkg/server/static.go Normal file
View File

@@ -0,0 +1,65 @@
package server
import (
"net/http"
"os"
"path/filepath"
"strings"
)
// handleStatic serves static files from Odoo addon directories.
// URL pattern: /<addon_name>/static/<path>
// Maps to: <addons_path>/<addon_name>/static/<path>
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(r.URL.Path, "/")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 || parts[1] != "static" {
http.NotFound(w, r)
return
}
addonName := parts[0]
filePath := parts[2]
// Security: prevent directory traversal
if strings.Contains(filePath, "..") {
http.NotFound(w, r)
return
}
// For JS/CSS files: check build dir first (transpiled/compiled files)
if s.config.BuildDir != "" && (strings.HasSuffix(filePath, ".js") || strings.HasSuffix(filePath, ".css")) {
buildPath := filepath.Join(s.config.BuildDir, addonName, "static", filePath)
if _, err := os.Stat(buildPath); err == nil {
w.Header().Set("Cache-Control", "public, max-age=3600")
http.ServeFile(w, r, buildPath)
return
}
}
// Search through addon paths (original files)
for _, addonsDir := range s.config.OdooAddonsPath {
fullPath := filepath.Join(addonsDir, addonName, "static", filePath)
if _, err := os.Stat(fullPath); err == nil {
w.Header().Set("Cache-Control", "public, max-age=3600")
// Serve SCSS as compiled CSS if available
if strings.HasSuffix(fullPath, ".scss") && s.config.BuildDir != "" {
buildCSS := filepath.Join(s.config.BuildDir, addonName, "static", strings.TrimSuffix(filePath, ".scss")+".css")
if _, err := os.Stat(buildCSS); err == nil {
fullPath = buildCSS
}
}
http.ServeFile(w, r, fullPath)
return
}
}
http.NotFound(w, r)
}

45
pkg/server/stubs.go Normal file
View File

@@ -0,0 +1,45 @@
package server
import (
"encoding/json"
"net/http"
)
// handleSessionCheck returns null (session is valid if middleware passed).
func (s *Server) handleSessionCheck(w http.ResponseWriter, r *http.Request) {
s.writeJSONRPC(w, nil, nil, nil)
}
// handleSessionModules returns installed module names.
func (s *Server) handleSessionModules(w http.ResponseWriter, r *http.Request) {
s.writeJSONRPC(w, nil, []string{
"base", "web", "account", "sale", "stock", "purchase",
"hr", "project", "crm", "fleet", "l10n_de", "product",
}, nil)
}
// handleManifest returns a minimal PWA manifest.
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
json.NewEncoder(w).Encode(map[string]interface{}{
"name": "Odoo",
"short_name": "Odoo",
"start_url": "/web",
"display": "standalone",
"background_color": "#71639e",
"theme_color": "#71639e",
"icons": []map[string]string{
{"src": "/web/static/img/odoo-icon-192x192.png", "sizes": "192x192", "type": "image/png"},
},
})
}
// handleBootstrapTranslations returns empty translations for initial boot.
func (s *Server) handleBootstrapTranslations(w http.ResponseWriter, r *http.Request) {
s.writeJSONRPC(w, nil, map[string]interface{}{
"lang": "en_US",
"hash": "empty",
"modules": map[string]interface{}{},
"multi_lang": false,
}, nil)
}

173
pkg/server/views.go Normal file
View File

@@ -0,0 +1,173 @@
package server
import (
"fmt"
"strings"
"odoo-go/pkg/orm"
)
// handleGetViews implements the get_views method.
// Mirrors: odoo/addons/base/models/ir_ui_view.py get_views()
func handleGetViews(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
// Parse views list: [[false, "list"], [false, "form"], [false, "search"]]
var viewRequests [][]interface{}
if len(params.Args) > 0 {
if vr, ok := params.Args[0].([]interface{}); ok {
viewRequests = make([][]interface{}, len(vr))
for i, v := range vr {
if pair, ok := v.([]interface{}); ok {
viewRequests[i] = pair
}
}
}
}
// Also check kwargs
if viewRequests == nil {
if vr, ok := params.KW["views"].([]interface{}); ok {
viewRequests = make([][]interface{}, len(vr))
for i, v := range vr {
if pair, ok := v.([]interface{}); ok {
viewRequests[i] = pair
}
}
}
}
views := make(map[string]interface{})
for _, vr := range viewRequests {
if len(vr) < 2 {
continue
}
viewType, _ := vr[1].(string)
if viewType == "" {
continue
}
// Try to load from ir_ui_view table
arch := loadViewArch(env, model, viewType)
if arch == "" {
// Generate default view
arch = generateDefaultView(model, viewType)
}
views[viewType] = map[string]interface{}{
"arch": arch,
"type": viewType,
"model": model,
"view_id": 0,
"field_parent": false,
}
}
// Build models dict with field metadata
models := map[string]interface{}{
model: map[string]interface{}{
"fields": fieldsGetForModel(model),
},
}
return map[string]interface{}{
"views": views,
"models": models,
}, nil
}
// loadViewArch tries to load a view from the ir_ui_view table.
func loadViewArch(env *orm.Environment, model, viewType string) string {
var arch string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT arch FROM ir_ui_view WHERE model = $1 AND type = $2 AND active = true ORDER BY priority LIMIT 1`,
model, viewType,
).Scan(&arch)
if err != nil {
return ""
}
return arch
}
// generateDefaultView creates a minimal view XML for a model.
func generateDefaultView(modelName, viewType string) string {
m := orm.Registry.Get(modelName)
if m == nil {
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
}
switch viewType {
case "list", "tree":
return generateDefaultListView(m)
case "form":
return generateDefaultFormView(m)
case "search":
return generateDefaultSearchView(m)
case "kanban":
return generateDefaultKanbanView(m)
default:
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
}
}
func generateDefaultListView(m *orm.Model) string {
var fields []string
count := 0
for _, f := range m.Fields() {
if f.Name == "id" || !f.IsStored() || f.Name == "create_uid" || f.Name == "write_uid" ||
f.Name == "create_date" || f.Name == "write_date" || f.Type == orm.TypeBinary {
continue
}
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, f.Name))
count++
if count >= 8 {
break
}
}
return fmt.Sprintf("<list>\n %s\n</list>", strings.Join(fields, "\n "))
}
func generateDefaultFormView(m *orm.Model) string {
var fields []string
for _, f := range m.Fields() {
if f.Name == "id" || f.Name == "create_uid" || f.Name == "write_uid" ||
f.Name == "create_date" || f.Name == "write_date" || f.Type == orm.TypeBinary {
continue
}
if f.Type == orm.TypeOne2many || f.Type == orm.TypeMany2many {
continue // Skip relational fields in default form
}
fields = append(fields, fmt.Sprintf(` <field name="%s"/>`, f.Name))
if len(fields) >= 20 {
break
}
}
return fmt.Sprintf("<form>\n <sheet>\n <group>\n%s\n </group>\n </sheet>\n</form>",
strings.Join(fields, "\n"))
}
func generateDefaultSearchView(m *orm.Model) string {
var fields []string
// Add name field if it exists
if f := m.GetField("name"); f != nil {
fields = append(fields, `<field name="name"/>`)
}
if f := m.GetField("email"); f != nil {
fields = append(fields, `<field name="email"/>`)
}
if len(fields) == 0 {
fields = append(fields, `<field name="id"/>`)
}
return fmt.Sprintf("<search>\n %s\n</search>", strings.Join(fields, "\n "))
}
func generateDefaultKanbanView(m *orm.Model) string {
nameField := "name"
if f := m.GetField("name"); f == nil {
nameField = "id"
}
return fmt.Sprintf(`<kanban>
<templates>
<t t-name="card">
<field name="%s"/>
</t>
</templates>
</kanban>`, nameField)
}

172
pkg/server/web_methods.go Normal file
View 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)}
}
}
}
}
}

264
pkg/server/webclient.go Normal file
View File

@@ -0,0 +1,264 @@
package server
import (
"bufio"
"embed"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"odoo-go/pkg/tools"
)
//go:embed assets_js.txt
var assetsJSFile embed.FS
//go:embed assets_css.txt
var assetsCSSFile embed.FS
//go:embed assets_xml.txt
var assetsXMLFile embed.FS
var jsFiles []string
var cssFiles []string
var xmlFiles []string
func init() {
jsFiles = loadAssetList("assets_js.txt", assetsJSFile)
cssFiles = loadAssetList("assets_css.txt", assetsCSSFile)
xmlFiles = loadAssetList("assets_xml.txt", assetsXMLFile)
}
// loadXMLTemplate reads an XML template file from the Odoo addons paths.
func loadXMLTemplate(cfg *tools.Config, urlPath string) string {
rel := strings.TrimPrefix(urlPath, "/")
for _, addonsDir := range cfg.OdooAddonsPath {
fullPath := filepath.Join(addonsDir, rel)
data, err := os.ReadFile(fullPath)
if err == nil {
return string(data)
}
}
return ""
}
func loadAssetList(name string, fs embed.FS) []string {
data, err := fs.ReadFile(name)
if err != nil {
return nil
}
var files []string
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" && !strings.HasPrefix(line, "#") {
files = append(files, line)
}
}
return files
}
// handleWebClient serves the Odoo webclient HTML shell.
// Mirrors: odoo/addons/web/controllers/home.py Home.web_client()
func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
// Check if setup is needed
if s.isSetupNeeded() {
http.Redirect(w, r, "/web/setup", http.StatusFound)
return
}
// Check authentication
sess := GetSession(r)
if sess == nil {
// Try cookie directly
cookie, err := r.Cookie("session_id")
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/web/login", http.StatusFound)
return
}
sess = s.sessions.Get(cookie.Value)
if sess == nil {
http.Redirect(w, r, "/web/login", http.StatusFound)
return
}
}
sessionInfo := s.buildSessionInfo(sess)
sessionInfoJSON, _ := json.Marshal(sessionInfo)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
// Build script tags for all JS files (with cache buster)
var scriptTags strings.Builder
cacheBuster := "?v=odoo-go-1"
for _, src := range jsFiles {
if strings.HasSuffix(src, ".scss") {
continue
}
scriptTags.WriteString(fmt.Sprintf(" <script type=\"text/javascript\" src=\"%s%s\"></script>\n", src, cacheBuster))
}
// Build link tags for CSS: compiled SCSS bundle + individual CSS files
var linkTags strings.Builder
// Main compiled SCSS bundle (Bootstrap + Odoo core styles)
linkTags.WriteString(fmt.Sprintf(" <link rel=\"stylesheet\" href=\"/web/static/odoo_web.css%s\"/>\n", cacheBuster))
// Additional plain CSS files
for _, src := range cssFiles {
if strings.HasSuffix(src, ".css") {
linkTags.WriteString(fmt.Sprintf(" <link rel=\"stylesheet\" href=\"%s%s\"/>\n", src, cacheBuster))
}
}
// XML templates are compiled to JS (registerTemplate calls) and included
// in the JS bundle as xml_templates_bundle.js — no inline XML needed.
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Odoo</title>
<link rel="shortcut icon" href="/web/static/img/favicon.ico" type="image/x-icon"/>
%s
<script>
var odoo = {
csrf_token: "dummy",
debug: "assets",
__session_info__: %s,
reloadMenus: function() {
return fetch("/web/webclient/load_menus", {
method: "GET",
headers: {"Content-Type": "application/json"}
}).then(function(r) { return r.json(); });
}
};
odoo.loadMenusPromise = odoo.reloadMenus();
// Patch OWL to prevent infinite error-dialog recursion.
// When ErrorDialog itself fails to render, stop retrying.
window.__errorDialogCount = 0;
var _origHandleError = null;
document.addEventListener('DOMContentLoaded', function() {
if (typeof owl !== 'undefined' && owl.App) {
_origHandleError = owl.App.prototype.handleError;
owl.App.prototype.handleError = function() {
window.__errorDialogCount++;
if (window.__errorDialogCount > 3) {
console.error('[odoo-go] Error dialog recursion stopped. Check earlier errors for root cause.');
return;
}
return _origHandleError.apply(this, arguments);
};
}
});
</script>
%s</head>
<body class="o_web_client">
</body>
</html>`, linkTags.String(), sessionInfoJSON, scriptTags.String())
}
// buildSessionInfo constructs the session_info JSON object expected by the webclient.
// Mirrors: odoo/addons/web/models/ir_http.py session_info()
func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
return map[string]interface{}{
"uid": sess.UID,
"is_system": sess.UID == 1,
"is_admin": sess.UID == 1,
"is_public": false,
"is_internal_user": true,
"user_context": map[string]interface{}{
"lang": "en_US",
"tz": "UTC",
"allowed_company_ids": []int64{sess.CompanyID},
},
"db": s.config.DBName,
"registry_hash": "odoo-go-static",
"server_version": "19.0-go",
"server_version_info": []interface{}{19, 0, 0, "final", 0, "g"},
"name": sess.Login,
"username": sess.Login,
"partner_id": sess.UID + 1, // Simplified mapping
"partner_display_name": sess.Login,
"partner_write_date": "2026-01-01 00:00:00",
"quick_login": true,
"web.base.url": fmt.Sprintf("http://localhost:%d", s.config.HTTPPort),
"active_ids_limit": 20000,
"max_file_upload_size": 134217728,
"home_action_id": false,
"support_url": "",
"test_mode": false,
"show_effect": true,
"currencies": map[string]interface{}{
"1": map[string]interface{}{
"id": 1, "name": "EUR", "symbol": "€",
"position": "after", "digits": []int{69, 2},
},
},
"bundle_params": map[string]interface{}{
"lang": "en_US",
"debug": "assets",
},
"user_companies": map[string]interface{}{
"current_company": sess.CompanyID,
"allowed_companies": map[string]interface{}{
fmt.Sprintf("%d", sess.CompanyID): map[string]interface{}{
"id": sess.CompanyID,
"name": "My Company",
"sequence": 10,
"child_ids": []int64{},
"parent_id": false,
"currency_id": 1,
},
},
"disallowed_ancestor_companies": map[string]interface{}{},
},
"user_settings": map[string]interface{}{
"id": 1,
"user_id": map[string]interface{}{"id": sess.UID, "display_name": sess.Login},
},
"view_info": map[string]interface{}{
"list": map[string]interface{}{"display_name": "List", "icon": "oi oi-view-list", "multi_record": true},
"form": map[string]interface{}{"display_name": "Form", "icon": "fa fa-address-card", "multi_record": false},
"kanban": map[string]interface{}{"display_name": "Kanban", "icon": "oi oi-view-kanban", "multi_record": true},
"graph": map[string]interface{}{"display_name": "Graph", "icon": "fa fa-area-chart", "multi_record": true},
"pivot": map[string]interface{}{"display_name": "Pivot", "icon": "oi oi-view-pivot", "multi_record": true},
"calendar": map[string]interface{}{"display_name": "Calendar", "icon": "fa fa-calendar", "multi_record": true},
"search": map[string]interface{}{"display_name": "Search", "icon": "oi oi-search", "multi_record": true},
},
"groups": map[string]interface{}{
"base.group_allow_export": true,
"base.group_user": true,
"base.group_system": true,
},
}
}
// handleTranslations returns empty English translations.
// Mirrors: odoo/addons/web/controllers/webclient.py translations()
func (s *Server) handleTranslations(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
json.NewEncoder(w).Encode(map[string]interface{}{
"lang": "en_US",
"hash": "odoo-go-empty",
"lang_parameters": map[string]interface{}{
"direction": "ltr",
"date_format": "%%m/%%d/%%Y",
"time_format": "%%H:%%M:%%S",
"grouping": "[3,0]",
"decimal_point": ".",
"thousands_sep": ",",
"week_start": 1,
},
"modules": map[string]interface{}{},
"multi_lang": false,
})
}

402
pkg/service/db.go Normal file
View File

@@ -0,0 +1,402 @@
// Package service provides database and model services.
// Mirrors: odoo/service/db.py
package service
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
l10n_de "odoo-go/addons/l10n_de"
"odoo-go/pkg/orm"
"odoo-go/pkg/tools"
)
// InitDatabase creates all tables for registered models.
// Mirrors: odoo/modules/loading.py load_module_graph() → model._setup_base()
func InitDatabase(ctx context.Context, pool *pgxpool.Pool) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("db: begin: %w", err)
}
defer tx.Rollback(ctx)
models := orm.Registry.Models()
// Phase 1: Create tables
for name, m := range models {
if m.IsAbstract() {
continue
}
sql := m.CreateTableSQL()
if sql == "" {
continue
}
log.Printf("db: creating table for %s", name)
if _, err := tx.Exec(ctx, sql); err != nil {
return fmt.Errorf("db: create table %s: %w", name, err)
}
}
// Phase 2: Add foreign keys (after all tables exist, each in savepoint)
for name, m := range models {
if m.IsAbstract() {
continue
}
for _, sql := range m.ForeignKeySQL() {
sp, spErr := tx.Begin(ctx)
if spErr != nil {
continue
}
wrappedSQL := fmt.Sprintf(
`DO $$ BEGIN %s; EXCEPTION WHEN duplicate_object THEN NULL; END $$`,
sql,
)
if _, err := sp.Exec(ctx, wrappedSQL); err != nil {
log.Printf("db: warning: FK for %s: %v", name, err)
sp.Rollback(ctx)
} else {
log.Printf("db: adding FK for %s", name)
sp.Commit(ctx)
}
}
}
// Phase 3: Create indexes (each in savepoint)
for _, m := range models {
if m.IsAbstract() {
continue
}
for _, sql := range m.IndexSQL() {
sp, spErr := tx.Begin(ctx)
if spErr != nil {
continue
}
if _, err := sp.Exec(ctx, sql); err != nil {
log.Printf("db: warning: index: %v", err)
sp.Rollback(ctx)
} else {
sp.Commit(ctx)
}
}
}
// Phase 4: Create Many2many junction tables (each in savepoint to avoid aborting tx)
for _, m := range models {
if m.IsAbstract() {
continue
}
for _, sql := range m.Many2manyTableSQL() {
sp, spErr := tx.Begin(ctx)
if spErr != nil {
continue
}
if _, err := sp.Exec(ctx, sql); err != nil {
log.Printf("db: warning: m2m table: %v", err)
sp.Rollback(ctx)
} else {
sp.Commit(ctx)
}
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("db: commit: %w", err)
}
log.Printf("db: schema initialized for %d models", len(models))
return nil
}
// NeedsSetup checks if the database requires initial setup.
func NeedsSetup(ctx context.Context, pool *pgxpool.Pool) bool {
var count int
err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM res_company`).Scan(&count)
return err != nil || count == 0
}
// SetupConfig holds parameters for the setup wizard.
type SetupConfig struct {
CompanyName string
Street string
Zip string
City string
CountryCode string
CountryName string
PhoneCode string
Email string
Phone string
VAT string
Chart string // "skr03", "skr04", "none"
AdminLogin string
AdminPassword string // Already hashed with bcrypt
DemoData bool
}
// SeedWithSetup initializes the database with user-provided configuration.
func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("db: begin: %w", err)
}
defer tx.Rollback(ctx)
log.Printf("db: seeding database for %q...", cfg.CompanyName)
// 1. Currency (EUR)
_, err = tx.Exec(ctx, `
INSERT INTO res_currency (id, name, symbol, decimal_places, rounding, active, "position")
VALUES (1, 'EUR', '€', 2, 0.01, true, 'after')
ON CONFLICT (id) DO NOTHING`)
if err != nil {
return fmt.Errorf("db: seed currency: %w", err)
}
// 2. Country
_, err = tx.Exec(ctx, `
INSERT INTO res_country (id, name, code, phone_code)
VALUES (1, $1, $2, $3)
ON CONFLICT (id) DO NOTHING`, cfg.CountryName, cfg.CountryCode, cfg.PhoneCode)
if err != nil {
return fmt.Errorf("db: seed country: %w", err)
}
// 3. Company partner
_, err = tx.Exec(ctx, `
INSERT INTO res_partner (id, name, is_company, active, type, lang, email, phone, street, zip, city, country_id, vat)
VALUES (1, $1, true, true, 'contact', 'de_DE', $2, $3, $4, $5, $6, 1, $7)
ON CONFLICT (id) DO NOTHING`,
cfg.CompanyName, cfg.Email, cfg.Phone, cfg.Street, cfg.Zip, cfg.City, cfg.VAT)
if err != nil {
return fmt.Errorf("db: seed company partner: %w", err)
}
// 4. Company
_, err = tx.Exec(ctx, `
INSERT INTO res_company (id, name, partner_id, currency_id, country_id, active, sequence, street, zip, city, email, phone, vat)
VALUES (1, $1, 1, 1, 1, true, 10, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO NOTHING`,
cfg.CompanyName, cfg.Street, cfg.Zip, cfg.City, cfg.Email, cfg.Phone, cfg.VAT)
if err != nil {
return fmt.Errorf("db: seed company: %w", err)
}
// 5. Admin partner
adminName := "Administrator"
_, err = tx.Exec(ctx, `
INSERT INTO res_partner (id, name, is_company, active, type, email, lang)
VALUES (2, $1, false, true, 'contact', $2, 'de_DE')
ON CONFLICT (id) DO NOTHING`, adminName, cfg.AdminLogin)
if err != nil {
return fmt.Errorf("db: seed admin partner: %w", err)
}
// 6. Admin user
_, err = tx.Exec(ctx, `
INSERT INTO res_users (id, login, password, active, partner_id, company_id)
VALUES (1, $1, $2, true, 2, 1)
ON CONFLICT (id) DO NOTHING`, cfg.AdminLogin, cfg.AdminPassword)
if err != nil {
return fmt.Errorf("db: seed admin user: %w", err)
}
// 7. Journals
_, err = tx.Exec(ctx, `
INSERT INTO account_journal (id, name, code, type, company_id, active, sequence) VALUES
(1, 'Ausgangsrechnungen', 'INV', 'sale', 1, true, 10),
(2, 'Eingangsrechnungen', 'BILL', 'purchase', 1, true, 20),
(3, 'Bank', 'BNK1', 'bank', 1, true, 30),
(4, 'Kasse', 'CSH1', 'cash', 1, true, 40),
(5, 'Sonstige', 'MISC', 'general', 1, true, 50)
ON CONFLICT (id) DO NOTHING`)
if err != nil {
return fmt.Errorf("db: seed journals: %w", err)
}
// 8. Sequences
_, err = tx.Exec(ctx, `
INSERT INTO ir_sequence (id, name, code, prefix, padding, number_next, number_increment, active, implementation) VALUES
(1, 'Buchungssatz', 'account.move', 'MISC/', 4, 1, 1, true, 'standard'),
(2, 'Ausgangsrechnung', 'account.move.out_invoice', 'RE/%(year)s/', 4, 1, 1, true, 'standard'),
(3, 'Eingangsrechnung', 'account.move.in_invoice', 'ER/%(year)s/', 4, 1, 1, true, 'standard'),
(4, 'Angebot', 'sale.order', 'AG', 4, 1, 1, true, 'standard'),
(5, 'Bestellung', 'purchase.order', 'BE', 4, 1, 1, true, 'standard')
ON CONFLICT (id) DO NOTHING`)
if err != nil {
return fmt.Errorf("db: seed sequences: %w", err)
}
// 9. Chart of Accounts (if selected)
if cfg.Chart == "skr03" || cfg.Chart == "skr04" {
// Currently only SKR03 is implemented
for _, acc := range l10n_de.SKR03Accounts {
tx.Exec(ctx, `
INSERT INTO account_account (code, name, account_type, company_id, reconcile)
VALUES ($1, $2, $3, 1, $4) ON CONFLICT DO NOTHING`,
acc.Code, acc.Name, acc.AccountType, acc.Reconcile)
}
log.Printf("db: seeded %d SKR03 accounts", len(l10n_de.SKR03Accounts))
// Taxes
for _, tax := range l10n_de.SKR03Taxes {
tx.Exec(ctx, `
INSERT INTO account_tax (name, amount, type_tax_use, amount_type, company_id, active, sequence, is_base_affected)
VALUES ($1, $2, $3, 'percent', 1, true, 1, true) ON CONFLICT DO NOTHING`,
tax.Name, tax.Amount, tax.TypeUse)
}
log.Printf("db: seeded %d German tax definitions", len(l10n_de.SKR03Taxes))
}
// 10. UI Views for key models
seedViews(ctx, tx)
// 11. Demo data
if cfg.DemoData {
seedDemoData(ctx, tx)
}
// 11. Reset sequences
tx.Exec(ctx, `
SELECT setval('res_currency_id_seq', (SELECT COALESCE(MAX(id),0) FROM res_currency));
SELECT setval('res_country_id_seq', (SELECT COALESCE(MAX(id),0) FROM res_country));
SELECT setval('res_partner_id_seq', (SELECT COALESCE(MAX(id),0) FROM res_partner));
SELECT setval('res_company_id_seq', (SELECT COALESCE(MAX(id),0) FROM res_company));
SELECT setval('res_users_id_seq', (SELECT COALESCE(MAX(id),0) FROM res_users));
SELECT setval('ir_sequence_id_seq', (SELECT COALESCE(MAX(id),0) FROM ir_sequence));
SELECT setval('account_journal_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_journal));
SELECT setval('account_account_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_account));
SELECT setval('account_tax_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_tax));
`)
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("db: commit seed: %w", err)
}
log.Printf("db: database seeded successfully for %q", cfg.CompanyName)
return nil
}
// seedViews creates UI views for key models.
func seedViews(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding UI views...")
tx.Exec(ctx, `INSERT INTO ir_ui_view (name, model, type, arch, priority, active, mode) VALUES
-- res.partner views
('partner.list', 'res.partner', 'list', '<list>
<field name="name"/>
<field name="email"/>
<field name="phone"/>
<field name="city"/>
<field name="country_id"/>
<field name="is_company"/>
</list>', 16, true, 'primary'),
('partner.form', 'res.partner', 'form', '<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="is_company"/>
<field name="type"/>
<field name="email"/>
<field name="phone"/>
<field name="mobile"/>
<field name="website"/>
</group>
<group>
<field name="street"/>
<field name="street2"/>
<field name="zip"/>
<field name="city"/>
<field name="country_id"/>
<field name="vat"/>
<field name="lang"/>
</group>
</group>
<notebook>
<page string="Internal Notes">
<field name="comment"/>
</page>
</notebook>
</sheet>
</form>', 16, true, 'primary'),
('partner.search', 'res.partner', 'search', '<search>
<field name="name"/>
<field name="email"/>
<field name="phone"/>
<field name="city"/>
</search>', 16, true, 'primary'),
-- account.move views
('invoice.list', 'account.move', 'list', '<list>
<field name="name"/>
<field name="partner_id"/>
<field name="invoice_date"/>
<field name="date"/>
<field name="move_type"/>
<field name="state"/>
<field name="amount_total"/>
</list>', 16, true, 'primary'),
-- sale.order views
('sale.list', 'sale.order', 'list', '<list>
<field name="name"/>
<field name="partner_id"/>
<field name="date_order"/>
<field name="state"/>
<field name="amount_total"/>
</list>', 16, true, 'primary')
ON CONFLICT DO NOTHING`)
log.Println("db: UI views seeded")
}
// seedDemoData creates example records for testing.
func seedDemoData(ctx context.Context, tx pgx.Tx) {
log.Println("db: loading demo data...")
// Demo customers
tx.Exec(ctx, `INSERT INTO res_partner (name, is_company, active, type, email, city, lang) VALUES
('Müller Bau GmbH', true, true, 'contact', 'info@mueller-bau.de', 'München', 'de_DE'),
('Schmidt & Söhne KG', true, true, 'contact', 'kontakt@schmidt-soehne.de', 'Hamburg', 'de_DE'),
('Weber Elektro AG', true, true, 'contact', 'info@weber-elektro.de', 'Frankfurt', 'de_DE'),
('Fischer Metallbau', true, true, 'contact', 'office@fischer-metall.de', 'Stuttgart', 'de_DE'),
('Hoffmann IT Services', true, true, 'contact', 'hello@hoffmann-it.de', 'Berlin', 'de_DE')
ON CONFLICT DO NOTHING`)
// Demo contacts (employees of customers)
tx.Exec(ctx, `INSERT INTO res_partner (name, is_company, active, type, email, phone, lang) VALUES
('Thomas Müller', false, true, 'contact', 'thomas@mueller-bau.de', '+49 89 1234567', 'de_DE'),
('Anna Schmidt', false, true, 'contact', 'anna@schmidt-soehne.de', '+49 40 9876543', 'de_DE'),
('Peter Weber', false, true, 'contact', 'peter@weber-elektro.de', '+49 69 5551234', 'de_DE')
ON CONFLICT DO NOTHING`)
log.Println("db: demo data loaded (8 demo contacts)")
}
// SeedBaseData is the legacy function — redirects to setup with defaults.
// Used when running without the setup wizard (e.g., Docker auto-start).
func SeedBaseData(ctx context.Context, pool *pgxpool.Pool) error {
if !NeedsSetup(ctx, pool) {
log.Println("db: base data already exists, skipping seed")
return nil
}
adminHash, _ := tools.HashPassword("admin")
return SeedWithSetup(ctx, pool, SetupConfig{
CompanyName: "My Company",
Street: "",
Zip: "",
City: "",
CountryCode: "DE",
CountryName: "Germany",
PhoneCode: "49",
Email: "admin@example.com",
Chart: "skr03",
AdminLogin: "admin",
AdminPassword: adminHash,
DemoData: false,
})
}

15
pkg/tools/auth.go Normal file
View File

@@ -0,0 +1,15 @@
package tools
import "golang.org/x/crypto/bcrypt"
// HashPassword hashes a password using bcrypt.
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPassword verifies a password against a bcrypt hash.
func CheckPassword(hashed, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password))
return err == nil
}

116
pkg/tools/config.go Normal file
View File

@@ -0,0 +1,116 @@
// Package tools provides configuration and utility functions.
// Mirrors: odoo/tools/config.py
package tools
import (
"fmt"
"os"
"strconv"
"strings"
)
// Config holds the server configuration.
// Mirrors: odoo/tools/config.py configmanager
type Config struct {
// Database
DBHost string
DBPort int
DBUser string
DBPassword string
DBName string
DBSSLMode string
// Server
HTTPInterface string
HTTPPort int
Workers int
DataDir string
// Modules
AddonsPath []string
OdooAddonsPath []string // Paths to Odoo source addon directories (for static files)
BuildDir string // Directory for compiled assets (SCSS→CSS)
WithoutDemo bool
// Logging
LogLevel string
// Limits
LimitMemorySoft int64
LimitTimeReal int
}
// DefaultConfig returns a configuration with default values.
// Mirrors: odoo/tools/config.py _default_options
func DefaultConfig() *Config {
return &Config{
DBHost: "localhost",
DBPort: 5432,
DBUser: "odoo",
DBPassword: "odoo",
DBName: "odoo",
DBSSLMode: "disable",
HTTPInterface: "0.0.0.0",
HTTPPort: 8069,
Workers: 0,
DataDir: "/var/lib/odoo",
LogLevel: "info",
}
}
// LoadFromEnv overrides config values from environment variables.
// Mirrors: odoo/tools/config.py _env_options (ODOO_* prefix)
func (c *Config) LoadFromEnv() {
if v := os.Getenv("ODOO_DB_HOST"); v != "" {
c.DBHost = v
}
// Also support Docker-style env vars (HOST, USER, PASSWORD)
if v := os.Getenv("HOST"); v != "" {
c.DBHost = v
}
if v := os.Getenv("ODOO_DB_PORT"); v != "" {
if port, err := strconv.Atoi(v); err == nil {
c.DBPort = port
}
}
if v := os.Getenv("ODOO_DB_USER"); v != "" {
c.DBUser = v
}
if v := os.Getenv("USER"); v != "" && os.Getenv("ODOO_DB_USER") == "" {
c.DBUser = v
}
if v := os.Getenv("ODOO_DB_PASSWORD"); v != "" {
c.DBPassword = v
}
if v := os.Getenv("PASSWORD"); v != "" && os.Getenv("ODOO_DB_PASSWORD") == "" {
c.DBPassword = v
}
if v := os.Getenv("ODOO_DB_NAME"); v != "" {
c.DBName = v
}
if v := os.Getenv("ODOO_HTTP_PORT"); v != "" {
if port, err := strconv.Atoi(v); err == nil {
c.HTTPPort = port
}
}
if v := os.Getenv("ODOO_DATA_DIR"); v != "" {
c.DataDir = v
}
if v := os.Getenv("ODOO_LOG_LEVEL"); v != "" {
c.LogLevel = v
}
if v := os.Getenv("ODOO_ADDONS_PATH"); v != "" {
c.OdooAddonsPath = strings.Split(v, ",")
}
if v := os.Getenv("ODOO_BUILD_DIR"); v != "" {
c.BuildDir = v
}
}
// DSN returns the PostgreSQL connection string.
func (c *Config) DSN() string {
return fmt.Sprintf(
"postgres://%s:%s@%s:%d/%s?sslmode=%s",
c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName, c.DBSSLMode,
)
}

120
pkg/tools/httpclient.go Normal file
View File

@@ -0,0 +1,120 @@
// Package tools provides a shared HTTP client for external API calls.
package tools
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// APIClient is a reusable HTTP client for external APIs.
// All outbound calls go through this — no hidden network access.
type APIClient struct {
client *http.Client
baseURL string
apiKey string
}
// NewAPIClient creates a client for an external API.
func NewAPIClient(baseURL, apiKey string) *APIClient {
return &APIClient{
client: &http.Client{
Timeout: 10 * time.Second,
},
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
}
}
// Get performs a GET request with query parameters.
func (c *APIClient) Get(path string, params map[string]string) ([]byte, error) {
u, err := url.Parse(c.baseURL + path)
if err != nil {
return nil, err
}
q := u.Query()
for k, v := range params {
q.Set(k, v)
}
if c.apiKey != "" {
q.Set("key", c.apiKey)
}
u.RawQuery = q.Encode()
resp, err := c.client.Get(u.String())
if err != nil {
return nil, fmt.Errorf("api: GET %s: %w", path, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("api: GET %s returned %d: %s", path, resp.StatusCode, string(body[:min(200, len(body))]))
}
return body, nil
}
// GetJSON performs a GET and decodes the response as JSON.
func (c *APIClient) GetJSON(path string, params map[string]string, result interface{}) error {
body, err := c.Get(path, params)
if err != nil {
return err
}
return json.Unmarshal(body, result)
}
// PostJSON performs a POST with JSON body and decodes the response.
func (c *APIClient) PostJSON(path string, params map[string]string, reqBody, result interface{}) error {
u, err := url.Parse(c.baseURL + path)
if err != nil {
return err
}
q := u.Query()
for k, v := range params {
q.Set(k, v)
}
if c.apiKey != "" {
q.Set("key", c.apiKey)
}
u.RawQuery = q.Encode()
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return err
}
resp, err := c.client.Post(u.String(), "application/json", strings.NewReader(string(bodyBytes)))
if err != nil {
return fmt.Errorf("api: POST %s: %w", path, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("api: POST %s returned %d: %s", path, resp.StatusCode, string(respBody[:min(200, len(respBody))]))
}
return json.Unmarshal(respBody, result)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

102
tools/compile_templates.py Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Compile Odoo XML templates to a JS bundle with registerTemplate() calls.
Mirrors: odoo/addons/base/models/assetsbundle.py generate_xml_bundle()
Usage:
python3 tools/compile_templates.py <odoo_source_path> <output_file>
"""
import os
import sys
import json
from lxml import etree
def main():
if len(sys.argv) < 3:
print("Usage: compile_templates.py <odoo_source_path> <output_file>")
sys.exit(1)
odoo_path = sys.argv[1]
output_file = sys.argv[2]
# Read XML file list
bundle_file = os.path.join(os.path.dirname(__file__), '..', 'pkg', 'server', 'assets_xml.txt')
with open(bundle_file) as f:
xml_urls = [line.strip() for line in f if line.strip()]
addons_dirs = [
os.path.join(odoo_path, 'addons'),
os.path.join(odoo_path, 'odoo', 'addons'),
]
content = []
count = 0
errors = 0
for url_path in xml_urls:
rel_path = url_path.lstrip('/')
source_file = None
for addons_dir in addons_dirs:
candidate = os.path.join(addons_dir, rel_path)
if os.path.isfile(candidate):
source_file = candidate
break
if not source_file:
continue
try:
tree = etree.parse(source_file)
root = tree.getroot()
# Process each <templates> block
if root.tag == 'templates':
templates_el = root
else:
templates_el = root
for template in templates_el.iter():
t_name = template.get('t-name')
if not t_name:
continue
inherit_from = template.get('t-inherit')
template.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
xml_string = etree.tostring(template, encoding='unicode')
# Escape for JS template literal
xml_string = xml_string.replace('\\', '\\\\').replace('`', '\\`').replace('${', '\\${')
# Templates with a t-name are always registered as primary templates.
# If they have t-inherit, the templates.js _getTemplate function
# handles the inheritance (cloning parent + applying xpath modifications).
# registerTemplateExtension is ONLY for anonymous patches without t-name.
content.append(f'registerTemplate("{t_name}", `{url_path}`, `{xml_string}`);')
count += 1
except Exception as e:
print(f" ERROR parsing {url_path}: {e}")
errors += 1
# Wrap in odoo.define with new-format module name so the loader resolves it
js_output = f'''odoo.define("@web/bundle_xml", ["@web/core/templates"], function(require) {{
"use strict";
const {{ checkPrimaryTemplateParents, registerTemplate, registerTemplateExtension }} = require("@web/core/templates");
{chr(10).join(content)}
}});
// Trigger the module in case the loader hasn't started it yet
if (odoo.loader && odoo.loader.modules.has("@web/bundle_xml") === false) {{
odoo.loader.startModule("@web/bundle_xml");
}}
'''
os.makedirs(os.path.dirname(output_file), exist_ok=True)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(js_output)
print(f"Done: {count} templates compiled, {errors} errors")
print(f"Output: {output_file}")
if __name__ == '__main__':
main()

120
tools/transpile_assets.py Normal file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
Transpile Odoo ES module JS files to odoo.define() format.
Uses Odoo's built-in js_transpiler.py to convert:
import { X } from "@web/core/foo" → odoo.define("@web/...", [...], function(require) {...})
Usage:
python3 tools/transpile_assets.py <odoo_source_path> <output_dir>
Example:
python3 tools/transpile_assets.py ../odoo build/js
"""
import os
import sys
import shutil
def main():
if len(sys.argv) < 3:
print("Usage: transpile_assets.py <odoo_source_path> <output_dir>")
sys.exit(1)
odoo_path = sys.argv[1]
output_dir = sys.argv[2]
# Mock odoo.tools.misc.OrderedSet before importing transpiler
import types
odoo_mock = types.ModuleType('odoo')
odoo_tools_mock = types.ModuleType('odoo.tools')
odoo_misc_mock = types.ModuleType('odoo.tools.misc')
class OrderedSet(dict):
"""Minimal OrderedSet replacement using dict keys."""
def __init__(self, iterable=None):
super().__init__()
if iterable:
for item in iterable:
self[item] = None
def add(self, item):
self[item] = None
def __iter__(self):
return iter(self.keys())
def __contains__(self, item):
return dict.__contains__(self, item)
odoo_misc_mock.OrderedSet = OrderedSet
odoo_tools_mock.misc = odoo_misc_mock
odoo_mock.tools = odoo_tools_mock
sys.modules['odoo'] = odoo_mock
sys.modules['odoo.tools'] = odoo_tools_mock
sys.modules['odoo.tools.misc'] = odoo_misc_mock
# Import the transpiler directly
import importlib.util
transpiler_path = os.path.join(odoo_path, 'odoo', 'tools', 'js_transpiler.py')
spec = importlib.util.spec_from_file_location("js_transpiler", transpiler_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
transpile_javascript = mod.transpile_javascript
is_odoo_module = mod.is_odoo_module
# Read the JS bundle file list
bundle_file = os.path.join(os.path.dirname(__file__), '..', 'pkg', 'server', 'assets_js.txt')
with open(bundle_file) as f:
js_files = [line.strip() for line in f if line.strip()]
addons_dirs = [
os.path.join(odoo_path, 'addons'),
os.path.join(odoo_path, 'odoo', 'addons'),
]
transpiled = 0
copied = 0
errors = 0
for url_path in js_files:
# url_path is like /web/static/src/env.js
# Find the real file
rel_path = url_path.lstrip('/')
source_file = None
for addons_dir in addons_dirs:
candidate = os.path.join(addons_dir, rel_path)
if os.path.isfile(candidate):
source_file = candidate
break
if not source_file:
print(f" SKIP (not found): {url_path}")
continue
# Output path
out_file = os.path.join(output_dir, rel_path)
os.makedirs(os.path.dirname(out_file), exist_ok=True)
# Read source
with open(source_file, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
# Check if it's an odoo module (has import/export)
if is_odoo_module(url_path, content):
try:
result = transpile_javascript(url_path, content)
with open(out_file, 'w', encoding='utf-8') as f:
f.write(result)
transpiled += 1
except Exception as e:
print(f" ERROR transpiling {url_path}: {e}")
# Copy as-is on error
shutil.copy2(source_file, out_file)
errors += 1
else:
# Not an ES module — copy as-is (e.g., libraries, legacy code)
shutil.copy2(source_file, out_file)
copied += 1
print(f"\nDone: {transpiled} transpiled, {copied} copied as-is, {errors} errors")
print(f"Output: {output_dir}")
if __name__ == '__main__':
main()