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