package models import ( "fmt" "log" "strings" "odoo-go/pkg/orm" ) // initCRMLeadExtended adds activity, address, contact, and scoring fields // to crm.lead, plus business methods (merge, assign, schedule, duplicate, etc.). // Mirrors: odoo/addons/crm/models/crm_lead.py (extended CRM fields and methods) func initCRMLeadExtended() { m := orm.ExtendModel("crm.lead") // ──── Activity fields ──── // Mirrors: odoo/addons/crm/models/crm_lead.py activity fields m.AddFields( orm.Date("activity_date_deadline", orm.FieldOpts{ String: "Next Activity Deadline", Help: "Date by which the next activity should be completed.", }), orm.Char("activity_summary", orm.FieldOpts{ String: "Next Activity Summary", Help: "Short description of the next scheduled activity.", }), orm.Selection("activity_type", []orm.SelectionItem{ {Value: "email", Label: "Email"}, {Value: "call", Label: "Call"}, {Value: "meeting", Label: "Meeting"}, {Value: "todo", Label: "To-Do"}, }, orm.FieldOpts{ String: "Activity Type", Help: "Type of the next scheduled activity.", }), orm.Many2one("activity_user_id", "res.users", orm.FieldOpts{ String: "Activity Responsible", Help: "User responsible for the next scheduled activity.", }), // ──── Tracking / timing fields ──── // Mirrors: odoo/addons/crm/models/crm_lead.py date_open, day_open, day_close orm.Datetime("date_open", orm.FieldOpts{ String: "Assignment Date", Help: "Date when the lead was first assigned to a salesperson.", }), orm.Float("day_open", orm.FieldOpts{ String: "Days to Assign", Compute: "_compute_day_open", Help: "Number of days between creation and assignment.", }), orm.Float("day_close", orm.FieldOpts{ String: "Days to Close", Compute: "_compute_day_close", Help: "Number of days between creation and closing.", }), // ──── Kanban state ──── // Mirrors: odoo/addons/crm/models/crm_lead.py kanban_state (via mail.activity.mixin) orm.Selection("kanban_state", []orm.SelectionItem{ {Value: "grey", Label: "No next activity planned"}, {Value: "red", Label: "Next activity late"}, {Value: "green", Label: "Next activity is planned"}, }, orm.FieldOpts{ String: "Kanban State", Compute: "_compute_kanban_state", Help: "Activity-based status indicator for kanban views.", }), // ──── Additional contact/address fields ──── // Fields not already on crm.lead base definition. // Mirrors: odoo/addons/crm/models/crm_lead.py address & contact fields orm.Char("referred", orm.FieldOpts{ String: "Referred By", Help: "Name or reference of who referred this lead.", }), orm.Char("title", orm.FieldOpts{ String: "Title", Help: "Contact title (Mr., Mrs., etc.).", }), orm.Many2one("state_id", "res.country.state", orm.FieldOpts{ String: "State", Help: "State/province of the lead address.", }), orm.Text("contact_name", orm.FieldOpts{ String: "Contact Name", Help: "Name of the primary contact person.", }), orm.Char("mobile", orm.FieldOpts{ String: "Mobile", Help: "Mobile phone number of the contact.", }), orm.Char("street2", orm.FieldOpts{ String: "Street2", Help: "Second line of the street address.", }), // ──── Computed timing fields ──── // Mirrors: odoo/addons/crm/models/crm_lead.py _compute_days_in_stage orm.Float("days_in_stage", orm.FieldOpts{ String: "Days in Current Stage", Compute: "_compute_days_in_stage", Help: "Number of days since the last stage change.", }), // ──── Email scoring / contact address ──── // Mirrors: odoo/addons/crm/models/crm_lead.py _compute_email_score orm.Char("email_domain_criterion", orm.FieldOpts{ String: "Email Domain", Compute: "_compute_email_score", Help: "Domain part of the lead email (e.g. 'example.com').", }), orm.Text("contact_address_complete", orm.FieldOpts{ String: "Contact Address", Compute: "_compute_contact_address", Help: "Full contact address assembled from partner data.", }), // ──── Revenue fields ──── // Mirrors: odoo/addons/crm/models/crm_lead.py prorated_revenue orm.Monetary("prorated_revenue", orm.FieldOpts{ String: "Prorated Revenue", CurrencyField: "company_currency", Compute: "_compute_prorated_revenue", Help: "Expected revenue weighted by probability.", }), orm.Many2one("company_currency", "res.currency", orm.FieldOpts{ String: "Company Currency", }), // ──── Scoring / automated fields ──── // Mirrors: odoo/addons/crm/models/crm_lead_scoring.py (simplified) orm.Float("automated_probability", orm.FieldOpts{ String: "Automated Probability (%)", Help: "Probability computed by the lead scoring engine.", }), orm.Boolean("is_automated_probability", orm.FieldOpts{ String: "Use Automated Probability", Default: true, Help: "If true, probability is set automatically by scoring.", }), // ──── Source / campaign tracking ──── // Mirrors: odoo/addons/crm/models/crm_lead.py utm fields orm.Many2one("source_id", "utm.source", orm.FieldOpts{ String: "Source", Help: "Source of the lead (e.g. search engine, social media).", }), orm.Many2one("medium_id", "utm.medium", orm.FieldOpts{ String: "Medium", Help: "Medium of the lead (e.g. email, banner).", }), orm.Many2one("campaign_id", "utm.campaign", orm.FieldOpts{ String: "Campaign", Help: "Marketing campaign that generated this lead.", }), // ──── Date tracking ──── // Mirrors: odoo/addons/crm/models/crm_lead.py date_conversion, date_closed orm.Datetime("date_conversion", orm.FieldOpts{ String: "Conversion Date", Help: "Date when the lead was converted to an opportunity.", }), orm.Datetime("date_closed", orm.FieldOpts{ String: "Closed Date", Help: "Date when the opportunity was won or lost.", }), ) // ──── Compute: prorated revenue ──── // Mirrors: odoo/addons/crm/models/crm_lead.py _compute_prorated_revenue m.RegisterCompute("prorated_revenue", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() leadID := rs.IDs()[0] var revenue float64 var probability float64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(expected_revenue::float8, 0), COALESCE(probability, 0) FROM crm_lead WHERE id = $1`, leadID, ).Scan(&revenue, &probability); err != nil { log.Printf("warning: crm.lead _compute_prorated_revenue query failed: %v", err) } prorated := revenue * probability / 100.0 return orm.Values{"prorated_revenue": prorated}, nil }) // ──── Compute: day_open ──── // Mirrors: odoo/addons/crm/models/crm_lead.py _compute_day_open m.RegisterCompute("day_open", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() leadID := rs.IDs()[0] var dayOpen *float64 err := env.Tx().QueryRow(env.Ctx(), `SELECT CASE WHEN date_open IS NOT NULL AND create_date IS NOT NULL THEN ABS(EXTRACT(EPOCH FROM (date_open - create_date)) / 86400) ELSE NULL END FROM crm_lead WHERE id = $1`, leadID, ).Scan(&dayOpen) if err != nil { log.Printf("warning: crm.lead _compute_day_open query failed: %v", err) } result := float64(0) if dayOpen != nil { result = *dayOpen } return orm.Values{"day_open": result}, nil }) // ──── Compute: day_close ──── // Mirrors: odoo/addons/crm/models/crm_lead.py _compute_day_close m.RegisterCompute("day_close", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() leadID := rs.IDs()[0] var dayClose *float64 err := env.Tx().QueryRow(env.Ctx(), `SELECT CASE WHEN date_closed IS NOT NULL AND create_date IS NOT NULL THEN ABS(EXTRACT(EPOCH FROM (date_closed - create_date)) / 86400) ELSE NULL END FROM crm_lead WHERE id = $1`, leadID, ).Scan(&dayClose) if err != nil { log.Printf("warning: crm.lead _compute_day_close query failed: %v", err) } result := float64(0) if dayClose != nil { result = *dayClose } return orm.Values{"day_close": result}, nil }) // ──── Compute: kanban_state ──── // Based on activity deadline: overdue=red, today/future=green, no activity=grey. // Mirrors: odoo/addons/mail/models/mail_activity_mixin.py _compute_kanban_state m.RegisterCompute("kanban_state", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() leadID := rs.IDs()[0] var deadline *string err := env.Tx().QueryRow(env.Ctx(), `SELECT activity_date_deadline FROM crm_lead WHERE id = $1`, leadID, ).Scan(&deadline) if err != nil { log.Printf("warning: crm.lead _compute_kanban_state query failed: %v", err) } state := "grey" // no activity planned if deadline != nil && *deadline != "" { // Check if overdue var isOverdue bool env.Tx().QueryRow(env.Ctx(), `SELECT activity_date_deadline < CURRENT_DATE FROM crm_lead WHERE id = $1`, leadID, ).Scan(&isOverdue) if isOverdue { state = "red" // overdue } else { state = "green" // planned (today or future) } } return orm.Values{"kanban_state": state}, nil }) // ──── Compute: days_in_stage ──── // Mirrors: odoo/addons/crm/models/crm_lead.py _compute_days_in_stage m.RegisterCompute("days_in_stage", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() leadID := rs.IDs()[0] var days *float64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT CASE WHEN date_last_stage_update IS NOT NULL THEN EXTRACT(DAY FROM NOW() - date_last_stage_update) ELSE 0 END FROM crm_lead WHERE id = $1`, leadID, ).Scan(&days); err != nil { log.Printf("warning: crm.lead _compute_days_in_stage query failed: %v", err) } result := float64(0) if days != nil { result = *days } return orm.Values{"days_in_stage": result}, nil }) // ──── Compute: email_score (email domain extraction) ──── // Mirrors: odoo/addons/crm/models/crm_lead.py _compute_email_domain_criterion m.RegisterCompute("email_domain_criterion", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() leadID := rs.IDs()[0] var email *string if err := env.Tx().QueryRow(env.Ctx(), `SELECT email_from FROM crm_lead WHERE id = $1`, leadID, ).Scan(&email); err != nil { log.Printf("warning: crm.lead _compute_email_score query failed: %v", err) } domain := "" if email != nil && *email != "" { parts := strings.SplitN(*email, "@", 2) if len(parts) == 2 { domain = strings.TrimSpace(parts[1]) } } return orm.Values{"email_domain_criterion": domain}, nil }) // ──── Compute: contact_address ──── // Mirrors: odoo/addons/crm/models/crm_lead.py _compute_contact_address m.RegisterCompute("contact_address_complete", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() leadID := rs.IDs()[0] var street, street2, city, zip *string var partnerID *int64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT street, street2, city, zip, partner_id FROM crm_lead WHERE id = $1`, leadID, ).Scan(&street, &street2, &city, &zip, &partnerID); err != nil { log.Printf("warning: crm.lead _compute_contact_address query failed: %v", err) } // If partner exists, fetch address from partner instead if partnerID != nil { if err := env.Tx().QueryRow(env.Ctx(), `SELECT street, street2, city, zip FROM res_partner WHERE id = $1`, *partnerID, ).Scan(&street, &street2, &city, &zip); err != nil { log.Printf("warning: crm.lead _compute_contact_address partner query failed: %v", err) } } var parts []string if street != nil && *street != "" { parts = append(parts, *street) } if street2 != nil && *street2 != "" { parts = append(parts, *street2) } if zip != nil && *zip != "" && city != nil && *city != "" { parts = append(parts, *zip+" "+*city) } else if city != nil && *city != "" { parts = append(parts, *city) } address := strings.Join(parts, "\n") return orm.Values{"contact_address_complete": address}, nil }) // ──── Business Methods ──── // action_schedule_activity: create a mail.activity record linked to the lead. // Mirrors: odoo/addons/crm/models/crm_lead.py action_schedule_activity m.RegisterMethod("action_schedule_activity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() leadID := rs.IDs()[0] // Extract optional kwargs: summary, activity_type_id, date_deadline, user_id, note summary := "" note := "" dateDeadline := "" var userID int64 var activityTypeID int64 if len(args) > 0 { if kwargs, ok := args[0].(map[string]interface{}); ok { if v, ok := kwargs["summary"].(string); ok { summary = v } if v, ok := kwargs["note"].(string); ok { note = v } if v, ok := kwargs["date_deadline"].(string); ok { dateDeadline = v } if v, ok := kwargs["user_id"]; ok { switch uid := v.(type) { case float64: userID = int64(uid) case int64: userID = uid case int: userID = int64(uid) } } if v, ok := kwargs["activity_type_id"]; ok { switch tid := v.(type) { case float64: activityTypeID = int64(tid) case int64: activityTypeID = tid case int: activityTypeID = int64(tid) } } } } // Default user to current user if userID == 0 { userID = env.UID() } // Default deadline to tomorrow if dateDeadline == "" { dateDeadline = "CURRENT_DATE + INTERVAL '1 day'" } var newID int64 var err error if dateDeadline == "CURRENT_DATE + INTERVAL '1 day'" { err = env.Tx().QueryRow(env.Ctx(), `INSERT INTO mail_activity (res_model, res_id, summary, note, date_deadline, user_id, activity_type_id, state) VALUES ('crm.lead', $1, $2, $3, CURRENT_DATE + INTERVAL '1 day', $4, NULLIF($5, 0), 'planned') RETURNING id`, leadID, summary, note, userID, activityTypeID, ).Scan(&newID) } else { err = env.Tx().QueryRow(env.Ctx(), `INSERT INTO mail_activity (res_model, res_id, summary, note, date_deadline, user_id, activity_type_id, state) VALUES ('crm.lead', $1, $2, $3, $6::date, $4, NULLIF($5, 0), 'planned') RETURNING id`, leadID, summary, note, userID, activityTypeID, dateDeadline, ).Scan(&newID) } if err != nil { return nil, fmt.Errorf("action_schedule_activity: %w", err) } return map[string]interface{}{ "activity_id": newID, "type": "ir.actions.act_window", "name": "Schedule Activity", "res_model": "mail.activity", "res_id": newID, "view_mode": "form", "views": [][]interface{}{{nil, "form"}}, "target": "new", }, nil }) // action_merge: alias for action_merge_leads (delegates to the full implementation). m.RegisterMethod("action_merge", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { mergeMethod := orm.Registry.Get("crm.lead").Methods["action_merge_leads"] if mergeMethod != nil { return mergeMethod(rs, args...) } return nil, fmt.Errorf("crm.lead: action_merge_leads not found") }) // _get_opportunities_by_status: GROUP BY stage_id aggregation returning counts + sums. // Mirrors: odoo/addons/crm/models/crm_lead.py _read_group (pipeline analysis) m.RegisterMethod("_get_opportunities_by_status", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() rows, err := env.Tx().Query(env.Ctx(), ` SELECT s.id, s.name, COUNT(l.id), COALESCE(SUM(l.expected_revenue::float8), 0), COALESCE(AVG(l.probability), 0) FROM crm_lead l JOIN crm_stage s ON s.id = l.stage_id WHERE l.active = true AND l.type = 'opportunity' GROUP BY s.id, s.name, s.sequence ORDER BY s.sequence`) if err != nil { return nil, fmt.Errorf("_get_opportunities_by_status: %w", err) } defer rows.Close() var results []map[string]interface{} for rows.Next() { var stageID int64 var stageName string var count int64 var revenue, avgProb float64 if err := rows.Scan(&stageID, &stageName, &count, &revenue, &avgProb); err != nil { return nil, fmt.Errorf("_get_opportunities_by_status scan: %w", err) } results = append(results, map[string]interface{}{ "stage_id": stageID, "stage_name": stageName, "count": count, "total_revenue": revenue, "avg_probability": avgProb, }) } return results, nil }) // action_merge_leads: merge multiple leads — sum revenues, keep first partner, // concatenate descriptions, delete merged records. // Mirrors: odoo/addons/crm/wizard/crm_merge_opportunities.py action_merge m.RegisterMethod("action_merge_leads", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() ids := rs.IDs() if len(ids) < 2 { return nil, fmt.Errorf("need at least 2 leads to merge") } masterID := ids[0] for _, slaveID := range ids[1:] { // Sum revenues if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET expected_revenue = COALESCE(expected_revenue, 0) + (SELECT COALESCE(expected_revenue, 0) FROM crm_lead WHERE id = $1) WHERE id = $2`, slaveID, masterID); err != nil { log.Printf("warning: crm.lead action_merge_leads revenue sum failed for slave %d: %v", slaveID, err) } // Keep first partner (master wins if set) if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET partner_id = COALESCE(partner_id, (SELECT partner_id FROM crm_lead WHERE id = $1)) WHERE id = $2`, slaveID, masterID); err != nil { log.Printf("warning: crm.lead action_merge_leads partner copy failed for slave %d: %v", slaveID, err) } // Concatenate descriptions if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET description = COALESCE(description, '') || E'\n---\n' || COALESCE((SELECT description FROM crm_lead WHERE id = $1), '') WHERE id = $2`, slaveID, masterID); err != nil { log.Printf("warning: crm.lead action_merge_leads description concat failed for slave %d: %v", slaveID, err) } // Delete the merged (slave) lead if _, err := env.Tx().Exec(env.Ctx(), `DELETE FROM crm_lead WHERE id = $1`, slaveID); err != nil { log.Printf("warning: crm.lead action_merge_leads delete failed for slave %d: %v", slaveID, err) } } return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "crm.lead", "res_id": masterID, "view_mode": "form", "views": [][]interface{}{{nil, "form"}}, "target": "current", }, nil }) // _action_reschedule_calls: update activity dates for leads with overdue activities. // Mirrors: odoo/addons/crm/models/crm_lead.py _action_reschedule_calls m.RegisterMethod("_action_reschedule_calls", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() // Default reschedule days = 7 rescheduleDays := 7 if len(args) > 0 { switch v := args[0].(type) { case float64: rescheduleDays = int(v) case int: rescheduleDays = v case int64: rescheduleDays = int(v) } } // Update all overdue mail.activity records linked to crm.lead result, err := env.Tx().Exec(env.Ctx(), `UPDATE mail_activity SET date_deadline = CURRENT_DATE + ($1 || ' days')::interval, state = 'planned' WHERE res_model = 'crm.lead' AND date_deadline < CURRENT_DATE AND done = false`, rescheduleDays) if err != nil { return nil, fmt.Errorf("_action_reschedule_calls: %w", err) } rowsAffected := result.RowsAffected() return map[string]interface{}{ "rescheduled_count": rowsAffected, }, nil }) // action_lead_duplicate: copy lead with "(Copy)" suffix on name. // Mirrors: odoo/addons/crm/models/crm_lead.py copy() m.RegisterMethod("action_lead_duplicate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() leadID := rs.IDs()[0] var newID int64 err := env.Tx().QueryRow(env.Ctx(), `INSERT INTO crm_lead (name, type, partner_id, email_from, phone, stage_id, team_id, user_id, expected_revenue, probability, priority, company_id, currency_id, active, description, partner_name, street, city, zip, country_id, date_last_stage_update) SELECT name || ' (Copy)', type, partner_id, email_from, phone, stage_id, team_id, user_id, expected_revenue, probability, priority, company_id, currency_id, true, description, partner_name, street, city, zip, country_id, NOW() FROM crm_lead WHERE id = $1 RETURNING id`, leadID, ).Scan(&newID) if err != nil { return nil, fmt.Errorf("action_lead_duplicate: %w", err) } return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "crm.lead", "res_id": newID, "view_mode": "form", "views": [][]interface{}{{nil, "form"}}, "target": "current", }, nil }) // set_user_as_follower: create mail.followers entry for the lead's salesperson. // Mirrors: odoo/addons/crm/models/crm_lead.py _create_lead_partner m.RegisterMethod("set_user_as_follower", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, leadID := range rs.IDs() { // Get the user_id for the lead, then find the partner_id for that user var userID *int64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT user_id FROM crm_lead WHERE id = $1`, leadID, ).Scan(&userID); err != nil || userID == nil { continue } var partnerID *int64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT partner_id FROM res_users WHERE id = $1`, *userID, ).Scan(&partnerID); err != nil || partnerID == nil { continue } // Check if already a follower var exists bool env.Tx().QueryRow(env.Ctx(), `SELECT EXISTS( SELECT 1 FROM mail_followers WHERE res_model = 'crm.lead' AND res_id = $1 AND partner_id = $2 )`, leadID, *partnerID, ).Scan(&exists) if !exists { if _, err := env.Tx().Exec(env.Ctx(), `INSERT INTO mail_followers (res_model, res_id, partner_id) VALUES ('crm.lead', $1, $2)`, leadID, *partnerID); err != nil { log.Printf("warning: crm.lead set_user_as_follower failed for lead %d: %v", leadID, err) } } } return true, nil }) // message_subscribe: subscribe partners as followers on the lead. // Mirrors: odoo/addons/mail/models/mail_thread.py message_subscribe m.RegisterMethod("message_subscribe", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() if len(args) < 1 { return nil, fmt.Errorf("partner_ids required") } // Accept partner_ids as []interface{} or []int64 var partnerIDs []int64 switch v := args[0].(type) { case []interface{}: for _, p := range v { switch pid := p.(type) { case float64: partnerIDs = append(partnerIDs, int64(pid)) case int64: partnerIDs = append(partnerIDs, pid) case int: partnerIDs = append(partnerIDs, int64(pid)) } } case []int64: partnerIDs = v } for _, leadID := range rs.IDs() { for _, partnerID := range partnerIDs { // Check if already subscribed var exists bool env.Tx().QueryRow(env.Ctx(), `SELECT EXISTS( SELECT 1 FROM mail_followers WHERE res_model = 'crm.lead' AND res_id = $1 AND partner_id = $2 )`, leadID, partnerID, ).Scan(&exists) if !exists { if _, err := env.Tx().Exec(env.Ctx(), `INSERT INTO mail_followers (res_model, res_id, partner_id) VALUES ('crm.lead', $1, $2)`, leadID, partnerID); err != nil { log.Printf("warning: crm.lead message_subscribe failed for lead %d partner %d: %v", leadID, partnerID, err) } } } } return true, nil }) // action_assign_salesperson: assign a salesperson to one or more leads. // Mirrors: odoo/addons/crm/models/crm_lead.py _handle_salesmen_assignment m.RegisterMethod("action_assign_salesperson", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("user_id required") } userID, ok := args[0].(float64) if !ok { return nil, fmt.Errorf("user_id must be a number") } env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET user_id = $1 WHERE id = $2`, int64(userID), id); err != nil { log.Printf("warning: crm.lead action_assign_salesperson failed for lead %d: %v", id, err) } } return true, nil }) // action_duplicate: duplicate a lead with a modified name. // Mirrors: odoo/addons/crm/models/crm_lead.py copy() m.RegisterMethod("action_duplicate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() leadID := rs.IDs()[0] var newID int64 err := env.Tx().QueryRow(env.Ctx(), `INSERT INTO crm_lead (name, type, partner_id, email_from, phone, stage_id, team_id, user_id, expected_revenue, probability, priority, company_id, currency_id, active, description) SELECT name || ' (copy)', type, partner_id, email_from, phone, stage_id, team_id, user_id, expected_revenue, probability, priority, company_id, currency_id, true, description FROM crm_lead WHERE id = $1 RETURNING id`, leadID, ).Scan(&newID) if err != nil { return nil, fmt.Errorf("action_duplicate: %w", err) } return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "crm.lead", "res_id": newID, "view_mode": "form", "views": [][]interface{}{{nil, "form"}}, "target": "current", }, nil }) // action_set_priority: set priority on selected leads. // Mirrors: odoo/addons/crm/models/crm_lead.py (bulk actions) m.RegisterMethod("action_set_priority", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("priority value required (0-3)") } priority, ok := args[0].(string) if !ok { return nil, fmt.Errorf("priority must be a string ('0','1','2','3')") } env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET priority = $1 WHERE id = $2`, priority, id); err != nil { log.Printf("warning: crm.lead action_set_priority failed for lead %d: %v", id, err) } } return true, nil }) // action_archive: archive (deactivate) selected leads. // Mirrors: odoo/addons/crm/models/crm_lead.py toggle_active m.RegisterMethod("action_archive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET active = false WHERE id = $1`, id); err != nil { log.Printf("warning: crm.lead action_archive failed for lead %d: %v", id, err) } } return true, nil }) // action_unarchive: restore (reactivate) selected leads. // Mirrors: odoo/addons/crm/models/crm_lead.py toggle_active m.RegisterMethod("action_unarchive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET active = true WHERE id = $1`, id); err != nil { log.Printf("warning: crm.lead action_unarchive failed for lead %d: %v", id, err) } } return true, nil }) // action_set_stage: move leads to a specific stage. // Mirrors: odoo/addons/crm/models/crm_lead.py write() stage change logic m.RegisterMethod("action_set_stage", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("stage_id required") } stageID, ok := args[0].(float64) if !ok { return nil, fmt.Errorf("stage_id must be a number") } env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET stage_id = $1, date_last_stage_update = NOW() WHERE id = $2`, int64(stageID), id); err != nil { log.Printf("warning: crm.lead action_set_stage failed for lead %d: %v", id, err) } } return true, nil }) // _get_lead_statistics: return summary statistics for a set of leads. // Internal helper used by dashboard views. m.RegisterMethod("_get_lead_statistics", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() var totalLeads, totalOpps, wonCount, lostCount int64 var totalRevenue, avgProbability float64 if err := env.Tx().QueryRow(env.Ctx(), ` SELECT COUNT(*) FILTER (WHERE type = 'lead'), COUNT(*) FILTER (WHERE type = 'opportunity'), COUNT(*) FILTER (WHERE state = 'won'), COUNT(*) FILTER (WHERE state = 'lost'), COALESCE(SUM(expected_revenue::float8), 0), COALESCE(AVG(probability), 0) FROM crm_lead WHERE active = true`, ).Scan(&totalLeads, &totalOpps, &wonCount, &lostCount, &totalRevenue, &avgProbability); err != nil { log.Printf("warning: crm.lead _get_lead_statistics query failed: %v", err) } return map[string]interface{}{ "total_leads": totalLeads, "total_opportunities": totalOpps, "won_count": wonCount, "lost_count": lostCount, "total_revenue": totalRevenue, "avg_probability": avgProbability, }, nil }) // action_schedule_meeting: return calendar action for scheduling a meeting. // Mirrors: odoo/addons/crm/models/crm_lead.py action_schedule_meeting m.RegisterMethod("action_schedule_meeting", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() leadID := rs.IDs()[0] // Fetch lead data for context var name string var partnerID, teamID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, ''), partner_id, team_id FROM crm_lead WHERE id = $1`, leadID, ).Scan(&name, &partnerID, &teamID) ctx := map[string]interface{}{ "default_opportunity_id": leadID, "default_name": name, "search_default_opportunity_id": leadID, } if partnerID != nil { ctx["default_partner_id"] = *partnerID ctx["default_partner_ids"] = []int64{*partnerID} } if teamID != nil { ctx["default_team_id"] = *teamID } return map[string]interface{}{ "type": "ir.actions.act_window", "name": "Meeting", "res_model": "calendar.event", "view_mode": "calendar,tree,form", "context": ctx, }, nil }) // action_new_quotation: return action to create a sale.order linked to the lead. // Mirrors: odoo/addons/sale_crm/models/crm_lead.py action_new_quotation m.RegisterMethod("action_new_quotation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() leadID := rs.IDs()[0] // Fetch lead context data var partnerID, teamID, companyID *int64 var name string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, ''), partner_id, team_id, company_id FROM crm_lead WHERE id = $1`, leadID, ).Scan(&name, &partnerID, &teamID, &companyID) ctx := map[string]interface{}{ "default_opportunity_id": leadID, "search_default_opportunity_id": leadID, "default_origin": name, } if partnerID != nil { ctx["default_partner_id"] = *partnerID } if teamID != nil { ctx["default_team_id"] = *teamID } if companyID != nil { ctx["default_company_id"] = *companyID } return map[string]interface{}{ "type": "ir.actions.act_window", "name": "New Quotation", "res_model": "sale.order", "view_mode": "form", "views": [][]interface{}{{nil, "form"}}, "target": "current", "context": ctx, }, nil }) // merge_opportunity: alias for action_merge_leads. m.RegisterMethod("merge_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { mergeMethod := orm.Registry.Get("crm.lead").Methods["action_merge_leads"] if mergeMethod != nil { return mergeMethod(rs, args...) } return nil, fmt.Errorf("crm.lead: action_merge_leads not found") }) // handle_partner_assignment: create or assign partner for leads. // Mirrors: odoo/addons/crm/models/crm_lead.py _handle_partner_assignment m.RegisterMethod("handle_partner_assignment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() // Optional force_partner_id from args var forcePartnerID int64 if len(args) > 0 { if pid, ok := args[0].(float64); ok { forcePartnerID = int64(pid) } } for _, id := range rs.IDs() { if forcePartnerID > 0 { env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET partner_id = $1 WHERE id = $2`, forcePartnerID, id) continue } // Check if lead already has a partner var existingPartnerID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT partner_id FROM crm_lead WHERE id = $1`, id).Scan(&existingPartnerID) if existingPartnerID != nil { continue } // Create partner from lead data var email, phone, partnerName, street, city, zip, contactName string var countryID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(email_from,''), COALESCE(phone,''), COALESCE(partner_name,''), COALESCE(street,''), COALESCE(city,''), COALESCE(zip,''), COALESCE(contact_name,''), country_id FROM crm_lead WHERE id = $1`, id, ).Scan(&email, &phone, &partnerName, &street, &city, &zip, &contactName, &countryID) name := partnerName if name == "" { name = contactName } if name == "" { name = email } if name == "" { continue // cannot create partner without any identifying info } var newPartnerID int64 err := env.Tx().QueryRow(env.Ctx(), `INSERT INTO res_partner (name, email, phone, street, city, zip, country_id, active, is_company) VALUES ($1, NULLIF($2,''), NULLIF($3,''), NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), $7, true, CASE WHEN $8 != '' THEN true ELSE false END) RETURNING id`, name, email, phone, street, city, zip, countryID, partnerName, ).Scan(&newPartnerID) if err != nil { log.Printf("warning: crm.lead handle_partner_assignment create partner failed for lead %d: %v", id, err) continue } env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET partner_id = $1 WHERE id = $2`, newPartnerID, id) } return true, nil }) // Onchange: partner_id -> populate contact/address fields from partner // Mirrors: odoo/addons/crm/models/crm_lead.py _onchange_partner_id m.RegisterOnchange("partner_id", func(env *orm.Environment, vals orm.Values) orm.Values { result := make(orm.Values) partnerID, ok := vals["partner_id"] if !ok || partnerID == nil { return result } pid, _ := partnerID.(float64) if pid == 0 { return result } var email, phone, street, city, zip, name string if err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(email,''), COALESCE(phone,''), COALESCE(street,''), COALESCE(city,''), COALESCE(zip,''), COALESCE(name,'') FROM res_partner WHERE id = $1`, int64(pid), ).Scan(&email, &phone, &street, &city, &zip, &name); err != nil { log.Printf("warning: crm.lead onchange partner_id lookup failed: %v", err) } if email != "" { result["email_from"] = email } if phone != "" { result["phone"] = phone } if street != "" { result["street"] = street } if city != "" { result["city"] = city } if zip != "" { result["zip"] = zip } if name != "" { result["partner_name"] = name } return result }) }