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:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
17
Dockerfile
Normal 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"]
|
||||
204
addons/account/models/account_account.go
Normal file
204
addons/account/models/account_account.go
Normal 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"}),
|
||||
)
|
||||
}
|
||||
568
addons/account/models/account_move.go
Normal file
568
addons/account/models/account_move.go
Normal 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"}),
|
||||
)
|
||||
}
|
||||
14
addons/account/models/init.go
Normal file
14
addons/account/models/init.go
Normal 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
22
addons/account/module.go
Normal 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,
|
||||
})
|
||||
}
|
||||
16
addons/base/models/init.go
Normal file
16
addons/base/models/init.go
Normal 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()
|
||||
}
|
||||
175
addons/base/models/ir_model.go
Normal file
175
addons/base/models/ir_model.go
Normal 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
228
addons/base/models/ir_ui.go
Normal 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"}),
|
||||
)
|
||||
}
|
||||
71
addons/base/models/res_company.go
Normal file
71
addons/base/models/res_company.go
Normal 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"}),
|
||||
)
|
||||
}
|
||||
79
addons/base/models/res_currency.go
Normal file
79
addons/base/models/res_currency.go
Normal 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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
158
addons/base/models/res_partner.go
Normal file
158
addons/base/models/res_partner.go
Normal 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}),
|
||||
)
|
||||
}
|
||||
101
addons/base/models/res_users.go
Normal file
101
addons/base/models/res_users.go
Normal 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
33
addons/base/module.go
Normal 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
131
addons/crm/models/crm.go
Normal 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}),
|
||||
)
|
||||
}
|
||||
9
addons/crm/models/init.go
Normal file
9
addons/crm/models/init.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initCRMTag()
|
||||
initCRMLostReason()
|
||||
initCRMTeam()
|
||||
initCRMStage()
|
||||
initCRMLead()
|
||||
}
|
||||
22
addons/crm/module.go
Normal file
22
addons/crm/module.go
Normal 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,
|
||||
})
|
||||
}
|
||||
222
addons/fleet/models/fleet.go
Normal file
222
addons/fleet/models/fleet.go
Normal 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}),
|
||||
)
|
||||
}
|
||||
13
addons/fleet/models/init.go
Normal file
13
addons/fleet/models/init.go
Normal 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
22
addons/fleet/module.go
Normal 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,
|
||||
})
|
||||
}
|
||||
210
addons/google_address/models/google_address.go
Normal file
210
addons/google_address/models/google_address.go
Normal 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"`
|
||||
}
|
||||
5
addons/google_address/models/init.go
Normal file
5
addons/google_address/models/init.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initGoogleAddress()
|
||||
}
|
||||
28
addons/google_address/module.go
Normal file
28
addons/google_address/module.go
Normal 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,
|
||||
})
|
||||
}
|
||||
227
addons/google_calendar/models/google_calendar.go
Normal file
227
addons/google_calendar/models/google_calendar.go
Normal 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"`
|
||||
}
|
||||
6
addons/google_calendar/models/init.go
Normal file
6
addons/google_calendar/models/init.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initCalendarEvent()
|
||||
initGoogleCalendarSync()
|
||||
}
|
||||
27
addons/google_calendar/module.go
Normal file
27
addons/google_calendar/module.go
Normal 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,
|
||||
})
|
||||
}
|
||||
221
addons/google_translate/models/google_translate.go
Normal file
221
addons/google_translate/models/google_translate.go
Normal 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"`
|
||||
}
|
||||
5
addons/google_translate/models/init.go
Normal file
5
addons/google_translate/models/init.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initGoogleTranslate()
|
||||
}
|
||||
27
addons/google_translate/module.go
Normal file
27
addons/google_translate/module.go
Normal 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
145
addons/hr/models/hr.go
Normal 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
8
addons/hr/models/init.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initResourceCalendar()
|
||||
initHREmployee()
|
||||
initHRDepartment()
|
||||
initHRJob()
|
||||
}
|
||||
22
addons/hr/module.go
Normal file
22
addons/hr/module.go
Normal 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
164
addons/l10n_de/data.go
Normal 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"},
|
||||
}
|
||||
5
addons/l10n_de/models/init.go
Normal file
5
addons/l10n_de/models/init.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initChartTemplate()
|
||||
}
|
||||
99
addons/l10n_de/models/l10n_de.go
Normal file
99
addons/l10n_de/models/l10n_de.go
Normal 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
25
addons/l10n_de/module.go
Normal 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,
|
||||
})
|
||||
}
|
||||
9
addons/product/models/init.go
Normal file
9
addons/product/models/init.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initProductCategory()
|
||||
initUoM()
|
||||
initProductTemplate()
|
||||
initProductProduct()
|
||||
initProductPricelist()
|
||||
}
|
||||
171
addons/product/models/product.go
Normal file
171
addons/product/models/product.go
Normal 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
22
addons/product/module.go
Normal 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,
|
||||
})
|
||||
}
|
||||
9
addons/project/models/init.go
Normal file
9
addons/project/models/init.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initProjectTags()
|
||||
initProjectTaskType()
|
||||
initProjectMilestone()
|
||||
initProjectProject()
|
||||
initProjectTask()
|
||||
}
|
||||
124
addons/project/models/project.go
Normal file
124
addons/project/models/project.go
Normal 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
22
addons/project/module.go
Normal 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,
|
||||
})
|
||||
}
|
||||
6
addons/purchase/models/init.go
Normal file
6
addons/purchase/models/init.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initPurchaseOrder()
|
||||
initPurchaseOrderLine()
|
||||
}
|
||||
177
addons/purchase/models/purchase_order.go
Normal file
177
addons/purchase/models/purchase_order.go
Normal 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
22
addons/purchase/module.go
Normal 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,
|
||||
})
|
||||
}
|
||||
6
addons/sale/models/init.go
Normal file
6
addons/sale/models/init.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initSaleOrder()
|
||||
initSaleOrderLine()
|
||||
}
|
||||
315
addons/sale/models/sale_order.go
Normal file
315
addons/sale/models/sale_order.go
Normal 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, ¤cyID, &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
22
addons/sale/module.go
Normal 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,
|
||||
})
|
||||
}
|
||||
5
addons/stock/models/init.go
Normal file
5
addons/stock/models/init.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initStock()
|
||||
}
|
||||
371
addons/stock/models/stock.go
Normal file
371
addons/stock/models/stock.go
Normal 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
22
addons/stock/module.go
Normal 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
104
cmd/odoo-server/main.go
Normal 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
39
docker-compose.yml
Normal 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
14
go.mod
Normal 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
28
go.sum
Normal 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
112
pkg/modules/graph.go
Normal 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
76
pkg/modules/module.go
Normal 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
68
pkg/orm/command.go
Normal 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
204
pkg/orm/compute.go
Normal 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
429
pkg/orm/domain.go
Normal 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
257
pkg/orm/environment.go
Normal 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
377
pkg/orm/field.go
Normal 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
378
pkg/orm/model.go
Normal 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
796
pkg/orm/recordset.go
Normal 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
282
pkg/orm/relational.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProcessO2MCommands processes One2many field commands after a Create/Write.
|
||||
// Mirrors: odoo/orm/fields_relational.py One2many write logic
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// CmdCreate(vals) → Create child record with inverse_field = parentID
|
||||
// CmdUpdate(id, vals) → Update child record
|
||||
// CmdDelete(id) → Delete child record
|
||||
func ProcessO2MCommands(env *Environment, f *Field, parentID int64, cmds []Command) error {
|
||||
if f.Comodel == "" || f.InverseField == "" {
|
||||
return fmt.Errorf("orm: O2M field %q missing comodel or inverse_field", f.Name)
|
||||
}
|
||||
|
||||
comodelRS := env.Model(f.Comodel)
|
||||
|
||||
for _, cmd := range cmds {
|
||||
switch cmd.Operation {
|
||||
case CommandCreate:
|
||||
vals := cmd.Values
|
||||
if vals == nil {
|
||||
vals = make(Values)
|
||||
}
|
||||
vals[f.InverseField] = parentID
|
||||
if _, err := comodelRS.Create(vals); err != nil {
|
||||
return fmt.Errorf("orm: O2M create on %s: %w", f.Comodel, err)
|
||||
}
|
||||
|
||||
case CommandUpdate:
|
||||
if cmd.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := comodelRS.Browse(cmd.ID).Write(cmd.Values); err != nil {
|
||||
return fmt.Errorf("orm: O2M update %s(%d): %w", f.Comodel, cmd.ID, err)
|
||||
}
|
||||
|
||||
case CommandDelete:
|
||||
if cmd.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := comodelRS.Browse(cmd.ID).Unlink(); err != nil {
|
||||
return fmt.Errorf("orm: O2M delete %s(%d): %w", f.Comodel, cmd.ID, err)
|
||||
}
|
||||
|
||||
case CommandClear:
|
||||
// Delete all children linked to this parent
|
||||
children, err := comodelRS.Search(And(Leaf(f.InverseField, "=", parentID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := children.Unlink(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessM2MCommands processes Many2many field commands after a Create/Write.
|
||||
// Mirrors: odoo/orm/fields_relational.py Many2many write logic
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// CmdLink(id) → Add link in junction table
|
||||
// CmdUnlink(id) → Remove link from junction table
|
||||
// CmdClear() → Remove all links
|
||||
// CmdSet(ids) → Replace all links
|
||||
// CmdCreate(vals) → Create comodel record then link it
|
||||
func ProcessM2MCommands(env *Environment, f *Field, parentID int64, cmds []Command) error {
|
||||
jt := f.JunctionTable()
|
||||
col1 := f.JunctionCol1()
|
||||
col2 := f.JunctionCol2()
|
||||
|
||||
if jt == "" || col1 == "" || col2 == "" {
|
||||
return fmt.Errorf("orm: M2M field %q: cannot determine junction table", f.Name)
|
||||
}
|
||||
|
||||
for _, cmd := range cmds {
|
||||
switch cmd.Operation {
|
||||
case CommandLink:
|
||||
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
jt, col1, col2,
|
||||
), parentID, cmd.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: M2M link %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
case CommandUnlink:
|
||||
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`DELETE FROM %q WHERE %q = $1 AND %q = $2`,
|
||||
jt, col1, col2,
|
||||
), parentID, cmd.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: M2M unlink %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
case CommandClear:
|
||||
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`DELETE FROM %q WHERE %q = $1`,
|
||||
jt, col1,
|
||||
), parentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: M2M clear %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
case CommandSet:
|
||||
// Clear then link all
|
||||
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`DELETE FROM %q WHERE %q = $1`,
|
||||
jt, col1,
|
||||
), parentID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, targetID := range cmd.IDs {
|
||||
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
jt, col1, col2,
|
||||
), parentID, targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case CommandCreate:
|
||||
// Create comodel record then link
|
||||
comodelRS := env.Model(f.Comodel)
|
||||
created, err := comodelRS.Create(cmd.Values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
jt, col1, col2,
|
||||
), parentID, created.ID()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadM2MField reads the linked IDs for a Many2many field.
|
||||
func ReadM2MField(env *Environment, f *Field, parentIDs []int64) (map[int64][]int64, error) {
|
||||
jt := f.JunctionTable()
|
||||
col1 := f.JunctionCol1()
|
||||
col2 := f.JunctionCol2()
|
||||
|
||||
if jt == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(parentIDs))
|
||||
args := make([]interface{}, len(parentIDs))
|
||||
for i, id := range parentIDs {
|
||||
args[i] = id
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT %q, %q FROM %q WHERE %q IN (%s)`,
|
||||
col1, col2, jt, col1, strings.Join(placeholders, ", "),
|
||||
)
|
||||
|
||||
rows, err := env.tx.Query(env.ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[int64][]int64)
|
||||
for rows.Next() {
|
||||
var pid, tid int64
|
||||
if err := rows.Scan(&pid, &tid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[pid] = append(result[pid], tid)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ParseCommands converts a JSON value to a slice of Commands.
|
||||
// JSON format: [[0, 0, {vals}], [4, id, 0], [6, 0, [ids]], ...]
|
||||
func ParseCommands(raw interface{}) ([]Command, bool) {
|
||||
list, ok := raw.([]interface{})
|
||||
if !ok || len(list) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if this looks like commands (list of lists)
|
||||
first, ok := list[0].([]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if len(first) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var cmds []Command
|
||||
for _, item := range list {
|
||||
tuple, ok := item.([]interface{})
|
||||
if !ok || len(tuple) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
op, ok := toInt64(tuple[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch CommandOp(op) {
|
||||
case CommandCreate: // [0, _, {vals}]
|
||||
vals := make(Values)
|
||||
if len(tuple) > 2 {
|
||||
if m, ok := tuple[2].(map[string]interface{}); ok {
|
||||
vals = m
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, CmdCreate(vals))
|
||||
|
||||
case CommandUpdate: // [1, id, {vals}]
|
||||
id, _ := toInt64(tuple[1])
|
||||
vals := make(Values)
|
||||
if len(tuple) > 2 {
|
||||
if m, ok := tuple[2].(map[string]interface{}); ok {
|
||||
vals = m
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, CmdUpdate(id, vals))
|
||||
|
||||
case CommandDelete: // [2, id, _]
|
||||
id, _ := toInt64(tuple[1])
|
||||
cmds = append(cmds, CmdDelete(id))
|
||||
|
||||
case CommandUnlink: // [3, id, _]
|
||||
id, _ := toInt64(tuple[1])
|
||||
cmds = append(cmds, CmdUnlink(id))
|
||||
|
||||
case CommandLink: // [4, id, _]
|
||||
id, _ := toInt64(tuple[1])
|
||||
cmds = append(cmds, CmdLink(id))
|
||||
|
||||
case CommandClear: // [5, _, _]
|
||||
cmds = append(cmds, CmdClear())
|
||||
|
||||
case CommandSet: // [6, _, [ids]]
|
||||
var ids []int64
|
||||
if len(tuple) > 2 {
|
||||
if arr, ok := tuple[2].([]interface{}); ok {
|
||||
for _, v := range arr {
|
||||
if id, ok := toInt64(v); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, CmdSet(ids))
|
||||
}
|
||||
}
|
||||
|
||||
if len(cmds) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return cmds, true
|
||||
}
|
||||
|
||||
func toInt64(v interface{}) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
case int:
|
||||
return int64(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
82
pkg/orm/rules.go
Normal file
82
pkg/orm/rules.go
Normal 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
69
pkg/orm/sequence.go
Normal 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
209
pkg/orm/types.go
Normal 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
47
pkg/server/action.go
Normal 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, ¶ms)
|
||||
|
||||
// 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
216
pkg/server/assets_css.txt
Normal 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
540
pkg/server/assets_js.txt
Normal 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
256
pkg/server/assets_xml.txt
Normal 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
57
pkg/server/fields_get.go
Normal 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
80
pkg/server/login.go
Normal 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
61
pkg/server/menus.go
Normal 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
61
pkg/server/middleware.go
Normal 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
665
pkg/server/server.go
Normal 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, ¶ms); 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, ¶ms); 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
86
pkg/server/session.go
Normal 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
290
pkg/server/setup.go
Normal 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(¶ms); 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
65
pkg/server/static.go
Normal 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
45
pkg/server/stubs.go
Normal 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
173
pkg/server/views.go
Normal 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
172
pkg/server/web_methods.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// handleWebSearchRead implements the web_search_read method.
|
||||
// Mirrors: odoo/addons/web/models/models.py web_search_read()
|
||||
// Returns {length: N, records: [...]} instead of just records.
|
||||
func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
|
||||
rs := env.Model(model)
|
||||
|
||||
// Parse domain from first arg
|
||||
domain := parseDomain(params.Args)
|
||||
|
||||
// Parse specification from kwargs
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
fields := specToFields(spec)
|
||||
|
||||
// Always include id
|
||||
hasID := false
|
||||
for _, f := range fields {
|
||||
if f == "id" {
|
||||
hasID = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasID {
|
||||
fields = append([]string{"id"}, fields...)
|
||||
}
|
||||
|
||||
// Parse offset, limit, order
|
||||
offset := 0
|
||||
limit := 80
|
||||
order := ""
|
||||
if v, ok := params.KW["offset"].(float64); ok {
|
||||
offset = int(v)
|
||||
}
|
||||
if v, ok := params.KW["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
if v, ok := params.KW["order"].(string); ok {
|
||||
order = v
|
||||
}
|
||||
|
||||
// Get total count
|
||||
count, err := rs.SearchCount(domain)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
|
||||
// Search with offset/limit
|
||||
found, err := rs.Search(domain, orm.SearchOpts{
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
Order: order,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
|
||||
var records []orm.Values
|
||||
if !found.IsEmpty() {
|
||||
records, err = found.Read(fields)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
// Format M2O fields as {id, display_name} when spec requests it
|
||||
formatM2OFields(env, model, records, spec)
|
||||
|
||||
if records == nil {
|
||||
records = []orm.Values{}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"length": count,
|
||||
"records": records,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleWebRead implements the web_read method.
|
||||
// Mirrors: odoo/addons/web/models/models.py web_read()
|
||||
func handleWebRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return []orm.Values{}, nil
|
||||
}
|
||||
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
fields := specToFields(spec)
|
||||
|
||||
rs := env.Model(model)
|
||||
records, err := rs.Browse(ids...).Read(fields)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
|
||||
formatM2OFields(env, model, records, spec)
|
||||
|
||||
if records == nil {
|
||||
records = []orm.Values{}
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// specToFields extracts field names from a specification dict.
|
||||
// {"name": {}, "partner_id": {"fields": {"display_name": {}}}} → ["name", "partner_id"]
|
||||
func specToFields(spec map[string]interface{}) []string {
|
||||
if len(spec) == 0 {
|
||||
return nil
|
||||
}
|
||||
fields := make([]string, 0, len(spec))
|
||||
for name := range spec {
|
||||
fields = append(fields, name)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// formatM2OFields converts Many2one field values from raw int to {id, display_name}.
|
||||
func formatM2OFields(env *orm.Environment, modelName string, records []orm.Values, spec map[string]interface{}) {
|
||||
m := orm.Registry.Get(modelName)
|
||||
if m == nil || spec == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
for fieldName, fieldSpec := range spec {
|
||||
f := m.GetField(fieldName)
|
||||
if f == nil || f.Type != orm.TypeMany2one {
|
||||
continue
|
||||
}
|
||||
|
||||
// Accept any spec entry for M2O fields (even empty {} means include it)
|
||||
_, ok := fieldSpec.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the raw FK ID
|
||||
rawID := rec[fieldName]
|
||||
fkID := int64(0)
|
||||
switch v := rawID.(type) {
|
||||
case int64:
|
||||
fkID = v
|
||||
case int32:
|
||||
fkID = int64(v)
|
||||
case float64:
|
||||
fkID = int64(v)
|
||||
}
|
||||
|
||||
if fkID == 0 {
|
||||
rec[fieldName] = false // Odoo convention for empty M2O
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch display_name from comodel — return as [id, "name"] array
|
||||
if f.Comodel != "" {
|
||||
coRS := env.Model(f.Comodel).Browse(fkID)
|
||||
names, err := coRS.NameGet()
|
||||
if err == nil && len(names) > 0 {
|
||||
rec[fieldName] = []interface{}{fkID, names[fkID]}
|
||||
} else {
|
||||
rec[fieldName] = []interface{}{fkID, fmt.Sprintf("%s,%d", f.Comodel, fkID)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
264
pkg/server/webclient.go
Normal file
264
pkg/server/webclient.go
Normal 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
402
pkg/service/db.go
Normal 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
15
pkg/tools/auth.go
Normal 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
116
pkg/tools/config.go
Normal 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
120
pkg/tools/httpclient.go
Normal 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
102
tools/compile_templates.py
Normal 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
120
tools/transpile_assets.py
Normal 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()
|
||||
Reference in New Issue
Block a user