package models import ( "fmt" "log" "odoo-go/pkg/orm" ) // initCrmTeamExpanded extends the basic crm.team with CRM-specific fields // (pipeline dashboard, lead assignment, team configuration). // Mirrors: odoo/addons/crm/models/crm_team.py CrmTeam func initCrmTeamExpanded() { m := orm.ExtendModel("crm.team") m.AddFields( // Team configuration orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Team Leader"}), orm.One2many("crm_team_member_ids", "crm.team.member", "crm_team_id", orm.FieldOpts{ String: "Sales Team Members", }), orm.Boolean("use_leads", orm.FieldOpts{ String: "Leads", Default: true, Help: "Check this box to filter and qualify leads.", }), orm.Boolean("use_opportunities", orm.FieldOpts{ String: "Pipeline", Default: true, Help: "Check this box to manage a pipeline of opportunities.", }), orm.Many2one("alias_id", "mail.alias", orm.FieldOpts{ String: "Email Alias", Help: "Incoming emails on this alias will create leads/opportunities.", }), // Dashboard computed fields // Mirrors: _compute_opportunities_data in crm.team orm.Integer("opportunities_count", orm.FieldOpts{ String: "Number of Opportunities", Compute: "_compute_counts", Help: "Number of active opportunities in this team's pipeline.", }), orm.Monetary("opportunities_amount", orm.FieldOpts{ String: "Opportunities Revenue", Compute: "_compute_counts", CurrencyField: "currency_id", Help: "Total expected revenue of active opportunities.", }), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{ String: "Currency", }), orm.Integer("unassigned_leads_count", orm.FieldOpts{ String: "Unassigned Leads", Compute: "_compute_counts", Help: "Number of leads without a salesperson assigned.", }), // Assignment settings // Mirrors: odoo/addons/crm/models/crm_team.py assignment fields orm.Selection("assignment_domain", []orm.SelectionItem{ {Value: "manual", Label: "Manual"}, {Value: "auto", Label: "Auto Assignment"}, }, orm.FieldOpts{ String: "Assignment Method", Default: "manual", Help: "How new leads are distributed among team members.", }), orm.Integer("assignment_max", orm.FieldOpts{ String: "Max Leads per Cycle", Default: 30, Help: "Maximum leads assigned to a member per assignment cycle.", }), orm.Boolean("assignment_enabled", orm.FieldOpts{ String: "Auto-Assignment Active", Help: "Enable automatic lead assignment for this team.", }), orm.Integer("assignment_optout_count", orm.FieldOpts{ String: "Members Opted-Out", Compute: "_compute_assignment_optout", Help: "Number of members who opted out of automatic assignment.", }), ) // _compute_assignment_optout: count members who opted out of auto-assignment. // Mirrors: odoo/addons/crm/models/crm_team.py _compute_assignment_optout m.RegisterCompute("assignment_optout_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() teamID := rs.IDs()[0] var count int64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM crm_team_member WHERE crm_team_id = $1 AND active = true AND assignment_optout = true`, teamID, ).Scan(&count); err != nil { log.Printf("warning: crm.team _compute_assignment_optout query failed: %v", err) } return orm.Values{"assignment_optout_count": count}, nil }) // _compute_counts: compute dashboard KPIs for the sales team. // Mirrors: odoo/addons/crm/models/crm_team.py _compute_opportunities_data m.RegisterCompute("opportunities_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() teamID := rs.IDs()[0] var count int64 var amount float64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*), COALESCE(SUM(expected_revenue::float8), 0) FROM crm_lead WHERE team_id = $1 AND active = true AND type = 'opportunity'`, teamID, ).Scan(&count, &amount); err != nil { log.Printf("warning: crm.team _compute_counts opportunities query failed: %v", err) } var unassigned int64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM crm_lead WHERE team_id = $1 AND active = true AND user_id IS NULL`, teamID, ).Scan(&unassigned); err != nil { log.Printf("warning: crm.team _compute_counts unassigned query failed: %v", err) } return orm.Values{ "opportunities_count": count, "opportunities_amount": amount, "unassigned_leads_count": unassigned, }, nil }) // get_crm_dashboard_data: KPIs — total pipeline value, won count, lost count, conversion rate. // Mirrors: odoo/addons/crm/models/crm_team.py _compute_dashboard_data m.RegisterMethod("get_crm_dashboard_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() teamID := rs.IDs()[0] var totalPipeline float64 var wonCount, lostCount, totalOpps int64 if err := env.Tx().QueryRow(env.Ctx(), ` SELECT COALESCE(SUM(expected_revenue::float8), 0), COUNT(*) FILTER (WHERE state = 'won'), COUNT(*) FILTER (WHERE state = 'lost'), COUNT(*) FROM crm_lead WHERE team_id = $1 AND type = 'opportunity'`, teamID, ).Scan(&totalPipeline, &wonCount, &lostCount, &totalOpps); err != nil { log.Printf("warning: crm.team get_crm_dashboard_data query failed: %v", err) } conversionRate := float64(0) decided := wonCount + lostCount if decided > 0 { conversionRate = float64(wonCount) / float64(decided) * 100 } // Active pipeline (open opportunities only) var activePipeline float64 var activeCount int64 if err := env.Tx().QueryRow(env.Ctx(), ` SELECT COALESCE(SUM(expected_revenue::float8), 0), COUNT(*) FROM crm_lead WHERE team_id = $1 AND type = 'opportunity' AND active = true AND state = 'open'`, teamID, ).Scan(&activePipeline, &activeCount); err != nil { log.Printf("warning: crm.team get_crm_dashboard_data active pipeline query failed: %v", err) } // Overdue activities count var overdueCount int64 if err := env.Tx().QueryRow(env.Ctx(), ` SELECT COUNT(DISTINCT l.id) FROM crm_lead l JOIN mail_activity a ON a.res_model = 'crm.lead' AND a.res_id = l.id WHERE l.team_id = $1 AND a.date_deadline < CURRENT_DATE AND a.done = false`, teamID, ).Scan(&overdueCount); err != nil { log.Printf("warning: crm.team get_crm_dashboard_data overdue query failed: %v", err) } return map[string]interface{}{ "total_pipeline": totalPipeline, "active_pipeline": activePipeline, "active_count": activeCount, "won_count": wonCount, "lost_count": lostCount, "total_opportunities": totalOpps, "conversion_rate": conversionRate, "overdue_activities": overdueCount, }, nil }) // action_assign_leads: trigger automatic lead assignment. // Mirrors: odoo/addons/crm/models/crm_team.py action_assign_leads m.RegisterMethod("action_assign_leads", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() teamID := rs.IDs()[0] // Fetch active members with remaining capacity rows, err := env.Tx().Query(env.Ctx(), ` SELECT m.user_id, COALESCE(m.assignment_max, 30) AS max_leads, COALESCE((SELECT COUNT(*) FROM crm_lead l WHERE l.user_id = m.user_id AND l.team_id = $1 AND l.active = true AND l.create_date >= date_trunc('month', CURRENT_DATE)), 0) AS current_count FROM crm_team_member m WHERE m.crm_team_id = $1 AND m.active = true ORDER BY current_count ASC`, teamID) if err != nil { return nil, fmt.Errorf("action_assign_leads: query members: %w", err) } defer rows.Close() type memberCap struct { userID int64 capacity int64 } var members []memberCap for rows.Next() { var uid int64 var maxLeads, current int64 if err := rows.Scan(&uid, &maxLeads, ¤t); err != nil { return nil, err } if remaining := maxLeads - current; remaining > 0 { members = append(members, memberCap{userID: uid, capacity: remaining}) } } if len(members) == 0 { return map[string]interface{}{"assigned": 0}, nil } // Fetch unassigned leads leadRows, err := env.Tx().Query(env.Ctx(), ` SELECT id FROM crm_lead WHERE team_id = $1 AND active = true AND user_id IS NULL ORDER BY priority DESC, id ASC`, teamID) if err != nil { return nil, fmt.Errorf("action_assign_leads: query leads: %w", err) } defer leadRows.Close() var assigned int64 memberIdx := 0 for leadRows.Next() { var leadID int64 if err := leadRows.Scan(&leadID); err != nil { return nil, err } if memberIdx >= len(members) { break } mc := &members[memberIdx] if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_lead SET user_id = $1 WHERE id = $2`, mc.userID, leadID); err != nil { log.Printf("warning: crm.team action_assign_leads update failed for lead %d: %v", leadID, err) } assigned++ mc.capacity-- if mc.capacity <= 0 { memberIdx++ } } return map[string]interface{}{"assigned": assigned}, nil }) // action_open_opportunities: return window action for team's opportunities. // Mirrors: odoo/addons/crm/models/crm_team.py action_open_opportunities m.RegisterMethod("action_open_opportunities", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { return map[string]interface{}{ "type": "ir.actions.act_window", "name": "Opportunities", "res_model": "crm.lead", "view_mode": "kanban,tree,form", "domain": fmt.Sprintf("[('team_id','=',%d),('type','=','opportunity')]", rs.IDs()[0]), "context": map[string]interface{}{"default_team_id": rs.IDs()[0], "default_type": "opportunity"}, }, nil }) // action_open_unassigned: return window action for unassigned leads. // Mirrors: odoo/addons/crm/models/crm_team.py (dashboard actions) m.RegisterMethod("action_open_unassigned", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { return map[string]interface{}{ "type": "ir.actions.act_window", "name": "Unassigned Leads", "res_model": "crm.lead", "view_mode": "tree,form", "domain": fmt.Sprintf("[('team_id','=',%d),('user_id','=',False)]", rs.IDs()[0]), "context": map[string]interface{}{"default_team_id": rs.IDs()[0]}, }, nil }) } // initCrmTeamMember registers the crm.team.member model. // Mirrors: odoo/addons/crm/models/crm_team_member.py CrmTeamMember func initCrmTeamMember() { m := orm.NewModel("crm.team.member", orm.ModelOpts{ Description: "Sales Team Member", }) m.AddFields( orm.Many2one("crm_team_id", "crm.team", orm.FieldOpts{ String: "Sales Team", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, }), orm.Many2one("user_id", "res.users", orm.FieldOpts{ String: "Salesperson", Required: true, Index: true, }), orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), orm.Selection("role", []orm.SelectionItem{ {Value: "member", Label: "Member"}, {Value: "leader", Label: "Team Leader"}, {Value: "manager", Label: "Sales Manager"}, }, orm.FieldOpts{ String: "Role", Default: "member", Help: "Role of this member within the sales team.", }), orm.Float("assignment_max", orm.FieldOpts{ String: "Max Leads", Help: "Maximum number of leads this member should be assigned per month.", Default: float64(30), }), orm.Integer("lead_month_count", orm.FieldOpts{ String: "Leads This Month", Compute: "_compute_lead_count", Help: "Number of leads assigned to this member in the current month.", }), orm.Boolean("assignment_optout", orm.FieldOpts{ String: "Opt-Out", Help: "If checked, this member will not receive automatically assigned leads.", }), orm.Many2one("company_id", "res.company", orm.FieldOpts{ String: "Company", Help: "Company of the sales team.", }), ) // _compute_lead_count: count leads assigned to this member this month. // Mirrors: odoo/addons/crm/models/crm_team_member.py _compute_lead_month_count m.RegisterCompute("lead_month_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() memberID := rs.IDs()[0] var userID, teamID int64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT user_id, crm_team_id FROM crm_team_member WHERE id = $1`, memberID, ).Scan(&userID, &teamID); err != nil { log.Printf("warning: crm.team.member _compute_lead_count member lookup failed: %v", err) } var count int64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM crm_lead WHERE user_id = $1 AND team_id = $2 AND active = true AND create_date >= date_trunc('month', CURRENT_DATE)`, userID, teamID, ).Scan(&count); err != nil { log.Printf("warning: crm.team.member _compute_lead_count query failed: %v", err) } return orm.Values{"lead_month_count": count}, nil }) // SQL constraint: one member per team m.AddSQLConstraint( "unique_member_per_team", "UNIQUE(crm_team_id, user_id)", "A user can only be a member of a team once.", ) // action_assign_to_team: add a user to a team as a member. // Mirrors: odoo/addons/crm/models/crm_team_member.py _assign_to_team m.RegisterMethod("action_assign_to_team", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() if len(args) < 2 { return nil, fmt.Errorf("user_id and team_id required") } var userID, teamID int64 switch v := args[0].(type) { case float64: userID = int64(v) case int64: userID = v case int: userID = int64(v) } switch v := args[1].(type) { case float64: teamID = int64(v) case int64: teamID = v case int: teamID = int64(v) } // Check if already a member var exists bool env.Tx().QueryRow(env.Ctx(), `SELECT EXISTS(SELECT 1 FROM crm_team_member WHERE user_id = $1 AND crm_team_id = $2)`, userID, teamID, ).Scan(&exists) if exists { // Ensure active if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_team_member SET active = true WHERE user_id = $1 AND crm_team_id = $2`, userID, teamID); err != nil { return nil, fmt.Errorf("action_assign_to_team reactivate: %w", err) } return true, nil } var newID int64 if err := env.Tx().QueryRow(env.Ctx(), `INSERT INTO crm_team_member (user_id, crm_team_id, active, role) VALUES ($1, $2, true, 'member') RETURNING id`, userID, teamID, ).Scan(&newID); err != nil { return nil, fmt.Errorf("action_assign_to_team insert: %w", err) } return map[string]interface{}{"id": newID}, nil }) // action_remove_from_team: deactivate membership (soft delete). // Mirrors: odoo/addons/crm/models/crm_team_member.py unlink m.RegisterMethod("action_remove_from_team", 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_team_member SET active = false WHERE id = $1`, id); err != nil { log.Printf("warning: crm.team.member action_remove_from_team failed for member %d: %v", id, err) } } return true, nil }) // action_set_role: change the role of a team member. // Mirrors: odoo/addons/crm/models/crm_team_member.py write m.RegisterMethod("action_set_role", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("role value required ('member', 'leader', 'manager')") } role, ok := args[0].(string) if !ok { return nil, fmt.Errorf("role must be a string") } env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE crm_team_member SET role = $1 WHERE id = $2`, role, id); err != nil { log.Printf("warning: crm.team.member action_set_role failed for member %d: %v", id, err) } } return true, nil }) }