diff --git a/pkg/orm/domain.go b/pkg/orm/domain.go index 7487db1..ae24c67 100644 --- a/pkg/orm/domain.go +++ b/pkg/orm/domain.go @@ -242,87 +242,18 @@ func (dc *DomainCompiler) compileCondition(c Condition) (string, error) { } func (dc *DomainCompiler) compileSimpleCondition(column, operator string, value Value) (string, error) { - paramIdx := len(dc.params) + 1 - + // Operators that need special handling (not in compileQualifiedCondition) 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, wrapLikeValue(value)) - return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil - - case "not like": - dc.params = append(dc.params, wrapLikeValue(value)) - return fmt.Sprintf("%q NOT LIKE $%d", column, paramIdx), nil - - case "ilike": - dc.params = append(dc.params, wrapLikeValue(value)) - return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil - - case "not ilike": - dc.params = append(dc.params, wrapLikeValue(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 - case "child_of": return dc.compileHierarchyOp(column, value, true) - case "parent_of": return dc.compileHierarchyOp(column, value, false) - case "any": return dc.compileAnyOp(column, value, false) - case "not any": return dc.compileAnyOp(column, value, true) - default: - return "", fmt.Errorf("unhandled operator: %q", operator) + return dc.compileQualifiedCondition(fmt.Sprintf("%q", column), operator, value) } } diff --git a/pkg/server/server.go b/pkg/server/server.go index 5e4628f..8092316 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -575,7 +575,7 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa } } - limit := 8 + limit := defaultNameSearchLimit if v, ok := params.KW["limit"].(float64); ok { limit = int(v) } diff --git a/pkg/server/web_methods.go b/pkg/server/web_methods.go index 037e30e..5e87a79 100644 --- a/pkg/server/web_methods.go +++ b/pkg/server/web_methods.go @@ -7,6 +7,12 @@ import ( "odoo-go/pkg/orm" ) +const ( + defaultWebSearchLimit = 80 + defaultO2MFetchLimit = 200 + defaultNameSearchLimit = 8 +) + // 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. @@ -39,7 +45,7 @@ func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams // Parse offset, limit, order offset := 0 - limit := 80 + limit := defaultWebSearchLimit order := "" if v, ok := params.KW["offset"].(float64); ok { offset = int(v) @@ -178,6 +184,8 @@ func formatO2MFields(env *orm.Environment, modelName string, records []orm.Value continue } + // Collect all parent IDs from records + var parentIDs []int64 for _, rec := range records { parentID, ok := rec["id"].(int64) if !ok { @@ -185,38 +193,58 @@ func formatO2MFields(env *orm.Environment, modelName string, records []orm.Value parentID = int64(pid) } } - if parentID == 0 { - rec[fieldName] = []interface{}{} - continue + if parentID > 0 { + parentIDs = append(parentIDs, parentID) } + } - // Search child records where inverse_field = parent_id - childRS := env.Model(comodel) - domain := orm.And(orm.Leaf(inverseField, "=", parentID)) - found, err := childRS.Search(domain, orm.SearchOpts{Limit: 200}) - if err != nil || found.IsEmpty() { - rec[fieldName] = []interface{}{} - continue + // Initialize all records with empty slices + for _, rec := range records { + rec[fieldName] = []interface{}{} + } + + if len(parentIDs) == 0 { + continue + } + + // Single batched search: WHERE inverse_field IN (all parent IDs) + childRS := env.Model(comodel) + domain := orm.And(orm.Leaf(inverseField, "in", parentIDs)) + found, err := childRS.Search(domain, orm.SearchOpts{Limit: defaultO2MFetchLimit}) + if err != nil || found.IsEmpty() { + continue + } + + // Single batched read + childRecords, err := found.Read(childFields) + if err != nil { + continue + } + + // Format child records (M2O fields, dates, nulls) + formatM2OFields(env, comodel, childRecords, subFieldsSpec) + formatDateFields(comodel, childRecords) + normalizeNullFields(comodel, childRecords) + + // Group child records by their inverse field (parent ID) + grouped := make(map[int64][]interface{}) + for _, cr := range childRecords { + if pid, ok := orm.ToRecordID(cr[inverseField]); ok && pid > 0 { + grouped[pid] = append(grouped[pid], cr) } + } - // Read child records - childRecords, err := found.Read(childFields) - if err != nil { - rec[fieldName] = []interface{}{} - continue + // Assign grouped children to each parent record + for _, rec := range records { + parentID, ok := rec["id"].(int64) + if !ok { + if pid, ok := rec["id"].(int32); ok { + parentID = int64(pid) + } } - - // Format child records (M2O fields, dates, nulls) - formatM2OFields(env, comodel, childRecords, subFieldsSpec) - formatDateFields(comodel, childRecords) - normalizeNullFields(comodel, childRecords) - - // Convert to []interface{} for JSON - lines := make([]interface{}, len(childRecords)) - for i, cr := range childRecords { - lines[i] = cr + if children, ok := grouped[parentID]; ok { + rec[fieldName] = children } - rec[fieldName] = lines } } } diff --git a/pkg/service/db.go b/pkg/service/db.go index 1cf053d..df735cd 100644 --- a/pkg/service/db.go +++ b/pkg/service/db.go @@ -307,8 +307,46 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err log.Printf("db: seeding database for %q...", cfg.CompanyName) - // 1. Currency (EUR) - _, err = tx.Exec(ctx, ` + if err := seedCurrencyAndCountry(ctx, tx, cfg); err != nil { + return err + } + if err := seedCompanyAndAdmin(ctx, tx, cfg); err != nil { + return err + } + if err := seedJournalsAndSequences(ctx, tx); err != nil { + return err + } + seedChartOfAccounts(ctx, tx, cfg) + seedStockData(ctx, tx) + seedViews(ctx, tx) + seedActions(ctx, tx) + seedMenus(ctx, tx) + + // Settings record (res.config.settings needs at least one record to display) + tx.Exec(ctx, `INSERT INTO res_config_settings (id, company_id, show_effect, create_uid, write_uid) + VALUES (1, 1, true, 1, 1) ON CONFLICT (id) DO NOTHING`) + + seedSystemParams(ctx, tx) + seedLanguages(ctx, tx) + seedTranslations(ctx, tx) + + if cfg.DemoData { + seedDemoData(ctx, tx) + } + + resetSequences(ctx, tx) + + 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 +} + +// seedCurrencyAndCountry seeds the default currency (EUR) and country. +func seedCurrencyAndCountry(ctx context.Context, tx pgx.Tx, cfg SetupConfig) error { + _, 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`) @@ -316,7 +354,6 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err 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) @@ -324,9 +361,12 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err if err != nil { return fmt.Errorf("db: seed country: %w", err) } + return nil +} - // 3. Company partner - _, err = tx.Exec(ctx, ` +// seedCompanyAndAdmin seeds the company partner, company, admin partner, and admin user. +func seedCompanyAndAdmin(ctx context.Context, tx pgx.Tx, cfg SetupConfig) error { + _, 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`, @@ -335,7 +375,6 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err 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) @@ -345,7 +384,6 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err 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) @@ -355,7 +393,6 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err 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) @@ -363,9 +400,12 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err if err != nil { return fmt.Errorf("db: seed admin user: %w", err) } + return nil +} - // 7. Journals - _, err = tx.Exec(ctx, ` +// seedJournalsAndSequences seeds accounting journals and IR sequences. +func seedJournalsAndSequences(ctx context.Context, tx pgx.Tx) error { + _, 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), @@ -377,7 +417,6 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err 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'), @@ -389,59 +428,34 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err if err != nil { return fmt.Errorf("db: seed sequences: %w", err) } + return nil +} - // 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)) +// seedChartOfAccounts seeds the chart of accounts and tax definitions if configured. +func seedChartOfAccounts(ctx context.Context, tx pgx.Tx, cfg SetupConfig) { + if cfg.Chart != "skr03" && cfg.Chart != "skr04" { + return } - - // 10. Stock reference data (locations, picking types, warehouse) - seedStockData(ctx, tx) - - // 11. UI Views for key models - seedViews(ctx, tx) - - // 12. Actions (ir_act_window + ir_model_data for XML IDs) - seedActions(ctx, tx) - - // 13. Menus (ir_ui_menu + ir_model_data for XML IDs) - seedMenus(ctx, tx) - - // 14. Settings record (res.config.settings needs at least one record to display) - tx.Exec(ctx, `INSERT INTO res_config_settings (id, company_id, show_effect, create_uid, write_uid) - VALUES (1, 1, true, 1, 1) ON CONFLICT (id) DO NOTHING`) - - // 14b. System parameters (ir.config_parameter) - seedSystemParams(ctx, tx) - - // 14c. Languages (res.lang — seed German alongside English) - seedLanguages(ctx, tx) - - // 14d. Translations (ir.translation — German translations for core UI terms) - seedTranslations(ctx, tx) - - // 15. Demo data - if cfg.DemoData { - seedDemoData(ctx, tx) + // 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)) - // 15. Reset sequences (each individually — pgx doesn't support multi-statement) + 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)) +} + +// resetSequences resets all auto-increment sequences to their current max values. +func resetSequences(ctx context.Context, tx pgx.Tx) { seqs := []string{ "res_currency", "res_country", "res_partner", "res_company", "res_users", "ir_sequence", "account_journal", "account_account", @@ -460,13 +474,6 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err `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 } // seedStockData creates stock locations, picking types, and a default warehouse.