diff --git a/addons/base/models/res_users.go b/addons/base/models/res_users.go index 2a43691..6038edf 100644 --- a/addons/base/models/res_users.go +++ b/addons/base/models/res_users.go @@ -61,6 +61,23 @@ func initResUsers() { Help: "External user with limited access (portal/public)", }), ) + + // -- Methods -- + + // action_get returns the "Change My Preferences" action for the current user. + // Mirrors: odoo/addons/base/models/res_users.py Users.action_get() + m.RegisterMethod("action_get", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + return map[string]interface{}{ + "type": "ir.actions.act_window", + "name": "Change My Preferences", + "res_model": "res.users", + "view_mode": "form", + "views": [][]interface{}{{false, "form"}}, + "target": "new", + "res_id": rs.Env().UID(), + "context": map[string]interface{}{}, + }, nil + }) } // initResGroups registers the res.groups model. diff --git a/odoo-server b/odoo-server index 3751050..ad8f4eb 100755 Binary files a/odoo-server and b/odoo-server differ diff --git a/pkg/server/server.go b/pkg/server/server.go index da2fd53..24780c7 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -629,20 +629,6 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa case "activity_format": return []interface{}{}, nil - case "action_get": - // res.users.action_get() — returns the preference action - // Mirrors: odoo/addons/base/models/res_users.py action_get() - return map[string]interface{}{ - "type": "ir.actions.act_window", - "name": "Change My Preferences", - "res_model": "res.users", - "view_mode": "form", - "views": [][]interface{}{{false, "form"}}, - "target": "new", - "res_id": env.UID(), - "context": map[string]interface{}{}, - }, nil - case "action_archive": ids := parseIDs(params.Args) if len(ids) > 0 { diff --git a/pkg/server/web_methods.go b/pkg/server/web_methods.go index 1b569dd..132af33 100644 --- a/pkg/server/web_methods.go +++ b/pkg/server/web_methods.go @@ -89,6 +89,9 @@ func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams // Format date/datetime fields to Odoo's expected string format formatDateFields(model, records) + // Convert SQL NULLs to Odoo-expected defaults (false, 0, etc.) + normalizeNullFields(model, records) + if records == nil { records = []orm.Values{} } @@ -133,6 +136,9 @@ func handleWebRead(env *orm.Environment, model string, params CallKWParams) (int // Format date/datetime fields to Odoo's expected string format formatDateFields(model, records) + // Convert SQL NULLs to Odoo-expected defaults (false, 0, etc.) + normalizeNullFields(model, records) + if records == nil { records = []orm.Values{} } @@ -203,6 +209,42 @@ func formatM2OFields(env *orm.Environment, modelName string, records []orm.Value } } +// normalizeNullFields converts SQL NULL (Go nil) values to Odoo-expected defaults. +// In Python Odoo, empty fields return False (JSON false), not null. +// Without this, the webclient may render "null" as literal text. +// Mirrors: odoo/orm/fields.py convert_to_read() behaviour +func normalizeNullFields(model string, records []orm.Values) { + m := orm.Registry.Get(model) + if m == nil { + return + } + for _, rec := range records { + for fieldName, val := range rec { + if val != nil { + continue + } + f := m.GetField(fieldName) + if f == nil { + continue + } + switch f.Type { + case orm.TypeChar, orm.TypeText, orm.TypeHTML, orm.TypeSelection: + rec[fieldName] = false + case orm.TypeMany2one: + rec[fieldName] = false + case orm.TypeInteger, orm.TypeFloat, orm.TypeMonetary: + rec[fieldName] = false + case orm.TypeBoolean: + rec[fieldName] = false + case orm.TypeDate, orm.TypeDatetime: + rec[fieldName] = false + default: + rec[fieldName] = false + } + } + } +} + // formatDateFields converts date/datetime values to Odoo's expected string format. func formatDateFields(model string, records []orm.Values) { m := orm.Registry.Get(model) diff --git a/pkg/service/db.go b/pkg/service/db.go index 46c1e22..9da4416 100644 --- a/pkg/service/db.go +++ b/pkg/service/db.go @@ -250,26 +250,30 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err log.Printf("db: seeded %d German tax definitions", len(l10n_de.SKR03Taxes)) } - // 10. UI Views for key models + // 10. Stock reference data (locations, picking types, warehouse) + seedStockData(ctx, tx) + + // 11. UI Views for key models seedViews(ctx, tx) - // 11. Actions (ir_act_window + ir_model_data for XML IDs) + // 12. Actions (ir_act_window + ir_model_data for XML IDs) seedActions(ctx, tx) - // 12. Menus (ir_ui_menu + ir_model_data for XML IDs) + // 13. Menus (ir_ui_menu + ir_model_data for XML IDs) seedMenus(ctx, tx) - // 13. Demo data + // 14. Demo data if cfg.DemoData { seedDemoData(ctx, tx) } - // 14. Reset sequences (each individually — pgx doesn't support multi-statement) + // 15. 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", + "stock_location", "stock_picking_type", "stock_warehouse", } for _, table := range seqs { tx.Exec(ctx, fmt.Sprintf( @@ -285,6 +289,40 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err return nil } +// seedStockData creates stock locations, picking types, and a default warehouse. +// Mirrors: odoo/addons/stock/data/stock_data.xml +func seedStockData(ctx context.Context, tx pgx.Tx) { + log.Println("db: seeding stock reference data...") + + // Stock locations (hierarchical, mirroring Odoo's stock_data.xml) + tx.Exec(ctx, ` + INSERT INTO stock_location (id, name, complete_name, usage, active, location_id, company_id) VALUES + (1, 'Physical Locations', 'Physical Locations', 'view', true, NULL, 1), + (2, 'Partner Locations', 'Partner Locations', 'view', true, NULL, 1), + (3, 'Virtual Locations', 'Virtual Locations', 'view', true, NULL, 1), + (4, 'WH', 'Physical Locations/WH', 'internal', true, 1, 1), + (5, 'Customers', 'Partner Locations/Customers', 'customer', true, 2, 1), + (6, 'Vendors', 'Partner Locations/Vendors', 'supplier', true, 2, 1) + ON CONFLICT (id) DO NOTHING`) + + // Default warehouse (must come before picking types due to FK) + tx.Exec(ctx, ` + INSERT INTO stock_warehouse (id, name, code, active, company_id, lot_stock_id, sequence) + VALUES (1, 'Main Warehouse', 'WH', true, 1, 4, 10) + ON CONFLICT (id) DO NOTHING`) + + // Stock picking types (operation types for receipts, deliveries, internal transfers) + tx.Exec(ctx, ` + INSERT INTO stock_picking_type (id, name, code, sequence_code, active, warehouse_id, company_id, sequence, + default_location_src_id, default_location_dest_id) VALUES + (1, 'Receipts', 'incoming', 'IN', true, 1, 1, 1, 6, 4), + (2, 'Delivery Orders', 'outgoing', 'OUT', true, 1, 1, 2, 4, 5), + (3, 'Internal Transfers', 'internal', 'INT', true, 1, 1, 3, 4, 4) + ON CONFLICT (id) DO NOTHING`) + + log.Println("db: stock reference data seeded (6 locations, 3 picking types, 1 warehouse)") +} + // seedViews creates UI views for key models. func seedViews(ctx context.Context, tx pgx.Tx) { log.Println("db: seeding UI views...") @@ -428,6 +466,7 @@ func seedActions(ctx context.Context, tx pgx.Tx) { {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"}, + {103, "Change My Preferences", "res.users", "form", "[]", "{}", "new", 80, 0, "base", "action_res_users_my"}, } for _, a := range actions {