- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1209 lines
43 KiB
Go
1209 lines
43 KiB
Go
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
|
|
})
|
|
}
|