// 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. Actions (ir_act_window + ir_model_data for XML IDs)
seedActions(ctx, tx)
// 12. Menus (ir_ui_menu + ir_model_data for XML IDs)
seedMenus(ctx, tx)
// 13. Demo data
if cfg.DemoData {
seedDemoData(ctx, tx)
}
// 14. Reset sequences (each individually — pgx doesn't support multi-statement)
seqs := []string{
"res_currency", "res_country", "res_partner", "res_company",
"res_users", "ir_sequence", "account_journal", "account_account",
"account_tax", "sale_order", "sale_order_line", "account_move",
"ir_act_window", "ir_model_data", "ir_ui_menu",
}
for _, table := range seqs {
tx.Exec(ctx, fmt.Sprintf(
`SELECT setval('%s_id_seq', GREATEST((SELECT COALESCE(MAX(id),0) FROM %q), 1))`,
table, table))
}
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
('partner.list', 'res.partner', 'list', '
', 16, true, 'primary'),
('partner.form', 'res.partner', 'form', '
', 16, true, 'primary'),
('partner.search', 'res.partner', 'search', '
', 16, true, 'primary'),
('invoice.list', 'account.move', 'list', '
', 16, true, 'primary'),
('sale.list', 'sale.order', 'list', '
', 16, true, 'primary'),
('lead.list', 'crm.lead', 'list', '
', 16, true, 'primary'),
('partner.kanban', 'res.partner', 'kanban', '
', 16, true, 'primary'),
('lead.kanban', 'crm.lead', 'kanban', '
', 16, true, 'primary')
ON CONFLICT DO NOTHING`)
log.Println("db: UI views seeded")
}
// seedActions inserts action records into ir_act_window and their XML IDs into ir_model_data.
// Mirrors: odoo/addons/base/data/ir_actions_data.xml, contacts/data/..., account/data/..., etc.
func seedActions(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding actions...")
// Action definitions: id, name, res_model, view_mode, domain, context, target, limit, res_id
type actionDef struct {
ID int
Name string
ResModel string
ViewMode string
Domain string
Context string
Target string
Limit int
ResID int
// XML ID parts
Module string
XMLName string
}
actions := []actionDef{
{1, "Contacts", "res.partner", "list,kanban,form", "[]", "{}", "current", 80, 0, "contacts", "action_contacts"},
{2, "Invoices", "account.move", "list,form", `[("move_type","in",["out_invoice","out_refund"])]`, "{}", "current", 80, 0, "account", "action_move_out_invoice_type"},
{3, "Sale Orders", "sale.order", "list,form", "[]", "{}", "current", 80, 0, "sale", "action_quotations_with_onboarding"},
{4, "CRM Pipeline", "crm.lead", "kanban,list,form", "[]", "{}", "current", 80, 0, "crm", "crm_lead_all_pipeline"},
{5, "Transfers", "stock.picking", "list,form", "[]", "{}", "current", 80, 0, "stock", "action_picking_tree_all"},
{6, "Products", "product.template", "list,form", "[]", "{}", "current", 80, 0, "stock", "action_product_template"},
{7, "Purchase Orders", "purchase.order", "list,form", "[]", "{}", "current", 80, 0, "purchase", "action_purchase_orders"},
{8, "Employees", "hr.employee", "list,form", "[]", "{}", "current", 80, 0, "hr", "action_hr_employee"},
{9, "Departments", "hr.department", "list,form", "[]", "{}", "current", 80, 0, "hr", "action_hr_department"},
{10, "Projects", "project.project", "list,form", "[]", "{}", "current", 80, 0, "project", "action_project"},
{11, "Tasks", "project.task", "list,form", "[]", "{}", "current", 80, 0, "project", "action_project_task"},
{12, "Vehicles", "fleet.vehicle", "list,form", "[]", "{}", "current", 80, 0, "fleet", "action_fleet_vehicle"},
{100, "Settings", "res.company", "form", "[]", "{}", "current", 80, 1, "base", "action_res_company_form"},
{101, "Users", "res.users", "list,form", "[]", "{}", "current", 80, 0, "base", "action_res_users"},
{102, "Sequences", "ir.sequence", "list,form", "[]", "{}", "current", 80, 0, "base", "ir_sequence_form"},
}
for _, a := range actions {
tx.Exec(ctx, `
INSERT INTO ir_act_window (id, name, type, res_model, view_mode, res_id, domain, context, target, "limit")
VALUES ($1, $2, 'ir.actions.act_window', $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO NOTHING`,
a.ID, a.Name, a.ResModel, a.ViewMode, a.ResID, a.Domain, a.Context, a.Target, a.Limit)
tx.Exec(ctx, `
INSERT INTO ir_model_data (module, name, model, res_id, noupdate)
VALUES ($1, $2, 'ir.actions.act_window', $3, true)
ON CONFLICT DO NOTHING`,
a.Module, a.XMLName, a.ID)
}
log.Printf("db: seeded %d actions with XML IDs", len(actions))
}
// seedMenus creates menu records in ir_ui_menu and their XML IDs in ir_model_data.
// Mirrors: odoo/addons/*/data/*_menu.xml — menus are loaded from XML data files.
//
// The action field stores Odoo reference format: "ir.actions.act_window,"
// pointing to the action IDs seeded by seedActions().
// parent_id is NULL for top-level app menus, or references the parent menu ID.
func seedMenus(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding menus...")
type menuDef struct {
ID int
Name string
ParentID *int // nil = top-level
Sequence int
Action string // "ir.actions.act_window," or ""
WebIcon string // FontAwesome icon for top-level menus
// XML ID parts for ir_model_data
Module string
XMLName string
}
// Helper to create a pointer to int
p := func(id int) *int { return &id }
menus := []menuDef{
// ── Contacts ──────────────────────────────────────────────
{1, "Contacts", nil, 10, "ir.actions.act_window,1", "fa-address-book,#71639e,#FFFFFF", "contacts", "menu_contacts"},
{10, "Contacts", p(1), 10, "ir.actions.act_window,1", "", "contacts", "menu_contacts_list"},
// ── Invoicing ────────────────────────────────────────────
{2, "Invoicing", nil, 20, "ir.actions.act_window,2", "fa-book,#71639e,#FFFFFF", "account", "menu_finance"},
{20, "Invoices", p(2), 10, "ir.actions.act_window,2", "", "account", "menu_finance_invoices"},
// ── Sales ────────────────────────────────────────────────
{3, "Sales", nil, 30, "ir.actions.act_window,3", "fa-bar-chart,#71639e,#FFFFFF", "sale", "menu_sale_root"},
{30, "Orders", p(3), 10, "ir.actions.act_window,3", "", "sale", "menu_sale_orders"},
// ── CRM ──────────────────────────────────────────────────
{4, "CRM", nil, 40, "ir.actions.act_window,4", "fa-star,#71639e,#FFFFFF", "crm", "menu_crm_root"},
{40, "Pipeline", p(4), 10, "ir.actions.act_window,4", "", "crm", "menu_crm_pipeline"},
// ── Inventory ────────────────────────────────────────────
{5, "Inventory", nil, 50, "ir.actions.act_window,5", "fa-cubes,#71639e,#FFFFFF", "stock", "menu_stock_root"},
{50, "Transfers", p(5), 10, "ir.actions.act_window,5", "", "stock", "menu_stock_transfers"},
{51, "Products", p(5), 20, "ir.actions.act_window,6", "", "stock", "menu_stock_products"},
// ── Purchase ─────────────────────────────────────────────
{6, "Purchase", nil, 60, "ir.actions.act_window,7", "fa-shopping-cart,#71639e,#FFFFFF", "purchase", "menu_purchase_root"},
{60, "Purchase Orders", p(6), 10, "ir.actions.act_window,7", "", "purchase", "menu_purchase_orders"},
// ── Employees / HR ───────────────────────────────────────
{7, "Employees", nil, 70, "ir.actions.act_window,8", "fa-users,#71639e,#FFFFFF", "hr", "menu_hr_root"},
{70, "Employees", p(7), 10, "ir.actions.act_window,8", "", "hr", "menu_hr_employees"},
{71, "Departments", p(7), 20, "ir.actions.act_window,9", "", "hr", "menu_hr_departments"},
// ── Project ──────────────────────────────────────────────
{8, "Project", nil, 80, "ir.actions.act_window,10", "fa-puzzle-piece,#71639e,#FFFFFF", "project", "menu_project_root"},
{80, "Projects", p(8), 10, "ir.actions.act_window,10", "", "project", "menu_projects"},
{81, "Tasks", p(8), 20, "ir.actions.act_window,11", "", "project", "menu_project_tasks"},
// ── Fleet ────────────────────────────────────────────────
{9, "Fleet", nil, 90, "ir.actions.act_window,12", "fa-car,#71639e,#FFFFFF", "fleet", "menu_fleet_root"},
{90, "Vehicles", p(9), 10, "ir.actions.act_window,12", "", "fleet", "menu_fleet_vehicles"},
// ── Settings ─────────────────────────────────────────────
{100, "Settings", nil, 100, "ir.actions.act_window,100", "fa-cog,#71639e,#FFFFFF", "base", "menu_administration"},
{101, "Users & Companies", p(100), 10, "ir.actions.act_window,101", "", "base", "menu_users"},
{102, "Technical", p(100), 20, "ir.actions.act_window,102", "", "base", "menu_custom"},
}
for _, m := range menus {
// Insert the menu record
var actionVal *string
if m.Action != "" {
actionVal = &m.Action
}
var webIconVal *string
if m.WebIcon != "" {
webIconVal = &m.WebIcon
}
tx.Exec(ctx, `
INSERT INTO ir_ui_menu (id, name, parent_id, sequence, action, web_icon, active)
VALUES ($1, $2, $3, $4, $5, $6, true)
ON CONFLICT (id) DO NOTHING`,
m.ID, m.Name, m.ParentID, m.Sequence, actionVal, webIconVal)
// Insert the XML ID into ir_model_data
tx.Exec(ctx, `
INSERT INTO ir_model_data (module, name, model, res_id, noupdate)
VALUES ($1, $2, 'ir.ui.menu', $3, true)
ON CONFLICT DO NOTHING`,
m.Module, m.XMLName, m.ID)
}
log.Printf("db: seeded %d menus with XML IDs", len(menus))
}
// 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`)
// Demo sale orders
tx.Exec(ctx, `INSERT INTO sale_order (name, partner_id, company_id, currency_id, state, date_order, amount_untaxed, amount_total) VALUES
('AG0001', 3, 1, 1, 'sale', '2026-03-15 10:00:00', 18100, 21539),
('AG0002', 4, 1, 1, 'draft', '2026-03-20 14:30:00', 6000, 7140),
('AG0003', 5, 1, 1, 'sale', '2026-03-25 09:15:00', 11700, 13923)
ON CONFLICT DO NOTHING`)
// Demo sale order lines
tx.Exec(ctx, `INSERT INTO sale_order_line (order_id, name, product_uom_qty, price_unit, sequence) VALUES
((SELECT id FROM sale_order WHERE name='AG0001'), 'Baustelleneinrichtung', 1, 12500, 10),
((SELECT id FROM sale_order WHERE name='AG0001'), 'Erdarbeiten', 3, 2800, 20),
((SELECT id FROM sale_order WHERE name='AG0002'), 'Beratung IT-Infrastruktur', 40, 150, 10),
((SELECT id FROM sale_order WHERE name='AG0003'), 'Elektroinstallation', 1, 8500, 10),
((SELECT id FROM sale_order WHERE name='AG0003'), 'Material Kabel/Dosen', 1, 3200, 20)
ON CONFLICT DO NOTHING`)
// Demo invoices (account.move)
tx.Exec(ctx, `INSERT INTO account_move (name, move_type, state, date, invoice_date, partner_id, journal_id, company_id, currency_id, amount_total, amount_untaxed) VALUES
('RE/2026/0001', 'out_invoice', 'posted', '2026-03-10', '2026-03-10', 3, 1, 1, 1, 14875, 12500),
('RE/2026/0002', 'out_invoice', 'draft', '2026-03-20', '2026-03-20', 4, 1, 1, 1, 7140, 6000),
('RE/2026/0003', 'out_invoice', 'posted', '2026-03-25', '2026-03-25', 5, 1, 1, 1, 13923, 11700)
ON CONFLICT DO NOTHING`)
log.Println("db: demo data loaded (8 contacts, 3 sale orders, 3 invoices)")
}
// 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,
})
}