- 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>
326 lines
9.8 KiB
Go
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
|
|
})
|
|
}
|