Files
goodie/addons/project/models/project_timesheet.go
Marc 66383adf06 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>
2026-04-12 18:41:57 +02:00

326 lines
9.8 KiB
Go

package models
import (
"fmt"
"time"
"odoo-go/pkg/orm"
)
// initProjectTimesheetExtension extends account.analytic.line with project/timesheet fields.
// In Odoo, timesheets are account.analytic.line records with project_id set.
// Mirrors: odoo/addons/hr_timesheet/models/hr_timesheet.py
//
// class AccountAnalyticLine(models.Model):
// _inherit = 'account.analytic.line'
func initProjectTimesheetExtension() {
al := orm.ExtendModel("account.analytic.line")
al.AddFields(
orm.Many2one("project_id", "project.project", orm.FieldOpts{
String: "Project", Index: true,
}),
orm.Many2one("task_id", "project.task", orm.FieldOpts{
String: "Task", Index: true,
}),
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
String: "Employee", Index: true,
}),
orm.Float("unit_amount", orm.FieldOpts{String: "Duration (Hours)"}),
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "User"}),
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
orm.Selection("encoding_uom_id", []orm.SelectionItem{
{Value: "hours", Label: "Hours"},
{Value: "days", Label: "Days"},
}, orm.FieldOpts{String: "Encoding UoM", Default: "hours"}),
)
// DefaultGet: set date to today, employee from current user
al.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
vals := make(orm.Values)
vals["date"] = time.Now().Format("2006-01-02")
if env.UID() > 0 {
vals["user_id"] = env.UID()
// Try to find the employee linked to this user
var empID int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM hr_employee WHERE user_id = $1 LIMIT 1`, env.UID()).Scan(&empID)
if err == nil && empID > 0 {
vals["employee_id"] = empID
// Also set department
var deptID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT department_id FROM hr_employee WHERE id = $1`, empID).Scan(&deptID)
if deptID > 0 {
vals["department_id"] = deptID
}
}
}
return vals
}
}
// initTimesheetReport registers a transient model for timesheet reporting.
// Mirrors: odoo/addons/hr_timesheet/report/hr_timesheet_report.py
func initTimesheetReport() {
m := orm.NewModel("hr.timesheet.report", orm.ModelOpts{
Description: "Timesheet Analysis Report",
Type: orm.ModelTransient,
})
m.AddFields(
orm.Date("date_from", orm.FieldOpts{String: "Start Date"}),
orm.Date("date_to", orm.FieldOpts{String: "End Date"}),
orm.Many2one("project_id", "project.project", orm.FieldOpts{String: "Project"}),
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee"}),
orm.Many2one("task_id", "project.task", orm.FieldOpts{String: "Task"}),
)
// get_timesheet_data: Aggregated timesheet data for reporting.
// Returns: { by_project, by_employee, by_task, summary }
m.RegisterMethod("get_timesheet_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// ── Hours by project ──
projRows, err := env.Tx().Query(env.Ctx(), `
SELECT COALESCE(pp.name, 'No Project') AS project,
SUM(aal.unit_amount) AS hours,
COUNT(*) AS entries
FROM account_analytic_line aal
LEFT JOIN project_project pp ON pp.id = aal.project_id
WHERE aal.project_id IS NOT NULL
GROUP BY pp.name
ORDER BY hours DESC
LIMIT 20`)
if err != nil {
return nil, fmt.Errorf("timesheet_report: by project query: %w", err)
}
defer projRows.Close()
var byProject []map[string]interface{}
for projRows.Next() {
var name string
var hours float64
var entries int64
if err := projRows.Scan(&name, &hours, &entries); err != nil {
continue
}
byProject = append(byProject, map[string]interface{}{
"project": name,
"hours": hours,
"entries": entries,
})
}
// ── Hours by employee ──
empRows, err := env.Tx().Query(env.Ctx(), `
SELECT COALESCE(he.name, 'Unknown') AS employee,
SUM(aal.unit_amount) AS hours,
COUNT(*) AS entries,
COUNT(DISTINCT aal.project_id) AS projects
FROM account_analytic_line aal
LEFT JOIN hr_employee he ON he.id = aal.employee_id
WHERE aal.project_id IS NOT NULL
GROUP BY he.name
ORDER BY hours DESC
LIMIT 20`)
if err != nil {
return nil, fmt.Errorf("timesheet_report: by employee query: %w", err)
}
defer empRows.Close()
var byEmployee []map[string]interface{}
for empRows.Next() {
var name string
var hours float64
var entries, projects int64
if err := empRows.Scan(&name, &hours, &entries, &projects); err != nil {
continue
}
byEmployee = append(byEmployee, map[string]interface{}{
"employee": name,
"hours": hours,
"entries": entries,
"projects": projects,
})
}
// ── Hours by task ──
taskRows, err := env.Tx().Query(env.Ctx(), `
SELECT COALESCE(pt.name, 'No Task') AS task,
COALESCE(pp.name, 'No Project') AS project,
SUM(aal.unit_amount) AS hours,
COUNT(*) AS entries
FROM account_analytic_line aal
LEFT JOIN project_task pt ON pt.id = aal.task_id
LEFT JOIN project_project pp ON pp.id = aal.project_id
WHERE aal.project_id IS NOT NULL AND aal.task_id IS NOT NULL
GROUP BY pt.name, pp.name
ORDER BY hours DESC
LIMIT 20`)
if err != nil {
return nil, fmt.Errorf("timesheet_report: by task query: %w", err)
}
defer taskRows.Close()
var byTask []map[string]interface{}
for taskRows.Next() {
var taskName, projName string
var hours float64
var entries int64
if err := taskRows.Scan(&taskName, &projName, &hours, &entries); err != nil {
continue
}
byTask = append(byTask, map[string]interface{}{
"task": taskName,
"project": projName,
"hours": hours,
"entries": entries,
})
}
// ── Summary ──
var totalHours float64
var totalEntries int64
env.Tx().QueryRow(env.Ctx(), `
SELECT COALESCE(SUM(unit_amount), 0), COUNT(*)
FROM account_analytic_line WHERE project_id IS NOT NULL
`).Scan(&totalHours, &totalEntries)
return map[string]interface{}{
"by_project": byProject,
"by_employee": byEmployee,
"by_task": byTask,
"summary": map[string]interface{}{
"total_hours": totalHours,
"total_entries": totalEntries,
},
}, 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()
rows, err := env.Tx().Query(env.Ctx(), `
SELECT date_trunc('week', aal.date) AS week,
SUM(aal.unit_amount) AS hours,
COUNT(*) AS entries,
COUNT(DISTINCT aal.employee_id) AS employees
FROM account_analytic_line aal
WHERE aal.project_id IS NOT NULL
GROUP BY week
ORDER BY week DESC
LIMIT 12`)
if err != nil {
return nil, fmt.Errorf("timesheet_report: weekly query: %w", err)
}
defer rows.Close()
var results []map[string]interface{}
for rows.Next() {
var week time.Time
var hours float64
var entries, employees int64
if err := rows.Scan(&week, &hours, &entries, &employees); err != nil {
continue
}
results = append(results, map[string]interface{}{
"week": week.Format("2006-01-02"),
"hours": hours,
"entries": entries,
"employees": employees,
})
}
return results, nil
})
}