package models import ( "encoding/json" "fmt" "time" "odoo-go/pkg/orm" ) // initProjectProjectExtension extends project.project with additional fields and methods. // Mirrors: odoo/addons/project/models/project_project.py (additional fields) // odoo/addons/hr_timesheet/models/project_project.py (timesheet fields) func initProjectProjectExtension() { proj := orm.ExtendModel("project.project") proj.AddFields( orm.One2many("update_ids", "project.update", "project_id", orm.FieldOpts{ String: "Updates", }), orm.One2many("milestone_ids", "project.milestone", "project_id", orm.FieldOpts{ String: "Milestones", }), orm.Selection("last_update_status", []orm.SelectionItem{ {Value: "on_track", Label: "On Track"}, {Value: "at_risk", Label: "At Risk"}, {Value: "off_track", Label: "Off Track"}, {Value: "on_hold", Label: "On Hold"}, }, orm.FieldOpts{String: "Status", Compute: "_compute_last_update_status"}), orm.Float("total_timesheet_hours", orm.FieldOpts{ String: "Total Timesheet Hours", Compute: "_compute_total_timesheet_hours", }), orm.Boolean("allow_timesheets", orm.FieldOpts{String: "Timesheets", Default: true}), orm.Integer("update_count", orm.FieldOpts{ String: "Update Count", Compute: "_compute_update_count", }), orm.Integer("milestone_count", orm.FieldOpts{ String: "Milestone Count", Compute: "_compute_milestone_count", }), orm.Integer("milestone_count_reached", orm.FieldOpts{ String: "Milestones Reached", Compute: "_compute_milestone_count_reached", }), orm.Float("allocated_hours", orm.FieldOpts{String: "Allocated Hours"}), orm.Float("remaining_hours", orm.FieldOpts{ String: "Remaining Hours", Compute: "_compute_remaining_hours", }), orm.Float("progress", orm.FieldOpts{ String: "Progress (%)", Compute: "_compute_progress", }), orm.Selection("privacy_visibility", []orm.SelectionItem{ {Value: "followers", Label: "Invited internal users"}, {Value: "employees", Label: "All internal users"}, {Value: "portal", Label: "Invited portal users and all internal users"}, }, orm.FieldOpts{String: "Visibility", Default: "portal"}), orm.Many2one("analytic_account_id", "account.analytic.account", orm.FieldOpts{ String: "Analytic Account", }), orm.Many2many("tag_ids", "project.tags", orm.FieldOpts{String: "Tags"}), orm.One2many("task_ids", "project.task", "project_id", orm.FieldOpts{String: "Tasks"}), orm.Float("progress_percentage", orm.FieldOpts{ String: "Progress Percentage", Compute: "_compute_progress_percentage", }), orm.Text("workload_by_user", orm.FieldOpts{ String: "Workload by User", Compute: "_compute_workload_by_user", }), orm.Float("planned_budget", orm.FieldOpts{String: "Planned Budget"}), orm.Float("remaining_budget", orm.FieldOpts{ String: "Remaining Budget", Compute: "_compute_remaining_budget", }), orm.One2many("sharing_ids", "project.sharing", "project_id", orm.FieldOpts{ String: "Sharing Entries", }), orm.Datetime("planned_date_start", orm.FieldOpts{ String: "Planned Start Date", Compute: "_compute_planned_date_start", }), orm.Datetime("planned_date_end", orm.FieldOpts{ String: "Planned End Date", Compute: "_compute_planned_date_end", }), orm.One2many("checklist_task_ids", "project.task", "project_id", orm.FieldOpts{ String: "Tasks with Checklists", }), ) // -- _compute_task_count -- // Mirrors: odoo/addons/project/models/project_project.py Project._compute_task_count() proj.RegisterCompute("task_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_task WHERE project_id = $1 AND active = true`, projID).Scan(&count) return orm.Values{"task_count": count}, nil }) // -- _compute_last_update_status -- // Mirrors: odoo/addons/project/models/project_project.py Project._compute_last_update_status() proj.RegisterCompute("last_update_status", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var status *string env.Tx().QueryRow(env.Ctx(), `SELECT status FROM project_update WHERE project_id = $1 ORDER BY date DESC, id DESC LIMIT 1`, projID).Scan(&status) if status != nil { return orm.Values{"last_update_status": *status}, nil } return orm.Values{"last_update_status": "on_track"}, nil }) // -- _compute_total_timesheet_hours -- // Mirrors: odoo/addons/hr_timesheet/models/project_project.py Project._compute_total_timesheet_time() proj.RegisterCompute("total_timesheet_hours", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var hours float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line WHERE project_id = $1`, projID).Scan(&hours) return orm.Values{"total_timesheet_hours": hours}, nil }) // -- _compute_update_count -- proj.RegisterCompute("update_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_update WHERE project_id = $1`, projID).Scan(&count) return orm.Values{"update_count": count}, nil }) // -- _compute_milestone_count -- proj.RegisterCompute("milestone_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_milestone WHERE project_id = $1`, projID).Scan(&count) return orm.Values{"milestone_count": count}, nil }) // -- _compute_milestone_count_reached -- proj.RegisterCompute("milestone_count_reached", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_milestone WHERE project_id = $1 AND is_reached = true`, projID).Scan(&count) return orm.Values{"milestone_count_reached": count}, nil }) // -- _compute_remaining_hours -- proj.RegisterCompute("remaining_hours", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var allocated, timesheet float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(allocated_hours, 0) FROM project_project WHERE id = $1`, projID).Scan(&allocated) env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line WHERE project_id = $1`, projID).Scan(×heet) remaining := allocated - timesheet if remaining < 0 { remaining = 0 } return orm.Values{"remaining_hours": remaining}, nil }) // -- _compute_progress -- proj.RegisterCompute("progress", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var allocated, timesheet float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(allocated_hours, 0) FROM project_project WHERE id = $1`, projID).Scan(&allocated) env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line WHERE project_id = $1`, projID).Scan(×heet) pct := float64(0) if allocated > 0 { pct = timesheet / allocated * 100 if pct > 100 { pct = 100 } } return orm.Values{"progress": pct}, nil }) // -- _compute_progress_percentage: done_tasks / total_tasks * 100 -- // Mirrors: odoo/addons/project/models/project_project.py Project._compute_progress_percentage() proj.RegisterCompute("progress_percentage", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var total, done int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_task WHERE project_id = $1 AND active = true`, projID).Scan(&total) env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_task WHERE project_id = $1 AND active = true AND state = 'done'`, projID).Scan(&done) pct := float64(0) if total > 0 { pct = float64(done) / float64(total) * 100 } return orm.Values{"progress_percentage": pct}, nil }) // -- _compute_workload_by_user: hours planned per user from tasks -- // Mirrors: odoo/addons/project/models/project_project.py Project._compute_workload_by_user() proj.RegisterCompute("workload_by_user", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] rows, err := env.Tx().Query(env.Ctx(), ` SELECT ru.id, COALESCE(rp.name, 'Unknown') AS user_name, COALESCE(SUM(pt.planned_hours), 0) AS planned_hours FROM project_task pt JOIN project_task_res_users_rel rel ON rel.project_task_id = pt.id JOIN res_users ru ON ru.id = rel.res_users_id LEFT JOIN res_partner rp ON rp.id = ru.partner_id WHERE pt.project_id = $1 AND pt.active = true GROUP BY ru.id, rp.name ORDER BY planned_hours DESC`, projID) if err != nil { return orm.Values{"workload_by_user": "[]"}, nil } defer rows.Close() var workload []map[string]interface{} for rows.Next() { var userID int64 var userName string var plannedHours float64 if err := rows.Scan(&userID, &userName, &plannedHours); err != nil { continue } workload = append(workload, map[string]interface{}{ "user_id": userID, "user_name": userName, "planned_hours": plannedHours, }) } if workload == nil { workload = []map[string]interface{}{} } data, _ := json.Marshal(workload) return orm.Values{"workload_by_user": string(data)}, nil }) // -- _compute_remaining_budget: planned_budget - SUM(analytic_line.amount) -- // Mirrors: odoo/addons/project/models/project_project.py Project._compute_remaining_budget() proj.RegisterCompute("remaining_budget", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var plannedBudget float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(planned_budget, 0) FROM project_project WHERE id = $1`, projID).Scan(&plannedBudget) var spent float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(amount), 0) FROM account_analytic_line WHERE project_id = $1`, projID).Scan(&spent) remaining := plannedBudget - spent return orm.Values{"remaining_budget": remaining}, nil }) // -- _compute_planned_date_start: earliest planned_date_start from tasks -- // Mirrors: odoo/addons/project/models/project_project.py Project._compute_planned_date_start() proj.RegisterCompute("planned_date_start", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var startDate *time.Time env.Tx().QueryRow(env.Ctx(), `SELECT MIN(planned_date_start) FROM project_task WHERE project_id = $1 AND active = true AND planned_date_start IS NOT NULL`, projID).Scan(&startDate) if startDate != nil { return orm.Values{"planned_date_start": startDate.Format(time.RFC3339)}, nil } return orm.Values{"planned_date_start": nil}, nil }) // -- _compute_planned_date_end: latest planned_date_end from tasks -- // Mirrors: odoo/addons/project/models/project_project.py Project._compute_planned_date_end() proj.RegisterCompute("planned_date_end", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() projID := rs.IDs()[0] var endDate *time.Time env.Tx().QueryRow(env.Ctx(), `SELECT MAX(planned_date_end) FROM project_task WHERE project_id = $1 AND active = true AND planned_date_end IS NOT NULL`, projID).Scan(&endDate) if endDate != nil { return orm.Values{"planned_date_end": endDate.Format(time.RFC3339)}, nil } return orm.Values{"planned_date_end": nil}, nil }) // action_view_tasks: Open tasks of this project. // Mirrors: odoo/addons/project/models/project_project.py Project.action_view_tasks() proj.RegisterMethod("action_view_tasks", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { projID := rs.IDs()[0] return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "project.task", "view_mode": "kanban,list,form", "views": [][]interface{}{{nil, "kanban"}, {nil, "list"}, {nil, "form"}}, "domain": []interface{}{[]interface{}{"project_id", "=", projID}}, "target": "current", "name": "Tasks", "context": map[string]interface{}{"default_project_id": projID}, }, nil }) // action_view_milestones: Open milestones of this project. // Mirrors: odoo/addons/project/models/project_project.py Project.action_view_milestones() proj.RegisterMethod("action_view_milestones", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { projID := rs.IDs()[0] return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "project.milestone", "view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, "domain": []interface{}{[]interface{}{"project_id", "=", projID}}, "target": "current", "name": "Milestones", "context": map[string]interface{}{"default_project_id": projID}, }, nil }) // action_view_updates: Open project updates. proj.RegisterMethod("action_view_updates", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { projID := rs.IDs()[0] return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "project.update", "view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, "domain": []interface{}{[]interface{}{"project_id", "=", projID}}, "target": "current", "name": "Updates", "context": map[string]interface{}{"default_project_id": projID}, }, nil }) // action_view_timesheets: Open timesheet entries for this project. // Mirrors: odoo/addons/hr_timesheet/models/project_project.py proj.RegisterMethod("action_view_timesheets", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { projID := rs.IDs()[0] return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "account.analytic.line", "view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, "domain": []interface{}{[]interface{}{"project_id", "=", projID}}, "target": "current", "name": "Timesheets", "context": map[string]interface{}{"default_project_id": projID}, }, nil }) // action_get_project_profitability: Get profitability data. // Mirrors: odoo/addons/project/models/project_profitability.py proj.RegisterMethod("action_get_project_profitability", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() projID := rs.IDs()[0] // Compute costs from timesheets (hours * employee cost) var timesheetCost float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(aal.unit_amount * COALESCE(he.timesheet_cost, 0)), 0) FROM account_analytic_line aal LEFT JOIN hr_employee he ON he.id = aal.employee_id WHERE aal.project_id = $1`, projID).Scan(×heetCost) // Compute revenue from linked SOs (if project has partner) var revenue float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(so.amount_total::float8), 0) FROM sale_order so WHERE so.state IN ('sale', 'done') AND so.partner_id = (SELECT partner_id FROM project_project WHERE id = $1) AND so.partner_id IS NOT NULL`, projID).Scan(&revenue) return map[string]interface{}{ "timesheet_cost": timesheetCost, "revenue": revenue, "margin": revenue - timesheetCost, }, nil }) } // initProjectTaskExtension extends project.task with additional fields and methods. // Mirrors: odoo/addons/project/models/project_task.py (additional fields) // odoo/addons/hr_timesheet/models/project_task.py (timesheet fields) func initProjectTaskExtension() { task := orm.ExtendModel("project.task") // Note: parent_id, child_ids, milestone_id, tag_ids, depend_ids already exist // Note: planned_date_start, planned_date_end are defined in project.go task.AddFields( orm.One2many("checklist_ids", "project.task.checklist", "task_id", orm.FieldOpts{ String: "Checklist", }), orm.Integer("checklist_count", orm.FieldOpts{ String: "Checklist Items", Compute: "_compute_checklist_count", }), orm.Float("checklist_progress", orm.FieldOpts{ String: "Checklist Progress (%)", Compute: "_compute_checklist_progress", }), orm.Float("planned_hours", orm.FieldOpts{String: "Initially Planned Hours"}), orm.Float("effective_hours", orm.FieldOpts{ String: "Hours Spent", Compute: "_compute_effective_hours", }), orm.Float("remaining_hours", orm.FieldOpts{ String: "Remaining Hours", Compute: "_compute_remaining_hours", }), orm.Float("progress", orm.FieldOpts{ String: "Progress (%)", Compute: "_compute_progress", }), orm.Float("subtask_effective_hours", orm.FieldOpts{ String: "Sub-task Hours", Compute: "_compute_subtask_effective_hours", }), orm.Float("total_hours_spent", orm.FieldOpts{ String: "Total Hours", Compute: "_compute_total_hours_spent", }), orm.Date("date_last_stage_update", orm.FieldOpts{String: "Last Stage Update"}), orm.One2many("timesheet_ids", "account.analytic.line", "task_id", orm.FieldOpts{ String: "Timesheets", }), orm.Integer("timesheet_count", orm.FieldOpts{ String: "Timesheet Count", Compute: "_compute_timesheet_count", }), orm.Char("email_from", orm.FieldOpts{String: "Email From"}), orm.Boolean("allow_timesheets", orm.FieldOpts{String: "Allow Timesheets"}), orm.Integer("working_days_close", orm.FieldOpts{ String: "Working Days to Close", Compute: "_compute_working_days_close", }), orm.Integer("working_days_open", orm.FieldOpts{ String: "Working Days to Assign", Compute: "_compute_working_days_open", }), orm.Float("overtime", orm.FieldOpts{ String: "Overtime", Compute: "_compute_overtime", }), orm.Many2one("sale_order_id", "sale.order", orm.FieldOpts{String: "Sales Order"}), orm.Many2one("sale_line_id", "sale.order.line", orm.FieldOpts{String: "Sales Order Item"}), orm.Many2one("analytic_account_id", "account.analytic.account", orm.FieldOpts{ String: "Analytic Account", }), ) // -- _compute_effective_hours: Sum of timesheet hours on this task. -- // Mirrors: odoo/addons/hr_timesheet/models/project_task.py Task._compute_effective_hours() task.RegisterCompute("effective_hours", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var hours float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line WHERE task_id = $1`, taskID).Scan(&hours) return orm.Values{"effective_hours": hours}, nil }) // -- _compute_remaining_hours -- // Mirrors: odoo/addons/hr_timesheet/models/project_task.py Task._compute_remaining_hours() task.RegisterCompute("remaining_hours", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var planned, effective float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(planned_hours, 0) FROM project_task WHERE id = $1`, taskID).Scan(&planned) env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line WHERE task_id = $1`, taskID).Scan(&effective) remaining := planned - effective return orm.Values{"remaining_hours": remaining}, nil }) // -- _compute_progress -- // Mirrors: odoo/addons/hr_timesheet/models/project_task.py Task._compute_progress() task.RegisterCompute("progress", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var planned float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(planned_hours, 0) FROM project_task WHERE id = $1`, taskID).Scan(&planned) if planned <= 0 { return orm.Values{"progress": float64(0)}, nil } var effective float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line WHERE task_id = $1`, taskID).Scan(&effective) pct := effective / planned * 100 if pct > 100 { pct = 100 } return orm.Values{"progress": pct}, nil }) // -- _compute_subtask_effective_hours -- task.RegisterCompute("subtask_effective_hours", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var hours float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(aal.unit_amount), 0) FROM account_analytic_line aal JOIN project_task pt ON pt.id = aal.task_id WHERE pt.parent_id = $1`, taskID).Scan(&hours) return orm.Values{"subtask_effective_hours": hours}, nil }) // -- _compute_total_hours_spent: own hours + subtask hours -- task.RegisterCompute("total_hours_spent", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var ownHours, subtaskHours float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line WHERE task_id = $1`, taskID).Scan(&ownHours) env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(aal.unit_amount), 0) FROM account_analytic_line aal JOIN project_task pt ON pt.id = aal.task_id WHERE pt.parent_id = $1`, taskID).Scan(&subtaskHours) return orm.Values{"total_hours_spent": ownHours + subtaskHours}, nil }) // -- _compute_timesheet_count -- task.RegisterCompute("timesheet_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM account_analytic_line WHERE task_id = $1`, taskID).Scan(&count) return orm.Values{"timesheet_count": count}, nil }) // -- _compute_working_days_close -- // Mirrors: odoo/addons/project/models/project_task.py Task._compute_elapsed() task.RegisterCompute("working_days_close", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var state string var createDate *time.Time env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(state, 'open'), create_date FROM project_task WHERE id = $1`, taskID).Scan(&state, &createDate) if state != "done" || createDate == nil { return orm.Values{"working_days_close": 0}, nil } days := int(time.Since(*createDate).Hours() / 24) return orm.Values{"working_days_close": days}, nil }) // -- _compute_working_days_open -- task.RegisterCompute("working_days_open", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var dateAssign *time.Time var createDate *time.Time env.Tx().QueryRow(env.Ctx(), `SELECT date_assign, create_date FROM project_task WHERE id = $1`, taskID).Scan(&dateAssign, &createDate) if dateAssign == nil || createDate == nil { return orm.Values{"working_days_open": 0}, nil } days := int(dateAssign.Sub(*createDate).Hours() / 24) if days < 0 { days = 0 } return orm.Values{"working_days_open": days}, nil }) // -- _compute_overtime -- task.RegisterCompute("overtime", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var planned, effective float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(planned_hours, 0) FROM project_task WHERE id = $1`, taskID).Scan(&planned) env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line WHERE task_id = $1`, taskID).Scan(&effective) overtime := effective - planned if overtime < 0 { overtime = 0 } return orm.Values{"overtime": overtime}, nil }) // action_view_timesheets: Open timesheets for this task. task.RegisterMethod("action_view_timesheets", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { taskID := rs.IDs()[0] return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "account.analytic.line", "view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, "domain": []interface{}{[]interface{}{"task_id", "=", taskID}}, "target": "current", "name": "Timesheets", "context": map[string]interface{}{"default_task_id": taskID}, }, nil }) // action_open_parent_task: Open parent task form. task.RegisterMethod("action_open_parent_task", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() taskID := rs.IDs()[0] var parentID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT parent_id FROM project_task WHERE id = $1`, taskID).Scan(&parentID) if parentID == nil { return nil, fmt.Errorf("task has no parent") } return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "project.task", "res_id": *parentID, "view_mode": "form", "target": "current", }, nil }) // action_subtask_view: Open subtasks list. task.RegisterMethod("action_subtask_view", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { taskID := rs.IDs()[0] return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "project.task", "view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, "domain": []interface{}{[]interface{}{"parent_id", "=", taskID}}, "target": "current", "name": "Sub-tasks", "context": map[string]interface{}{"default_parent_id": taskID}, }, nil }) // action_assign_to_me: Assign the task to the current user. task.RegisterMethod("action_assign_to_me", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() uid := env.UID() for _, taskID := range rs.IDs() { env.Tx().Exec(env.Ctx(), `INSERT INTO project_task_res_users_rel (project_task_id, res_users_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, taskID, uid) env.Tx().Exec(env.Ctx(), `UPDATE project_task SET date_assign = NOW() WHERE id = $1 AND date_assign IS NULL`, taskID) } return true, nil }) // action_set_stage: Change the task stage. // Mirrors: odoo/addons/project/models/project_task.py Task.write() stage tracking task.RegisterMethod("action_set_stage", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("stage_id required") } env := rs.Env() stageID, _ := args[0].(float64) for _, taskID := range rs.IDs() { env.Tx().Exec(env.Ctx(), `UPDATE project_task SET stage_id = $1, date_last_stage_update = NOW() WHERE id = $2`, int64(stageID), taskID) } return true, nil }) // -- _compute_checklist_count -- task.RegisterCompute("checklist_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_task_checklist WHERE task_id = $1`, taskID).Scan(&count) return orm.Values{"checklist_count": count}, nil }) // -- _compute_checklist_progress -- task.RegisterCompute("checklist_progress", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() taskID := rs.IDs()[0] var total, done int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_task_checklist WHERE task_id = $1`, taskID).Scan(&total) env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_task_checklist WHERE task_id = $1 AND is_done = true`, taskID).Scan(&done) pct := float64(0) if total > 0 { pct = float64(done) / float64(total) * 100 } return orm.Values{"checklist_progress": pct}, nil }) // action_schedule_task: Create a calendar.event from task dates. // Mirrors: odoo/addons/project/models/project_task.py Task.action_schedule_task() task.RegisterMethod("action_schedule_task", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() taskID := rs.IDs()[0] var name string var plannedStart, plannedEnd *time.Time var deadline *time.Time var projectID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT name, planned_date_start, planned_date_end, date_deadline, project_id FROM project_task WHERE id = $1`, taskID).Scan(&name, &plannedStart, &plannedEnd, &deadline, &projectID) // Determine start/stop for the calendar event now := time.Now() start := now stop := now.Add(time.Hour) if plannedStart != nil { start = *plannedStart } if plannedEnd != nil { stop = *plannedEnd } else if deadline != nil { stop = *deadline } // Ensure stop is after start if !stop.After(start) { stop = start.Add(time.Hour) } var eventID int64 err := env.Tx().QueryRow(env.Ctx(), `INSERT INTO calendar_event (name, start, stop, user_id, active, state, create_uid, create_date, write_uid, write_date) VALUES ($1, $2, $3, $4, true, 'draft', $4, NOW(), $4, NOW()) RETURNING id`, fmt.Sprintf("[Task] %s", name), start, stop, env.UID()).Scan(&eventID) if err != nil { return nil, fmt.Errorf("project.task: schedule %d: %w", taskID, err) } return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "calendar.event", "res_id": eventID, "view_mode": "form", "target": "current", "name": "Scheduled Event", }, nil }) // _compute_critical_path: Find tasks with dependencies that determine the longest path. // Mirrors: odoo/addons/project/models/project_task.py Task._compute_critical_path() // Returns the critical path as a JSON array of task IDs for the project. task.RegisterMethod("_compute_critical_path", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() taskID := rs.IDs()[0] // Get the project_id for this task var projectID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT project_id FROM project_task WHERE id = $1`, taskID).Scan(&projectID) if projectID == nil { return map[string]interface{}{"critical_path": []int64{}, "longest_duration": float64(0)}, nil } // Load all tasks and dependencies for this project type taskNode struct { ID int64 PlannedHours float64 DependIDs []int64 } taskRows, err := env.Tx().Query(env.Ctx(), `SELECT id, COALESCE(planned_hours, 0) FROM project_task WHERE project_id = $1 AND active = true`, *projectID) if err != nil { return nil, fmt.Errorf("critical_path: query tasks: %w", err) } defer taskRows.Close() nodes := make(map[int64]*taskNode) for taskRows.Next() { var n taskNode if err := taskRows.Scan(&n.ID, &n.PlannedHours); err != nil { continue } nodes[n.ID] = &n } // Load dependencies (task depends on depend_id, meaning depend_id must finish first) depRows, err := env.Tx().Query(env.Ctx(), `SELECT pt.id, rel.project_task_id2 FROM project_task pt JOIN project_task_project_task_rel rel ON rel.project_task_id1 = pt.id WHERE pt.project_id = $1 AND pt.active = true`, *projectID) if err != nil { return nil, fmt.Errorf("critical_path: query deps: %w", err) } defer depRows.Close() for depRows.Next() { var tid, depID int64 if err := depRows.Scan(&tid, &depID); err != nil { continue } if n, ok := nodes[tid]; ok { n.DependIDs = append(n.DependIDs, depID) } } // Compute longest path using dynamic programming (topological order) // dist[id] = longest path ending at id dist := make(map[int64]float64) prev := make(map[int64]int64) var visited map[int64]bool var dfs func(id int64) float64 visited = make(map[int64]bool) var inStack map[int64]bool inStack = make(map[int64]bool) dfs = func(id int64) float64 { if v, ok := dist[id]; ok && visited[id] { return v } visited[id] = true inStack[id] = true node := nodes[id] if node == nil { dist[id] = 0 inStack[id] = false return 0 } maxPredDist := float64(0) bestPred := int64(0) for _, depID := range node.DependIDs { if inStack[depID] { continue // skip circular dependencies } d := dfs(depID) if d > maxPredDist { maxPredDist = d bestPred = depID } } dist[id] = maxPredDist + node.PlannedHours if bestPred > 0 { prev[id] = bestPred } inStack[id] = false return dist[id] } // Compute distances for all tasks for id := range nodes { if !visited[id] { dfs(id) } } // Find the task with the longest path var maxDist float64 var endTaskID int64 for id, d := range dist { if d > maxDist { maxDist = d endTaskID = id } } // Reconstruct the critical path var path []int64 for cur := endTaskID; cur != 0; cur = prev[cur] { path = append([]int64{cur}, path...) if _, ok := prev[cur]; !ok { break } } return map[string]interface{}{ "critical_path": path, "longest_duration": maxDist, }, nil }) // _check_task_dependencies: Validate no circular dependencies in depend_ids. // Mirrors: odoo/addons/project/models/project_task.py Task._check_task_dependencies() task.AddConstraint(func(rs *orm.Recordset) error { env := rs.Env() for _, taskID := range rs.IDs() { // BFS/DFS to detect cycles starting from this task visited := make(map[int64]bool) queue := []int64{taskID} for len(queue) > 0 { current := queue[0] queue = queue[1:] // Get dependencies of current task rows, err := env.Tx().Query(env.Ctx(), `SELECT project_task_id2 FROM project_task_project_task_rel WHERE project_task_id1 = $1`, current) if err != nil { continue } for rows.Next() { var depID int64 if err := rows.Scan(&depID); err != nil { continue } if depID == taskID { rows.Close() return fmt.Errorf("circular dependency detected: task %d depends on itself through task %d", taskID, current) } if !visited[depID] { visited[depID] = true queue = append(queue, depID) } } rows.Close() } } return nil }) } // initProjectMilestoneExtension extends project.milestone with additional fields. // Mirrors: odoo/addons/project/models/project_milestone.py (additional fields) func initProjectMilestoneExtension() { ms := orm.ExtendModel("project.milestone") ms.AddFields( orm.Date("reached_date", orm.FieldOpts{String: "Reached Date"}), orm.Integer("task_count", orm.FieldOpts{ String: "Task Count", Compute: "_compute_task_count", }), orm.Float("progress", orm.FieldOpts{ String: "Progress (%)", Compute: "_compute_progress", }), orm.Integer("color", orm.FieldOpts{String: "Color Index"}), orm.Many2one("sale_line_id", "sale.order.line", orm.FieldOpts{String: "Sales Order Item"}), ) // action_toggle_reached: Toggle the reached status. ms.RegisterMethod("action_toggle_reached", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, msID := range rs.IDs() { var isReached bool env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(is_reached, false) FROM project_milestone WHERE id = $1`, msID).Scan(&isReached) if isReached { env.Tx().Exec(env.Ctx(), `UPDATE project_milestone SET is_reached = false, reached_date = NULL WHERE id = $1`, msID) } else { env.Tx().Exec(env.Ctx(), `UPDATE project_milestone SET is_reached = true, reached_date = CURRENT_DATE WHERE id = $1`, msID) } } return true, nil }) // _compute_task_count ms.RegisterCompute("task_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() msID := rs.IDs()[0] var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_task WHERE milestone_id = $1`, msID).Scan(&count) return orm.Values{"task_count": count}, nil }) // _compute_progress: % of tasks that are done out of total linked tasks. ms.RegisterCompute("progress", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() msID := rs.IDs()[0] var total, done int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_task WHERE milestone_id = $1`, msID).Scan(&total) env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM project_task WHERE milestone_id = $1 AND state = 'done'`, msID).Scan(&done) pct := float64(0) if total > 0 { pct = float64(done) / float64(total) * 100 } return orm.Values{"progress": pct}, nil }) } // initProjectTaskRecurrence registers project.task.recurrence. // Mirrors: odoo/addons/project/models/project_task_recurrence.py func initProjectTaskRecurrence() { m := orm.NewModel("project.task.recurrence", orm.ModelOpts{ Description: "Task Recurrence", }) m.AddFields( orm.Many2many("task_ids", "project.task", orm.FieldOpts{String: "Tasks"}), orm.Selection("repeat_interval", []orm.SelectionItem{ {Value: "daily", Label: "Days"}, {Value: "weekly", Label: "Weeks"}, {Value: "monthly", Label: "Months"}, {Value: "yearly", Label: "Years"}, }, orm.FieldOpts{String: "Repeat Every", Default: "weekly"}), orm.Integer("repeat_number", orm.FieldOpts{String: "Repeat", Default: 1}), orm.Selection("repeat_type", []orm.SelectionItem{ {Value: "forever", Label: "Forever"}, {Value: "until", Label: "Until"}, {Value: "after", Label: "Number of Repetitions"}, }, orm.FieldOpts{String: "Until", Default: "forever"}), orm.Date("repeat_until", orm.FieldOpts{String: "End Date"}), orm.Integer("repeat_on_month", orm.FieldOpts{String: "Day of Month", Default: 1}), orm.Boolean("mon", orm.FieldOpts{String: "Monday"}), orm.Boolean("tue", orm.FieldOpts{String: "Tuesday"}), orm.Boolean("wed", orm.FieldOpts{String: "Wednesday"}), orm.Boolean("thu", orm.FieldOpts{String: "Thursday"}), orm.Boolean("fri", orm.FieldOpts{String: "Friday"}), orm.Boolean("sat", orm.FieldOpts{String: "Saturday"}), orm.Boolean("sun", orm.FieldOpts{String: "Sunday"}), orm.Integer("recurrence_left", orm.FieldOpts{String: "Recurrences Left"}), orm.Date("next_recurrence_date", orm.FieldOpts{String: "Next Recurrence Date"}), ) } // initProjectTaskRecurrenceExtension extends project.task.recurrence with the // _generate_recurrence_moves method that creates new task copies. // Mirrors: odoo/addons/project/models/project_task_recurrence.py _generate_recurrence_moves() func initProjectTaskRecurrenceExtension() { rec := orm.ExtendModel("project.task.recurrence") rec.RegisterMethod("_generate_recurrence_moves", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() var createdIDs []int64 for _, recID := range rs.IDs() { // Read recurrence config var repeatInterval string var repeatNumber int var repeatType string var repeatUntil *time.Time var recurrenceLeft int var nextDate *time.Time env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(repeat_interval, 'weekly'), COALESCE(repeat_number, 1), COALESCE(repeat_type, 'forever'), repeat_until, COALESCE(recurrence_left, 0), next_recurrence_date FROM project_task_recurrence WHERE id = $1`, recID).Scan( &repeatInterval, &repeatNumber, &repeatType, &repeatUntil, &recurrenceLeft, &nextDate) // Check if we should generate now := time.Now() if nextDate != nil && nextDate.After(now) { continue // Not yet time } if repeatType == "after" && recurrenceLeft <= 0 { continue // No repetitions left } if repeatType == "until" && repeatUntil != nil && now.After(*repeatUntil) { continue // Past end date } // Get template tasks (the original tasks linked to this recurrence) taskRows, err := env.Tx().Query(env.Ctx(), `SELECT pt.id, pt.name, pt.project_id, pt.stage_id, pt.priority, pt.company_id, pt.planned_hours, pt.description, pt.partner_id FROM project_task pt JOIN project_task_recurrence_project_task_rel rel ON rel.project_task_id = pt.id WHERE rel.project_task_recurrence_id = $1 LIMIT 10`, recID) if err != nil { continue } type templateTask struct { ID, ProjectID, StageID, CompanyID, PartnerID int64 Name, Priority string PlannedHours float64 Description *string } var templates []templateTask for taskRows.Next() { var t templateTask var projID, stageID, compID, partnerID *int64 var desc *string if err := taskRows.Scan(&t.ID, &t.Name, &projID, &stageID, &t.Priority, &compID, &t.PlannedHours, &desc, &partnerID); err != nil { continue } if projID != nil { t.ProjectID = *projID } if stageID != nil { t.StageID = *stageID } if compID != nil { t.CompanyID = *compID } if partnerID != nil { t.PartnerID = *partnerID } t.Description = desc templates = append(templates, t) } taskRows.Close() // Create copies of each template task for _, t := range templates { var newID int64 err := env.Tx().QueryRow(env.Ctx(), `INSERT INTO project_task (name, project_id, stage_id, priority, company_id, planned_hours, description, partner_id, state, active, recurring_task, sequence, create_uid, create_date, write_uid, write_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'open', true, true, 10, $9, NOW(), $9, NOW()) RETURNING id`, fmt.Sprintf("%s (copy)", t.Name), nilIfZero(t.ProjectID), nilIfZero(t.StageID), t.Priority, nilIfZero(t.CompanyID), t.PlannedHours, t.Description, nilIfZero(t.PartnerID), env.UID()).Scan(&newID) if err != nil { continue } createdIDs = append(createdIDs, newID) // Copy assignees from original task env.Tx().Exec(env.Ctx(), `INSERT INTO project_task_res_users_rel (project_task_id, res_users_id) SELECT $1, res_users_id FROM project_task_res_users_rel WHERE project_task_id = $2`, newID, t.ID) // Copy tags from original task env.Tx().Exec(env.Ctx(), `INSERT INTO project_task_project_tags_rel (project_task_id, project_tags_id) SELECT $1, project_tags_id FROM project_task_project_tags_rel WHERE project_task_id = $2`, newID, t.ID) } // Compute next recurrence date base := now if nextDate != nil { base = *nextDate } var nextRecDate time.Time switch repeatInterval { case "daily": nextRecDate = base.AddDate(0, 0, repeatNumber) case "weekly": nextRecDate = base.AddDate(0, 0, 7*repeatNumber) case "monthly": nextRecDate = base.AddDate(0, repeatNumber, 0) case "yearly": nextRecDate = base.AddDate(repeatNumber, 0, 0) default: nextRecDate = base.AddDate(0, 0, 7) } // Update recurrence record newLeft := recurrenceLeft - 1 if newLeft < 0 { newLeft = 0 } env.Tx().Exec(env.Ctx(), `UPDATE project_task_recurrence SET next_recurrence_date = $1, recurrence_left = $2, write_date = NOW(), write_uid = $3 WHERE id = $4`, nextRecDate, newLeft, env.UID(), recID) } return map[string]interface{}{ "created_task_ids": createdIDs, "count": len(createdIDs), }, nil }) } // nilIfZero returns nil if v is 0, otherwise returns v. Used for nullable FK inserts. func nilIfZero(v int64) interface{} { if v == 0 { return nil } return v } // initProjectSharingWizard registers a wizard for sharing projects with external users. // Mirrors: odoo/addons/project/wizard/project_share_wizard.py func initProjectSharingWizard() { m := orm.NewModel("project.share.wizard", orm.ModelOpts{ Description: "Project Sharing Wizard", Type: orm.ModelTransient, }) m.AddFields( orm.Many2one("project_id", "project.project", orm.FieldOpts{ String: "Project", Required: true, }), orm.Many2many("partner_ids", "res.partner", orm.FieldOpts{String: "Recipients"}), orm.Selection("access_mode", []orm.SelectionItem{ {Value: "read", Label: "Read"}, {Value: "edit", Label: "Edit"}, }, orm.FieldOpts{String: "Access Mode", Default: "read"}), orm.Text("note", orm.FieldOpts{String: "Note"}), ) // action_share: Share the project with selected partners. m.RegisterMethod("action_share", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { // Placeholder: in full Odoo this would create portal access rules return true, nil }) }