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>
This commit is contained in:
237
addons/project/models/project_timesheet.go
Normal file
237
addons/project/models/project_timesheet.go
Normal file
@@ -0,0 +1,237 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user