feat: Portal, Email Inbound, Discuss + module improvements

- 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>
This commit is contained in:
Marc
2026-04-12 18:41:57 +02:00
parent 2c7c1e6c88
commit 66383adf06
87 changed files with 14696 additions and 654 deletions

View File

@@ -6,6 +6,8 @@ func Init() {
initProjectMilestone()
initProjectProject()
initProjectTask()
initProjectTaskChecklist()
initProjectSharing()
initProjectUpdate()
initProjectTimesheetExtension()
initTimesheetReport()
@@ -13,5 +15,6 @@ func Init() {
initProjectTaskExtension()
initProjectMilestoneExtension()
initProjectTaskRecurrence()
initProjectTaskRecurrenceExtension()
initProjectSharingWizard()
}

View File

@@ -80,6 +80,8 @@ func initProjectTask() {
orm.Many2one("milestone_id", "project.milestone", orm.FieldOpts{String: "Milestone"}),
orm.Many2many("depend_ids", "project.task", orm.FieldOpts{String: "Depends On"}),
orm.Boolean("recurring_task", orm.FieldOpts{String: "Recurrent"}),
orm.Datetime("planned_date_start", orm.FieldOpts{String: "Planned Start Date"}),
orm.Datetime("planned_date_end", orm.FieldOpts{String: "Planned End Date"}),
orm.Selection("display_type", []orm.SelectionItem{
{Value: "", Label: ""},
{Value: "line_section", Label: "Section"},
@@ -100,38 +102,54 @@ func initProjectTask() {
task.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'done' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'done' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: done %d: %w", id, err)
}
}
return true, nil
})
// action_cancel: mark task as cancelled
task.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'cancel' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'cancel' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: cancel %d: %w", id, err)
}
}
return true, nil
})
// action_reopen: reopen a cancelled/done task
task.RegisterMethod("action_reopen", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'open' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'open' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: reopen %d: %w", id, err)
}
}
return true, nil
})
// action_blocked: set kanban state to blocked
task.RegisterMethod("action_blocked", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: blocked %d: %w", id, err)
}
}
return true, nil
})
task.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET active = NOT active WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: toggle_active %d: %w", id, err)
}
}
return true, nil
})
@@ -185,3 +203,62 @@ func initProjectTags() {
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
)
}
// initProjectTaskChecklist registers the project.task.checklist model.
// Mirrors: odoo/addons/project/models/project_task_checklist.py
func initProjectTaskChecklist() {
m := orm.NewModel("project.task.checklist", orm.ModelOpts{
Description: "Task Checklist Item",
Order: "sequence, id",
})
m.AddFields(
orm.Many2one("task_id", "project.task", orm.FieldOpts{
String: "Task", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Boolean("is_done", orm.FieldOpts{String: "Done", Default: false}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
// action_toggle_done: Toggle the checklist item done status.
m.RegisterMethod("action_toggle_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task_checklist SET is_done = NOT is_done WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task.checklist: toggle_done %d: %w", id, err)
}
}
return true, nil
})
}
// initProjectSharing registers the project.sharing model.
// Mirrors: odoo/addons/project/models/project_sharing.py
func initProjectSharing() {
m := orm.NewModel("project.sharing", orm.ModelOpts{
Description: "Project Sharing",
Order: "id",
})
m.AddFields(
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Partner", Required: true, Index: true,
}),
orm.Many2one("project_id", "project.project", orm.FieldOpts{
String: "Project", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Selection("access_level", []orm.SelectionItem{
{Value: "read", Label: "Read"},
{Value: "edit", Label: "Edit"},
{Value: "admin", Label: "Admin"},
}, orm.FieldOpts{String: "Access Level", Required: true, Default: "read"}),
)
m.AddSQLConstraint(
"unique_partner_project",
"UNIQUE(partner_id, project_id)",
"A partner can only have one sharing entry per project.",
)
}

View File

@@ -1,6 +1,7 @@
package models
import (
"encoding/json"
"fmt"
"time"
@@ -56,6 +57,28 @@ func initProjectProjectExtension() {
}),
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 --
@@ -164,6 +187,110 @@ func initProjectProjectExtension() {
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) {
@@ -264,7 +391,17 @@ 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",
@@ -524,6 +661,263 @@ func initProjectTaskExtension() {
}
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.
@@ -623,6 +1017,169 @@ func initProjectTaskRecurrence() {
)
}
// 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() {

View File

@@ -198,6 +198,94 @@ func initTimesheetReport() {
}, nil
})
// get_timesheet_pivot_data: Pivot data by employee x task with date breakdown.
// Returns rows grouped by employee, columns grouped by task, cells contain hours per date period.
// Mirrors: odoo/addons/hr_timesheet/report/hr_timesheet_report.py pivot view
m.RegisterMethod("get_timesheet_pivot_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// Query: employee x task x week, with hours
rows, err := env.Tx().Query(env.Ctx(), `
SELECT COALESCE(he.name, 'Unknown') AS employee,
COALESCE(pt.name, 'No Task') AS task,
date_trunc('week', aal.date) AS week,
SUM(aal.unit_amount) AS hours
FROM account_analytic_line aal
LEFT JOIN hr_employee he ON he.id = aal.employee_id
LEFT JOIN project_task pt ON pt.id = aal.task_id
WHERE aal.project_id IS NOT NULL
GROUP BY he.name, pt.name, date_trunc('week', aal.date)
ORDER BY he.name, pt.name, week
LIMIT 500`)
if err != nil {
return nil, fmt.Errorf("timesheet_report: pivot query: %w", err)
}
defer rows.Close()
// Build pivot structure: { rows: [{employee, task, dates: [{week, hours}]}] }
type pivotCell struct {
Week string `json:"week"`
Hours float64 `json:"hours"`
}
type pivotRow struct {
Employee string `json:"employee"`
Task string `json:"task"`
Dates []pivotCell `json:"dates"`
Total float64 `json:"total"`
}
rowMap := make(map[string]*pivotRow) // key = "employee|task"
var allWeeks []string
weekSet := make(map[string]bool)
for rows.Next() {
var employee, task string
var week time.Time
var hours float64
if err := rows.Scan(&employee, &task, &week, &hours); err != nil {
continue
}
weekStr := week.Format("2006-01-02")
key := employee + "|" + task
r, ok := rowMap[key]
if !ok {
r = &pivotRow{Employee: employee, Task: task}
rowMap[key] = r
}
r.Dates = append(r.Dates, pivotCell{Week: weekStr, Hours: hours})
r.Total += hours
if !weekSet[weekStr] {
weekSet[weekStr] = true
allWeeks = append(allWeeks, weekStr)
}
}
var pivotRows []pivotRow
for _, r := range rowMap {
pivotRows = append(pivotRows, *r)
}
// Compute totals per employee
empTotals := make(map[string]float64)
for _, r := range pivotRows {
empTotals[r.Employee] += r.Total
}
var empSummary []map[string]interface{}
for emp, total := range empTotals {
empSummary = append(empSummary, map[string]interface{}{
"employee": emp,
"total": total,
})
}
return map[string]interface{}{
"pivot_rows": pivotRows,
"weeks": allWeeks,
"employee_totals": empSummary,
}, nil
})
// get_timesheet_by_week: Weekly breakdown of timesheet hours.
m.RegisterMethod("get_timesheet_by_week", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()