Files
goodie/addons/project/models/project_timesheet.go
Marc fad2a37d1c Expand Sale/Purchase/Project + 102 ORM tests — +4633 LOC
Sale (1177→2321 LOC):
- Quotation templates (apply to order, option lines)
- Sales reports (by month, product, customer, salesperson, category)
- Advance payment wizard (delivered/percentage/fixed modes)
- SO cancel wizard, discount wizard
- action_quotation_sent, action_lock/unlock, preview_quotation
- Line computes: invoice_status, price_reduce, untaxed_amount
- Partner extension: sale_order_total

Purchase (478→1424 LOC):
- Purchase reports (by month, category, bill status, receipt analysis)
- Receipt creation from PO (action_create_picking)
- 3-way matching: action_view_picking, action_view_invoice
- button_approve, button_done, action_rfq_send
- Line computes: price_subtotal/total with tax, product onchange
- Partner extension: purchase_order_count/total

Project (218→1161 LOC):
- Project updates (status tracking: on_track/at_risk/off_track)
- Milestones (deadline, reached tracking, task count)
- Timesheet integration (account.analytic.line extension)
- Timesheet reports (by project, employee, task, week)
- Task recurrence model
- Task: planned/effective/remaining hours, progress, subtask hours
- Project: allocated/remaining hours, profitability actions

ORM Tests (102 tests, 0→1257 LOC):
- domain_test.go: 32 tests (compile, operators, AND/OR/NOT, null)
- field_test.go: 15 tests (IsCopyable, SQLType, IsRelational, IsStored)
- model_test.go: 21 tests (NewModel, AddFields, RegisterMethod, ExtendModel)
- domain_parse_test.go: 21 tests (parse Python domain strings)
- sanitize_test.go: 13 tests (false→nil, type conversions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:39:41 +02:00

238 lines
7.1 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_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
})
}