// 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', ' ', 16, true, 'primary'), ('partner.form', 'res.partner', 'form', '
', 16, true, 'primary'), ('partner.search', 'res.partner', 'search', ' ', 16, true, 'primary'), -- account.move views ('invoice.list', 'account.move', 'list', ' ', 16, true, 'primary'), -- sale.order views ('sale.list', 'sale.order', '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, }) }