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 }) }