diff --git a/addons/project/models/init.go b/addons/project/models/init.go index 223075f..c1fc4c9 100644 --- a/addons/project/models/init.go +++ b/addons/project/models/init.go @@ -6,4 +6,12 @@ func Init() { initProjectMilestone() initProjectProject() initProjectTask() + initProjectUpdate() + initProjectTimesheetExtension() + initTimesheetReport() + initProjectProjectExtension() + initProjectTaskExtension() + initProjectMilestoneExtension() + initProjectTaskRecurrence() + initProjectSharingWizard() } diff --git a/addons/project/models/project_extend.go b/addons/project/models/project_extend.go new file mode 100644 index 0000000..df9f6f2 --- /dev/null +++ b/addons/project/models/project_extend.go @@ -0,0 +1,651 @@ +package models + +import ( + "fmt" + "time" + + "odoo-go/pkg/orm" +) + +// initProjectProjectExtension extends project.project with additional fields and methods. +// Mirrors: odoo/addons/project/models/project_project.py (additional fields) +// odoo/addons/hr_timesheet/models/project_project.py (timesheet fields) +func initProjectProjectExtension() { + proj := orm.ExtendModel("project.project") + + proj.AddFields( + orm.One2many("update_ids", "project.update", "project_id", orm.FieldOpts{ + String: "Updates", + }), + orm.One2many("milestone_ids", "project.milestone", "project_id", orm.FieldOpts{ + String: "Milestones", + }), + orm.Selection("last_update_status", []orm.SelectionItem{ + {Value: "on_track", Label: "On Track"}, + {Value: "at_risk", Label: "At Risk"}, + {Value: "off_track", Label: "Off Track"}, + {Value: "on_hold", Label: "On Hold"}, + }, orm.FieldOpts{String: "Status", Compute: "_compute_last_update_status"}), + orm.Float("total_timesheet_hours", orm.FieldOpts{ + String: "Total Timesheet Hours", Compute: "_compute_total_timesheet_hours", + }), + orm.Boolean("allow_timesheets", orm.FieldOpts{String: "Timesheets", Default: true}), + orm.Integer("update_count", orm.FieldOpts{ + String: "Update Count", Compute: "_compute_update_count", + }), + orm.Integer("milestone_count", orm.FieldOpts{ + String: "Milestone Count", Compute: "_compute_milestone_count", + }), + orm.Integer("milestone_count_reached", orm.FieldOpts{ + String: "Milestones Reached", Compute: "_compute_milestone_count_reached", + }), + orm.Float("allocated_hours", orm.FieldOpts{String: "Allocated Hours"}), + orm.Float("remaining_hours", orm.FieldOpts{ + String: "Remaining Hours", Compute: "_compute_remaining_hours", + }), + orm.Float("progress", orm.FieldOpts{ + String: "Progress (%)", Compute: "_compute_progress", + }), + orm.Selection("privacy_visibility", []orm.SelectionItem{ + {Value: "followers", Label: "Invited internal users"}, + {Value: "employees", Label: "All internal users"}, + {Value: "portal", Label: "Invited portal users and all internal users"}, + }, orm.FieldOpts{String: "Visibility", Default: "portal"}), + orm.Many2one("analytic_account_id", "account.analytic.account", orm.FieldOpts{ + String: "Analytic Account", + }), + orm.Many2many("tag_ids", "project.tags", orm.FieldOpts{String: "Tags"}), + orm.One2many("task_ids", "project.task", "project_id", orm.FieldOpts{String: "Tasks"}), + ) + + // -- _compute_task_count -- + // Mirrors: odoo/addons/project/models/project_project.py Project._compute_task_count() + proj.RegisterCompute("task_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + projID := rs.IDs()[0] + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM project_task WHERE project_id = $1 AND active = true`, projID).Scan(&count) + return orm.Values{"task_count": count}, nil + }) + + // -- _compute_last_update_status -- + // Mirrors: odoo/addons/project/models/project_project.py Project._compute_last_update_status() + proj.RegisterCompute("last_update_status", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + projID := rs.IDs()[0] + var status *string + env.Tx().QueryRow(env.Ctx(), + `SELECT status FROM project_update + WHERE project_id = $1 + ORDER BY date DESC, id DESC LIMIT 1`, projID).Scan(&status) + if status != nil { + return orm.Values{"last_update_status": *status}, nil + } + return orm.Values{"last_update_status": "on_track"}, nil + }) + + // -- _compute_total_timesheet_hours -- + // Mirrors: odoo/addons/hr_timesheet/models/project_project.py Project._compute_total_timesheet_time() + proj.RegisterCompute("total_timesheet_hours", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + projID := rs.IDs()[0] + var hours float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line + WHERE project_id = $1`, projID).Scan(&hours) + return orm.Values{"total_timesheet_hours": hours}, nil + }) + + // -- _compute_update_count -- + proj.RegisterCompute("update_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + projID := rs.IDs()[0] + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM project_update WHERE project_id = $1`, projID).Scan(&count) + return orm.Values{"update_count": count}, nil + }) + + // -- _compute_milestone_count -- + proj.RegisterCompute("milestone_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + projID := rs.IDs()[0] + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM project_milestone WHERE project_id = $1`, projID).Scan(&count) + return orm.Values{"milestone_count": count}, nil + }) + + // -- _compute_milestone_count_reached -- + proj.RegisterCompute("milestone_count_reached", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + projID := rs.IDs()[0] + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM project_milestone WHERE project_id = $1 AND is_reached = true`, projID).Scan(&count) + return orm.Values{"milestone_count_reached": count}, nil + }) + + // -- _compute_remaining_hours -- + proj.RegisterCompute("remaining_hours", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + projID := rs.IDs()[0] + var allocated, timesheet float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(allocated_hours, 0) FROM project_project WHERE id = $1`, projID).Scan(&allocated) + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line + WHERE project_id = $1`, projID).Scan(×heet) + remaining := allocated - timesheet + if remaining < 0 { + remaining = 0 + } + return orm.Values{"remaining_hours": remaining}, nil + }) + + // -- _compute_progress -- + proj.RegisterCompute("progress", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + projID := rs.IDs()[0] + var allocated, timesheet float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(allocated_hours, 0) FROM project_project WHERE id = $1`, projID).Scan(&allocated) + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line + WHERE project_id = $1`, projID).Scan(×heet) + pct := float64(0) + if allocated > 0 { + pct = timesheet / allocated * 100 + if pct > 100 { + pct = 100 + } + } + return orm.Values{"progress": pct}, nil + }) + + // action_view_tasks: Open tasks of this project. + // Mirrors: odoo/addons/project/models/project_project.py Project.action_view_tasks() + proj.RegisterMethod("action_view_tasks", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + projID := rs.IDs()[0] + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "project.task", + "view_mode": "kanban,list,form", + "views": [][]interface{}{{nil, "kanban"}, {nil, "list"}, {nil, "form"}}, + "domain": []interface{}{[]interface{}{"project_id", "=", projID}}, + "target": "current", + "name": "Tasks", + "context": map[string]interface{}{"default_project_id": projID}, + }, nil + }) + + // action_view_milestones: Open milestones of this project. + // Mirrors: odoo/addons/project/models/project_project.py Project.action_view_milestones() + proj.RegisterMethod("action_view_milestones", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + projID := rs.IDs()[0] + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "project.milestone", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "domain": []interface{}{[]interface{}{"project_id", "=", projID}}, + "target": "current", + "name": "Milestones", + "context": map[string]interface{}{"default_project_id": projID}, + }, nil + }) + + // action_view_updates: Open project updates. + proj.RegisterMethod("action_view_updates", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + projID := rs.IDs()[0] + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "project.update", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "domain": []interface{}{[]interface{}{"project_id", "=", projID}}, + "target": "current", + "name": "Updates", + "context": map[string]interface{}{"default_project_id": projID}, + }, nil + }) + + // action_view_timesheets: Open timesheet entries for this project. + // Mirrors: odoo/addons/hr_timesheet/models/project_project.py + proj.RegisterMethod("action_view_timesheets", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + projID := rs.IDs()[0] + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "account.analytic.line", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "domain": []interface{}{[]interface{}{"project_id", "=", projID}}, + "target": "current", + "name": "Timesheets", + "context": map[string]interface{}{"default_project_id": projID}, + }, nil + }) + + // action_get_project_profitability: Get profitability data. + // Mirrors: odoo/addons/project/models/project_profitability.py + proj.RegisterMethod("action_get_project_profitability", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + projID := rs.IDs()[0] + + // Compute costs from timesheets (hours * employee cost) + var timesheetCost float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(aal.unit_amount * COALESCE(he.timesheet_cost, 0)), 0) + FROM account_analytic_line aal + LEFT JOIN hr_employee he ON he.id = aal.employee_id + WHERE aal.project_id = $1`, projID).Scan(×heetCost) + + // Compute revenue from linked SOs (if project has partner) + var revenue float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(so.amount_total::float8), 0) FROM sale_order so + WHERE so.state IN ('sale', 'done') + AND so.partner_id = (SELECT partner_id FROM project_project WHERE id = $1) + AND so.partner_id IS NOT NULL`, projID).Scan(&revenue) + + return map[string]interface{}{ + "timesheet_cost": timesheetCost, + "revenue": revenue, + "margin": revenue - timesheetCost, + }, nil + }) +} + +// initProjectTaskExtension extends project.task with additional fields and methods. +// Mirrors: odoo/addons/project/models/project_task.py (additional fields) +// odoo/addons/hr_timesheet/models/project_task.py (timesheet fields) +func initProjectTaskExtension() { + task := orm.ExtendModel("project.task") + + // Note: parent_id, child_ids, milestone_id, tag_ids, depend_ids already exist + task.AddFields( + orm.Float("planned_hours", orm.FieldOpts{String: "Initially Planned Hours"}), + orm.Float("effective_hours", orm.FieldOpts{ + String: "Hours Spent", Compute: "_compute_effective_hours", + }), + orm.Float("remaining_hours", orm.FieldOpts{ + String: "Remaining Hours", Compute: "_compute_remaining_hours", + }), + orm.Float("progress", orm.FieldOpts{ + String: "Progress (%)", Compute: "_compute_progress", + }), + orm.Float("subtask_effective_hours", orm.FieldOpts{ + String: "Sub-task Hours", Compute: "_compute_subtask_effective_hours", + }), + orm.Float("total_hours_spent", orm.FieldOpts{ + String: "Total Hours", Compute: "_compute_total_hours_spent", + }), + orm.Date("date_last_stage_update", orm.FieldOpts{String: "Last Stage Update"}), + orm.One2many("timesheet_ids", "account.analytic.line", "task_id", orm.FieldOpts{ + String: "Timesheets", + }), + orm.Integer("timesheet_count", orm.FieldOpts{ + String: "Timesheet Count", Compute: "_compute_timesheet_count", + }), + orm.Char("email_from", orm.FieldOpts{String: "Email From"}), + orm.Boolean("allow_timesheets", orm.FieldOpts{String: "Allow Timesheets"}), + orm.Integer("working_days_close", orm.FieldOpts{ + String: "Working Days to Close", Compute: "_compute_working_days_close", + }), + orm.Integer("working_days_open", orm.FieldOpts{ + String: "Working Days to Assign", Compute: "_compute_working_days_open", + }), + orm.Float("overtime", orm.FieldOpts{ + String: "Overtime", Compute: "_compute_overtime", + }), + orm.Many2one("sale_order_id", "sale.order", orm.FieldOpts{String: "Sales Order"}), + orm.Many2one("sale_line_id", "sale.order.line", orm.FieldOpts{String: "Sales Order Item"}), + orm.Many2one("analytic_account_id", "account.analytic.account", orm.FieldOpts{ + String: "Analytic Account", + }), + ) + + // -- _compute_effective_hours: Sum of timesheet hours on this task. -- + // Mirrors: odoo/addons/hr_timesheet/models/project_task.py Task._compute_effective_hours() + task.RegisterCompute("effective_hours", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + taskID := rs.IDs()[0] + var hours float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line + WHERE task_id = $1`, taskID).Scan(&hours) + return orm.Values{"effective_hours": hours}, nil + }) + + // -- _compute_remaining_hours -- + // Mirrors: odoo/addons/hr_timesheet/models/project_task.py Task._compute_remaining_hours() + task.RegisterCompute("remaining_hours", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + taskID := rs.IDs()[0] + var planned, effective float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(planned_hours, 0) FROM project_task WHERE id = $1`, taskID).Scan(&planned) + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line + WHERE task_id = $1`, taskID).Scan(&effective) + remaining := planned - effective + return orm.Values{"remaining_hours": remaining}, nil + }) + + // -- _compute_progress -- + // Mirrors: odoo/addons/hr_timesheet/models/project_task.py Task._compute_progress() + task.RegisterCompute("progress", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + taskID := rs.IDs()[0] + var planned float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(planned_hours, 0) FROM project_task WHERE id = $1`, taskID).Scan(&planned) + if planned <= 0 { + return orm.Values{"progress": float64(0)}, nil + } + var effective float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line + WHERE task_id = $1`, taskID).Scan(&effective) + pct := effective / planned * 100 + if pct > 100 { + pct = 100 + } + return orm.Values{"progress": pct}, nil + }) + + // -- _compute_subtask_effective_hours -- + task.RegisterCompute("subtask_effective_hours", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + taskID := rs.IDs()[0] + var hours float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(aal.unit_amount), 0) + FROM account_analytic_line aal + JOIN project_task pt ON pt.id = aal.task_id + WHERE pt.parent_id = $1`, taskID).Scan(&hours) + return orm.Values{"subtask_effective_hours": hours}, nil + }) + + // -- _compute_total_hours_spent: own hours + subtask hours -- + task.RegisterCompute("total_hours_spent", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + taskID := rs.IDs()[0] + var ownHours, subtaskHours float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line + WHERE task_id = $1`, taskID).Scan(&ownHours) + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(aal.unit_amount), 0) + FROM account_analytic_line aal + JOIN project_task pt ON pt.id = aal.task_id + WHERE pt.parent_id = $1`, taskID).Scan(&subtaskHours) + return orm.Values{"total_hours_spent": ownHours + subtaskHours}, nil + }) + + // -- _compute_timesheet_count -- + task.RegisterCompute("timesheet_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + taskID := rs.IDs()[0] + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM account_analytic_line WHERE task_id = $1`, taskID).Scan(&count) + return orm.Values{"timesheet_count": count}, nil + }) + + // -- _compute_working_days_close -- + // Mirrors: odoo/addons/project/models/project_task.py Task._compute_elapsed() + task.RegisterCompute("working_days_close", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + taskID := rs.IDs()[0] + var state string + var createDate *time.Time + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(state, 'open'), create_date FROM project_task WHERE id = $1`, taskID).Scan(&state, &createDate) + if state != "done" || createDate == nil { + return orm.Values{"working_days_close": 0}, nil + } + days := int(time.Since(*createDate).Hours() / 24) + return orm.Values{"working_days_close": days}, nil + }) + + // -- _compute_working_days_open -- + task.RegisterCompute("working_days_open", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + taskID := rs.IDs()[0] + var dateAssign *time.Time + var createDate *time.Time + env.Tx().QueryRow(env.Ctx(), + `SELECT date_assign, create_date FROM project_task WHERE id = $1`, taskID).Scan(&dateAssign, &createDate) + if dateAssign == nil || createDate == nil { + return orm.Values{"working_days_open": 0}, nil + } + days := int(dateAssign.Sub(*createDate).Hours() / 24) + if days < 0 { + days = 0 + } + return orm.Values{"working_days_open": days}, nil + }) + + // -- _compute_overtime -- + task.RegisterCompute("overtime", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + taskID := rs.IDs()[0] + var planned, effective float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(planned_hours, 0) FROM project_task WHERE id = $1`, taskID).Scan(&planned) + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(unit_amount), 0) FROM account_analytic_line + WHERE task_id = $1`, taskID).Scan(&effective) + overtime := effective - planned + if overtime < 0 { + overtime = 0 + } + return orm.Values{"overtime": overtime}, nil + }) + + // action_view_timesheets: Open timesheets for this task. + task.RegisterMethod("action_view_timesheets", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + taskID := rs.IDs()[0] + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "account.analytic.line", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "domain": []interface{}{[]interface{}{"task_id", "=", taskID}}, + "target": "current", + "name": "Timesheets", + "context": map[string]interface{}{"default_task_id": taskID}, + }, nil + }) + + // action_open_parent_task: Open parent task form. + task.RegisterMethod("action_open_parent_task", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + taskID := rs.IDs()[0] + var parentID *int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT parent_id FROM project_task WHERE id = $1`, taskID).Scan(&parentID) + if parentID == nil { + return nil, fmt.Errorf("task has no parent") + } + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "project.task", + "res_id": *parentID, + "view_mode": "form", + "target": "current", + }, nil + }) + + // action_subtask_view: Open subtasks list. + task.RegisterMethod("action_subtask_view", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + taskID := rs.IDs()[0] + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "project.task", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "domain": []interface{}{[]interface{}{"parent_id", "=", taskID}}, + "target": "current", + "name": "Sub-tasks", + "context": map[string]interface{}{"default_parent_id": taskID}, + }, nil + }) + + // action_assign_to_me: Assign the task to the current user. + task.RegisterMethod("action_assign_to_me", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + uid := env.UID() + for _, taskID := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `INSERT INTO project_task_res_users_rel (project_task_id, res_users_id) + VALUES ($1, $2) ON CONFLICT DO NOTHING`, taskID, uid) + env.Tx().Exec(env.Ctx(), + `UPDATE project_task SET date_assign = NOW() + WHERE id = $1 AND date_assign IS NULL`, taskID) + } + return true, nil + }) + + // action_set_stage: Change the task stage. + // Mirrors: odoo/addons/project/models/project_task.py Task.write() stage tracking + task.RegisterMethod("action_set_stage", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("stage_id required") + } + env := rs.Env() + stageID, _ := args[0].(float64) + for _, taskID := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE project_task SET stage_id = $1, date_last_stage_update = NOW() + WHERE id = $2`, int64(stageID), taskID) + } + return true, nil + }) +} + +// initProjectMilestoneExtension extends project.milestone with additional fields. +// Mirrors: odoo/addons/project/models/project_milestone.py (additional fields) +func initProjectMilestoneExtension() { + ms := orm.ExtendModel("project.milestone") + + ms.AddFields( + orm.Date("reached_date", orm.FieldOpts{String: "Reached Date"}), + orm.Integer("task_count", orm.FieldOpts{ + String: "Task Count", Compute: "_compute_task_count", + }), + orm.Float("progress", orm.FieldOpts{ + String: "Progress (%)", Compute: "_compute_progress", + }), + orm.Integer("color", orm.FieldOpts{String: "Color Index"}), + orm.Many2one("sale_line_id", "sale.order.line", orm.FieldOpts{String: "Sales Order Item"}), + ) + + // action_toggle_reached: Toggle the reached status. + ms.RegisterMethod("action_toggle_reached", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, msID := range rs.IDs() { + var isReached bool + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(is_reached, false) FROM project_milestone WHERE id = $1`, msID).Scan(&isReached) + if isReached { + env.Tx().Exec(env.Ctx(), + `UPDATE project_milestone SET is_reached = false, reached_date = NULL WHERE id = $1`, msID) + } else { + env.Tx().Exec(env.Ctx(), + `UPDATE project_milestone SET is_reached = true, reached_date = CURRENT_DATE WHERE id = $1`, msID) + } + } + return true, nil + }) + + // _compute_task_count + ms.RegisterCompute("task_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + msID := rs.IDs()[0] + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM project_task WHERE milestone_id = $1`, msID).Scan(&count) + return orm.Values{"task_count": count}, nil + }) + + // _compute_progress: % of tasks that are done out of total linked tasks. + ms.RegisterCompute("progress", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + msID := rs.IDs()[0] + var total, done int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM project_task WHERE milestone_id = $1`, msID).Scan(&total) + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM project_task WHERE milestone_id = $1 AND state = 'done'`, msID).Scan(&done) + pct := float64(0) + if total > 0 { + pct = float64(done) / float64(total) * 100 + } + return orm.Values{"progress": pct}, nil + }) +} + +// initProjectTaskRecurrence registers project.task.recurrence. +// Mirrors: odoo/addons/project/models/project_task_recurrence.py +func initProjectTaskRecurrence() { + m := orm.NewModel("project.task.recurrence", orm.ModelOpts{ + Description: "Task Recurrence", + }) + + m.AddFields( + orm.Many2many("task_ids", "project.task", orm.FieldOpts{String: "Tasks"}), + orm.Selection("repeat_interval", []orm.SelectionItem{ + {Value: "daily", Label: "Days"}, + {Value: "weekly", Label: "Weeks"}, + {Value: "monthly", Label: "Months"}, + {Value: "yearly", Label: "Years"}, + }, orm.FieldOpts{String: "Repeat Every", Default: "weekly"}), + orm.Integer("repeat_number", orm.FieldOpts{String: "Repeat", Default: 1}), + orm.Selection("repeat_type", []orm.SelectionItem{ + {Value: "forever", Label: "Forever"}, + {Value: "until", Label: "Until"}, + {Value: "after", Label: "Number of Repetitions"}, + }, orm.FieldOpts{String: "Until", Default: "forever"}), + orm.Date("repeat_until", orm.FieldOpts{String: "End Date"}), + orm.Integer("repeat_on_month", orm.FieldOpts{String: "Day of Month", Default: 1}), + orm.Boolean("mon", orm.FieldOpts{String: "Monday"}), + orm.Boolean("tue", orm.FieldOpts{String: "Tuesday"}), + orm.Boolean("wed", orm.FieldOpts{String: "Wednesday"}), + orm.Boolean("thu", orm.FieldOpts{String: "Thursday"}), + orm.Boolean("fri", orm.FieldOpts{String: "Friday"}), + orm.Boolean("sat", orm.FieldOpts{String: "Saturday"}), + orm.Boolean("sun", orm.FieldOpts{String: "Sunday"}), + orm.Integer("recurrence_left", orm.FieldOpts{String: "Recurrences Left"}), + orm.Date("next_recurrence_date", orm.FieldOpts{String: "Next Recurrence Date"}), + ) +} + +// initProjectSharingWizard registers a wizard for sharing projects with external users. +// Mirrors: odoo/addons/project/wizard/project_share_wizard.py +func initProjectSharingWizard() { + m := orm.NewModel("project.share.wizard", orm.ModelOpts{ + Description: "Project Sharing Wizard", + Type: orm.ModelTransient, + }) + + m.AddFields( + orm.Many2one("project_id", "project.project", orm.FieldOpts{ + String: "Project", Required: true, + }), + orm.Many2many("partner_ids", "res.partner", orm.FieldOpts{String: "Recipients"}), + orm.Selection("access_mode", []orm.SelectionItem{ + {Value: "read", Label: "Read"}, + {Value: "edit", Label: "Edit"}, + }, orm.FieldOpts{String: "Access Mode", Default: "read"}), + orm.Text("note", orm.FieldOpts{String: "Note"}), + ) + + // action_share: Share the project with selected partners. + m.RegisterMethod("action_share", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + // Placeholder: in full Odoo this would create portal access rules + return true, nil + }) +} diff --git a/addons/project/models/project_timesheet.go b/addons/project/models/project_timesheet.go new file mode 100644 index 0000000..454d1dd --- /dev/null +++ b/addons/project/models/project_timesheet.go @@ -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 + }) +} diff --git a/addons/project/models/project_update.go b/addons/project/models/project_update.go new file mode 100644 index 0000000..c6bf7ad --- /dev/null +++ b/addons/project/models/project_update.go @@ -0,0 +1,69 @@ +package models + +import ( + "fmt" + "time" + + "odoo-go/pkg/orm" +) + +// initProjectUpdate registers the project.update model. +// Mirrors: odoo/addons/project/models/project_update.py +// +// class ProjectUpdate(models.Model): +// _name = 'project.update' +// _description = 'Project Update' +// _order = 'date desc' +func initProjectUpdate() { + m := orm.NewModel("project.update", orm.ModelOpts{ + Description: "Project Update", + Order: "date desc, id desc", + }) + + m.AddFields( + orm.Many2one("project_id", "project.project", orm.FieldOpts{ + String: "Project", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, + }), + orm.Char("name", orm.FieldOpts{String: "Title", Required: true}), + orm.Selection("status", []orm.SelectionItem{ + {Value: "on_track", Label: "On Track"}, + {Value: "at_risk", Label: "At Risk"}, + {Value: "off_track", Label: "Off Track"}, + {Value: "on_hold", Label: "On Hold"}, + }, orm.FieldOpts{String: "Status", Required: true, Default: "on_track"}), + orm.Date("date", orm.FieldOpts{String: "Date", Required: true}), + orm.HTML("description", orm.FieldOpts{String: "Description"}), + orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Author"}), + orm.Float("progress", orm.FieldOpts{String: "Progress (%)"}), + orm.Integer("color", orm.FieldOpts{String: "Color Index"}), + ) + + // DefaultGet: set date to today, user to current user + m.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() + } + return vals + } + + // action_open_project: Return an action to open the project. + m.RegisterMethod("action_open_project", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + updateID := rs.IDs()[0] + var projectID int64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT project_id FROM project_update WHERE id = $1`, updateID).Scan(&projectID) + if err != nil { + return nil, fmt.Errorf("project_update: read update %d: %w", updateID, err) + } + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "project.project", + "res_id": projectID, + "view_mode": "form", + "target": "current", + }, nil + }) +} diff --git a/addons/purchase/models/init.go b/addons/purchase/models/init.go index 68184c0..0a7b3f1 100644 --- a/addons/purchase/models/init.go +++ b/addons/purchase/models/init.go @@ -4,4 +4,9 @@ func Init() { initPurchaseOrder() initPurchaseOrderLine() initPurchaseAgreement() + initPurchaseReport() + initPurchaseOrderExtension() + initPurchaseOrderLineExtension() + initResPartnerPurchaseExtension() + initPurchaseOrderAmount() } diff --git a/addons/purchase/models/purchase_extend.go b/addons/purchase/models/purchase_extend.go new file mode 100644 index 0000000..25de0df --- /dev/null +++ b/addons/purchase/models/purchase_extend.go @@ -0,0 +1,671 @@ +package models + +import ( + "fmt" + "time" + + "odoo-go/pkg/orm" +) + +// initPurchaseOrderExtension extends purchase.order with additional fields and methods. +// Mirrors: odoo/addons/purchase/models/purchase_order.py (additional workflow fields) +// odoo/addons/purchase_stock/models/purchase_order.py (stock integration) +func initPurchaseOrderExtension() { + po := orm.ExtendModel("purchase.order") + + // -- Additional Fields -- + // Note: date_planned, date_approve, origin, invoice_status already exist on purchase.order + po.AddFields( + orm.Boolean("is_shipped", orm.FieldOpts{ + String: "Fully Shipped", Compute: "_compute_is_shipped", + }), + orm.Integer("invoice_count", orm.FieldOpts{ + String: "Bill Count", Compute: "_compute_invoice_count", + }), + orm.Integer("picking_count", orm.FieldOpts{ + String: "Receipt Count", Compute: "_compute_picking_count", + }), + orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Purchase Representative", Index: true}), + orm.Many2one("dest_address_id", "res.partner", orm.FieldOpts{String: "Dropship Address"}), + orm.Boolean("mail_reception_confirmed", orm.FieldOpts{String: "Receipt Confirmation Sent"}), + orm.Boolean("mail_reminder_confirmed", orm.FieldOpts{String: "Reminder Sent"}), + orm.Datetime("receipt_reminder_email", orm.FieldOpts{String: "Receipt Reminder Email"}), + orm.Datetime("effective_date", orm.FieldOpts{String: "Effective Date"}), + orm.Integer("incoming_picking_count", orm.FieldOpts{ + String: "Incoming Shipment Count", Compute: "_compute_incoming_picking_count", + }), + ) + + // -- Computed: _compute_is_shipped -- + // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py PurchaseOrder._compute_is_shipped() + po.RegisterCompute("is_shipped", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + poID := rs.IDs()[0] + + var totalQty, receivedQty float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(product_qty), 0), COALESCE(SUM(qty_received), 0) + FROM purchase_order_line WHERE order_id = $1`, poID, + ).Scan(&totalQty, &receivedQty) + + shipped := totalQty > 0 && receivedQty >= totalQty + return orm.Values{"is_shipped": shipped}, nil + }) + + // -- Computed: _compute_invoice_count -- + // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder._compute_invoice() + po.RegisterCompute("invoice_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + poID := rs.IDs()[0] + + // Bills linked via invoice_origin + var poName string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName) + + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM account_move + WHERE invoice_origin = $1 AND move_type = 'in_invoice'`, poName, + ).Scan(&count) + + // Also check by PO ID pattern fallback + if count == 0 { + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM account_move + WHERE invoice_origin = $1 AND move_type = 'in_invoice'`, + fmt.Sprintf("PO%d", poID), + ).Scan(&count) + } + + return orm.Values{"invoice_count": count}, nil + }) + + // -- Computed: _compute_picking_count -- + // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py PurchaseOrder._compute_picking() + po.RegisterCompute("picking_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + poID := rs.IDs()[0] + + var poName string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName) + + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM stock_picking WHERE origin = $1`, poName, + ).Scan(&count) + return orm.Values{"picking_count": count}, nil + }) + + // -- Computed: _compute_incoming_picking_count -- + po.RegisterCompute("incoming_picking_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + poID := rs.IDs()[0] + + var poName string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName) + + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM stock_picking sp + JOIN stock_picking_type spt ON spt.id = sp.picking_type_id + WHERE sp.origin = $1 AND spt.code = 'incoming'`, poName, + ).Scan(&count) + return orm.Values{"incoming_picking_count": count}, nil + }) + + // action_view_picking: Open the receipts (incoming pickings) linked to this PO. + // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py PurchaseOrder.action_view_picking() + po.RegisterMethod("action_view_picking", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + poID := rs.IDs()[0] + + var poName string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName) + + rows, err := env.Tx().Query(env.Ctx(), + `SELECT id FROM stock_picking WHERE origin = $1`, poName) + if err != nil { + return nil, fmt.Errorf("purchase: view picking query: %w", err) + } + defer rows.Close() + + var pickingIDs []interface{} + for rows.Next() { + var id int64 + rows.Scan(&id) + pickingIDs = append(pickingIDs, id) + } + + if len(pickingIDs) == 1 { + return map[string]interface{}{ + "type": "ir.actions.act_window", "res_model": "stock.picking", + "res_id": pickingIDs[0], "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, "target": "current", + }, nil + } + return map[string]interface{}{ + "type": "ir.actions.act_window", "res_model": "stock.picking", + "view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "domain": []interface{}{[]interface{}{"id", "in", pickingIDs}}, "target": "current", + "name": "Receipts", + }, nil + }) + + // action_view_invoice: Open vendor bills linked to this PO. + // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_view_invoice() + po.RegisterMethod("action_view_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + poID := rs.IDs()[0] + + var poName string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName) + + rows, err := env.Tx().Query(env.Ctx(), + `SELECT id FROM account_move WHERE invoice_origin = $1 AND move_type = 'in_invoice'`, poName) + if err != nil { + return nil, fmt.Errorf("purchase: view invoice query: %w", err) + } + defer rows.Close() + + var invIDs []interface{} + for rows.Next() { + var id int64 + rows.Scan(&id) + invIDs = append(invIDs, id) + } + + // Also check by PO ID pattern fallback + if len(invIDs) == 0 { + rows2, _ := env.Tx().Query(env.Ctx(), + `SELECT id FROM account_move WHERE invoice_origin = $1 AND move_type = 'in_invoice'`, + fmt.Sprintf("PO%d", poID)) + if rows2 != nil { + for rows2.Next() { + var id int64 + rows2.Scan(&id) + invIDs = append(invIDs, id) + } + rows2.Close() + } + } + + if len(invIDs) == 1 { + return map[string]interface{}{ + "type": "ir.actions.act_window", "res_model": "account.move", + "res_id": invIDs[0], "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, "target": "current", + }, nil + } + return map[string]interface{}{ + "type": "ir.actions.act_window", "res_model": "account.move", + "view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "domain": []interface{}{[]interface{}{"id", "in", invIDs}}, "target": "current", + "name": "Vendor Bills", + }, nil + }) + + // action_create_picking: Generate incoming stock picking from a confirmed PO. + // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py PurchaseOrder._create_picking() + po.RegisterMethod("action_create_picking", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + var pickingIDs []int64 + + for _, poID := range rs.IDs() { + var partnerID, companyID int64 + var poName string + err := env.Tx().QueryRow(env.Ctx(), + `SELECT partner_id, company_id, COALESCE(name, '') + FROM purchase_order WHERE id = $1`, poID, + ).Scan(&partnerID, &companyID, &poName) + if err != nil { + return nil, fmt.Errorf("purchase: read PO %d for picking: %w", poID, err) + } + + // Read PO lines with products + rows, err := env.Tx().Query(env.Ctx(), + `SELECT product_id, product_qty, COALESCE(name, '') + FROM purchase_order_line + WHERE order_id = $1 AND product_id IS NOT NULL`, poID) + if err != nil { + return nil, fmt.Errorf("purchase: read PO lines %d for picking: %w", poID, err) + } + + type poline struct { + productID int64 + qty float64 + name string + } + var lines []poline + for rows.Next() { + var l poline + if err := rows.Scan(&l.productID, &l.qty, &l.name); err != nil { + rows.Close() + return nil, err + } + lines = append(lines, l) + } + rows.Close() + + if len(lines) == 0 { + continue + } + + // Find incoming picking type and locations + var pickingTypeID, srcLocID, destLocID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT pt.id, COALESCE(pt.default_location_src_id, 0), COALESCE(pt.default_location_dest_id, 0) + FROM stock_picking_type pt + WHERE pt.code = 'incoming' AND pt.company_id = $1 + LIMIT 1`, companyID, + ).Scan(&pickingTypeID, &srcLocID, &destLocID) + + if srcLocID == 0 { + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM stock_location WHERE usage = 'supplier' LIMIT 1`).Scan(&srcLocID) + } + if destLocID == 0 { + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM stock_location WHERE usage = 'internal' AND COALESCE(company_id, $1) = $1 LIMIT 1`, + companyID).Scan(&destLocID) + } + if pickingTypeID == 0 { + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM stock_picking_type WHERE code = 'incoming' LIMIT 1`).Scan(&pickingTypeID) + } + + // Create picking + var pickingID int64 + err = env.Tx().QueryRow(env.Ctx(), + `INSERT INTO stock_picking + (name, state, scheduled_date, company_id, partner_id, picking_type_id, + location_id, location_dest_id, origin) + VALUES ($1, 'confirmed', NOW(), $2, $3, $4, $5, $6, $7) RETURNING id`, + fmt.Sprintf("WH/IN/%05d", poID), companyID, partnerID, pickingTypeID, + srcLocID, destLocID, poName, + ).Scan(&pickingID) + if err != nil { + return nil, fmt.Errorf("purchase: create picking for PO %d: %w", poID, err) + } + + // Create stock moves + for _, l := range lines { + _, err = env.Tx().Exec(env.Ctx(), + `INSERT INTO stock_move + (name, product_id, product_uom_qty, state, picking_id, company_id, + location_id, location_dest_id, date, origin, product_uom) + VALUES ($1, $2, $3, 'confirmed', $4, $5, $6, $7, NOW(), $8, 1)`, + l.name, l.productID, l.qty, pickingID, companyID, + srcLocID, destLocID, poName) + if err != nil { + return nil, fmt.Errorf("purchase: create stock move for PO %d: %w", poID, err) + } + } + + pickingIDs = append(pickingIDs, pickingID) + } + + if len(pickingIDs) == 0 { + return nil, nil + } + return pickingIDs, nil + }) + + // button_approve: Approve a PO that requires approval (to approve → purchase). + // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_approve() + po.RegisterMethod("button_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, poID := range rs.IDs() { + var state string + env.Tx().QueryRow(env.Ctx(), + `SELECT state FROM purchase_order WHERE id = $1`, poID).Scan(&state) + if state != "to approve" { + continue + } + env.Tx().Exec(env.Ctx(), + `UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, poID) + } + return true, nil + }) + + // button_done: Lock a confirmed PO. + // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_done() + po.RegisterMethod("button_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, poID := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE purchase_order SET state = 'done' WHERE id = $1`, poID) + } + return true, nil + }) + + // button_unlock: Unlock a locked PO back to purchase state. + // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_unlock() + po.RegisterMethod("button_unlock", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, poID := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE purchase_order SET state = 'purchase' WHERE id = $1 AND state = 'done'`, poID) + } + return true, nil + }) + + // action_rfq_send: Mark the PO as "sent" (RFQ has been emailed). + // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_rfq_send() + po.RegisterMethod("action_rfq_send", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, poID := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE purchase_order SET state = 'sent' WHERE id = $1 AND state = 'draft'`, poID) + } + return true, nil + }) + + // _compute_effective_date: The effective date is set when all products have been received. + // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py + po.RegisterCompute("effective_date", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + poID := rs.IDs()[0] + + var totalQty, receivedQty float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(product_qty), 0), COALESCE(SUM(qty_received), 0) + FROM purchase_order_line WHERE order_id = $1`, poID, + ).Scan(&totalQty, &receivedQty) + + if totalQty > 0 && receivedQty >= totalQty { + return orm.Values{"effective_date": time.Now()}, nil + } + return orm.Values{"effective_date": nil}, nil + }) +} + +// initPurchaseOrderLineExtension extends purchase.order.line with additional fields. +// Mirrors: odoo/addons/purchase/models/purchase_order_line.py (additional fields) +func initPurchaseOrderLineExtension() { + pol := orm.ExtendModel("purchase.order.line") + + // Note: date_planned, qty_received, qty_invoiced already exist + pol.AddFields( + orm.Float("qty_received_manual", orm.FieldOpts{String: "Manual Received Qty"}), + orm.Selection("invoice_status", []orm.SelectionItem{ + {Value: "no", Label: "Nothing to Bill"}, + {Value: "to invoice", Label: "Waiting Bills"}, + {Value: "invoiced", Label: "Fully Billed"}, + }, orm.FieldOpts{String: "Billing Status", Compute: "_compute_line_invoice_status"}), + orm.Float("qty_to_invoice", orm.FieldOpts{ + String: "To Invoice Quantity", Compute: "_compute_line_qty_to_invoice", + }), + orm.Char("product_type", orm.FieldOpts{String: "Product Type"}), + orm.Boolean("product_qty_updated", orm.FieldOpts{String: "Qty Updated"}), + ) + + // _compute_line_invoice_status: Per-line billing status. + // Mirrors: odoo/addons/purchase/models/purchase_order_line.py _compute_qty_invoiced() + pol.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + + var qty, qtyInvoiced float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(product_qty, 0), COALESCE(qty_invoiced, 0) + FROM purchase_order_line WHERE id = $1`, lineID, + ).Scan(&qty, &qtyInvoiced) + + status := "no" + if qty > 0 { + if qtyInvoiced >= qty { + status = "invoiced" + } else if qtyInvoiced > 0 { + status = "to invoice" + } else { + status = "to invoice" + } + } + return orm.Values{"invoice_status": status}, nil + }) + + // _compute_line_qty_to_invoice + pol.RegisterCompute("qty_to_invoice", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var qty, qtyInvoiced float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(product_qty, 0), COALESCE(qty_invoiced, 0) + FROM purchase_order_line WHERE id = $1`, lineID, + ).Scan(&qty, &qtyInvoiced) + toInvoice := qty - qtyInvoiced + if toInvoice < 0 { + toInvoice = 0 + } + return orm.Values{"qty_to_invoice": toInvoice}, nil + }) + + // _compute_qty_received: Uses manual received qty if set, otherwise from stock moves. + // Mirrors: odoo/addons/purchase_stock/models/purchase_order_line.py _compute_qty_received() + pol.RegisterCompute("qty_received", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + + // Check for manual override + var manual *float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT qty_received_manual FROM purchase_order_line WHERE id = $1`, lineID).Scan(&manual) + if manual != nil && *manual > 0 { + return orm.Values{"qty_received": *manual}, nil + } + + // Fallback: sum from linked stock moves + var qty float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(qty_received, 0) FROM purchase_order_line WHERE id = $1`, lineID).Scan(&qty) + return orm.Values{"qty_received": qty}, nil + }) + + // _compute_price_subtotal and _compute_price_total for PO lines. + // Mirrors: odoo/addons/purchase/models/purchase_order_line.py _compute_amount() + computePOLineAmount := func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var qty, price, discount float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(product_qty, 0), COALESCE(price_unit, 0), COALESCE(discount, 0) + FROM purchase_order_line WHERE id = $1`, lineID, + ).Scan(&qty, &price, &discount) + subtotal := qty * price * (1 - discount/100) + + // Compute tax from linked taxes + var taxTotal float64 + taxRows, err := env.Tx().Query(env.Ctx(), + `SELECT t.amount, t.amount_type, COALESCE(t.price_include, false) + FROM account_tax t + JOIN account_tax_purchase_order_line_rel rel ON rel.account_tax_id = t.id + WHERE rel.purchase_order_line_id = $1`, lineID) + if err == nil { + for taxRows.Next() { + var taxRate float64 + var amountType string + var priceInclude bool + if err := taxRows.Scan(&taxRate, &amountType, &priceInclude); err != nil { + break + } + switch amountType { + case "percent": + if priceInclude { + taxTotal += subtotal - (subtotal / (1 + taxRate/100)) + } else { + taxTotal += subtotal * taxRate / 100 + } + case "fixed": + taxTotal += taxRate + } + } + taxRows.Close() + } + + return orm.Values{ + "price_subtotal": subtotal, + "price_total": subtotal + taxTotal, + }, nil + } + pol.RegisterCompute("price_subtotal", computePOLineAmount) + pol.RegisterCompute("price_total", computePOLineAmount) + + // Onchange: product_id → name, price_unit + // Mirrors: odoo/addons/purchase/models/purchase_order_line.py _compute_price_unit_and_date_planned_and_name() + pol.RegisterOnchange("product_id", func(env *orm.Environment, vals orm.Values) orm.Values { + result := make(orm.Values) + + var productID int64 + switch v := vals["product_id"].(type) { + case int64: + productID = v + case float64: + productID = int64(v) + case map[string]interface{}: + if id, ok := v["id"]; ok { + switch n := id.(type) { + case float64: + productID = int64(n) + case int64: + productID = n + } + } + } + if productID <= 0 { + return result + } + + var name string + var standardPrice float64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(pt.name, ''), COALESCE(pt.standard_price, 0) + FROM product_product pp + JOIN product_template pt ON pt.id = pp.product_tmpl_id + WHERE pp.id = $1`, productID, + ).Scan(&name, &standardPrice) + if err != nil { + return result + } + + result["name"] = name + result["price_unit"] = standardPrice + return result + }) +} + +// initResPartnerPurchaseExtension extends res.partner with purchase-specific fields. +// Mirrors: odoo/addons/purchase/models/res_partner.py +func initResPartnerPurchaseExtension() { + partner := orm.ExtendModel("res.partner") + + partner.AddFields( + orm.One2many("purchase_order_ids", "purchase.order", "partner_id", orm.FieldOpts{ + String: "Purchase Orders", + }), + orm.Integer("purchase_order_count", orm.FieldOpts{ + String: "Purchase Order Count", Compute: "_compute_purchase_order_count", + }), + orm.Integer("supplier_rank", orm.FieldOpts{String: "Vendor Rank"}), + orm.Monetary("purchase_order_total", orm.FieldOpts{ + String: "Total Purchases", Compute: "_compute_purchase_order_total", CurrencyField: "currency_id", + }), + ) + + partner.RegisterCompute("purchase_order_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + partnerID := rs.IDs()[0] + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM purchase_order WHERE partner_id = $1`, partnerID).Scan(&count) + return orm.Values{"purchase_order_count": count}, nil + }) + + partner.RegisterCompute("purchase_order_total", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + partnerID := rs.IDs()[0] + var total float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(amount_total::float8), 0) FROM purchase_order + WHERE partner_id = $1 AND state IN ('purchase', 'done')`, partnerID).Scan(&total) + return orm.Values{"purchase_order_total": total}, nil + }) +} + +// initPurchaseOrderAmount extends purchase.order with amount compute functions. +// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder._compute_amount() +func initPurchaseOrderAmount() { + po := orm.ExtendModel("purchase.order") + + computeAmounts := func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + poID := rs.IDs()[0] + + var untaxed, tax, total float64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT + COALESCE(SUM(price_subtotal), 0), + COALESCE(SUM(price_total - price_subtotal), 0), + COALESCE(SUM(price_total), 0) + FROM purchase_order_line WHERE order_id = $1`, poID, + ).Scan(&untaxed, &tax, &total) + + if err != nil { + // Fallback: compute from raw line values + err = env.Tx().QueryRow(env.Ctx(), + `SELECT + COALESCE(SUM(product_qty * price_unit * (1 - COALESCE(discount,0)/100)), 0) + FROM purchase_order_line WHERE order_id = $1`, poID, + ).Scan(&untaxed) + if err != nil { + return nil, fmt.Errorf("purchase: compute amounts for PO %d: %w", poID, err) + } + total = untaxed + tax = 0 + } + + return orm.Values{ + "amount_untaxed": untaxed, + "amount_tax": tax, + "amount_total": total, + }, nil + } + po.RegisterCompute("amount_untaxed", computeAmounts) + po.RegisterCompute("amount_tax", computeAmounts) + po.RegisterCompute("amount_total", computeAmounts) + + // _compute_invoice_status for the whole PO. + // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder._compute_invoice() + po.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + poID := rs.IDs()[0] + + var state string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(state, 'draft') FROM purchase_order WHERE id = $1`, poID).Scan(&state) + + if state != "purchase" && state != "done" { + return orm.Values{"invoice_status": "no"}, nil + } + + var totalQty, totalInvoiced float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(product_qty), 0), COALESCE(SUM(qty_invoiced), 0) + FROM purchase_order_line WHERE order_id = $1`, poID, + ).Scan(&totalQty, &totalInvoiced) + + status := "no" + if totalQty > 0 { + if totalInvoiced >= totalQty { + status = "invoiced" + } else { + status = "to invoice" + } + } + return orm.Values{"invoice_status": status}, nil + }) +} diff --git a/addons/purchase/models/purchase_report.go b/addons/purchase/models/purchase_report.go new file mode 100644 index 0000000..ac02439 --- /dev/null +++ b/addons/purchase/models/purchase_report.go @@ -0,0 +1,292 @@ +package models + +import ( + "fmt" + "time" + + "odoo-go/pkg/orm" +) + +// initPurchaseReport registers purchase.report — a transient model for purchase analysis. +// Mirrors: odoo/addons/purchase/report/purchase_report.py +// +// class PurchaseReport(models.Model): +// _name = 'purchase.report' +// _description = 'Purchase Report' +// _auto = False +func initPurchaseReport() { + m := orm.NewModel("purchase.report", orm.ModelOpts{ + Description: "Purchase 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("partner_id", "res.partner", orm.FieldOpts{String: "Vendor"}), + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), + orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{String: "Product Template"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Representative"}), + orm.Many2one("categ_id", "product.category", orm.FieldOpts{String: "Product Category"}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "RFQ"}, + {Value: "purchase", Label: "Purchase Order"}, + {Value: "done", Label: "Done"}, + {Value: "cancel", Label: "Cancelled"}, + }, orm.FieldOpts{String: "Status"}), + ) + + // get_purchase_data: Aggregated purchase data for dashboards and reports. + // Mirrors: odoo/addons/purchase/report/purchase_report.py (SQL view query) + // Returns: { months, top_vendors, top_products, summary } + m.RegisterMethod("get_purchase_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + // ── Spending by month (last 12 months) ── + monthRows, err := env.Tx().Query(env.Ctx(), ` + SELECT date_trunc('month', po.date_order) AS month, + COUNT(DISTINCT po.id) AS order_count, + COALESCE(SUM(po.amount_total::float8), 0) AS spending, + COALESCE(AVG(po.amount_total::float8), 0) AS avg_order + FROM purchase_order po + WHERE po.state IN ('purchase', 'done') + GROUP BY month + ORDER BY month DESC + LIMIT 12`) + if err != nil { + return nil, fmt.Errorf("purchase_report: monthly spending query: %w", err) + } + defer monthRows.Close() + + var months []map[string]interface{} + for monthRows.Next() { + var month time.Time + var cnt int64 + var spending, avg float64 + if err := monthRows.Scan(&month, &cnt, &spending, &avg); err != nil { + continue + } + months = append(months, map[string]interface{}{ + "month": month.Format("2006-01"), + "orders": cnt, + "spending": spending, + "avg_order": avg, + }) + } + + // ── Top 10 vendors by spending ── + vendorRows, err := env.Tx().Query(env.Ctx(), ` + SELECT p.name, + COUNT(DISTINCT po.id) AS orders, + COALESCE(SUM(po.amount_total::float8), 0) AS spending, + COALESCE(AVG(po.amount_total::float8), 0) AS avg_order + FROM purchase_order po + JOIN res_partner p ON p.id = po.partner_id + WHERE po.state IN ('purchase', 'done') + GROUP BY p.id, p.name + ORDER BY spending DESC + LIMIT 10`) + if err != nil { + return nil, fmt.Errorf("purchase_report: top vendors query: %w", err) + } + defer vendorRows.Close() + + var vendors []map[string]interface{} + for vendorRows.Next() { + var name string + var cnt int64 + var spending, avg float64 + if err := vendorRows.Scan(&name, &cnt, &spending, &avg); err != nil { + continue + } + vendors = append(vendors, map[string]interface{}{ + "vendor": name, + "orders": cnt, + "spending": spending, + "avg_order": avg, + }) + } + + // ── Top 10 products by purchase volume ── + prodRows, err := env.Tx().Query(env.Ctx(), ` + SELECT pt.name, + SUM(pol.product_qty) AS qty, + COALESCE(SUM(pol.price_subtotal::float8), 0) AS spending, + COUNT(DISTINCT pol.order_id) AS order_count + FROM purchase_order_line pol + JOIN purchase_order po ON po.id = pol.order_id AND po.state IN ('purchase', 'done') + JOIN product_product pp ON pp.id = pol.product_id + JOIN product_template pt ON pt.id = pp.product_tmpl_id + WHERE pol.product_id IS NOT NULL + GROUP BY pt.name + ORDER BY spending DESC + LIMIT 10`) + if err != nil { + return nil, fmt.Errorf("purchase_report: top products query: %w", err) + } + defer prodRows.Close() + + var products []map[string]interface{} + for prodRows.Next() { + var name string + var qty, spending float64 + var orderCnt int64 + if err := prodRows.Scan(&name, &qty, &spending, &orderCnt); err != nil { + continue + } + products = append(products, map[string]interface{}{ + "product": name, + "qty": qty, + "spending": spending, + "orders": orderCnt, + }) + } + + // ── Summary totals ── + var totalOrders int64 + var totalSpending, avgOrderValue float64 + env.Tx().QueryRow(env.Ctx(), ` + SELECT COUNT(*), COALESCE(SUM(amount_total::float8), 0), COALESCE(AVG(amount_total::float8), 0) + FROM purchase_order WHERE state IN ('purchase', 'done') + `).Scan(&totalOrders, &totalSpending, &avgOrderValue) + + return map[string]interface{}{ + "months": months, + "top_vendors": vendors, + "top_products": products, + "summary": map[string]interface{}{ + "total_orders": totalOrders, + "total_spending": totalSpending, + "avg_order_value": avgOrderValue, + }, + }, nil + }) + + // get_purchases_by_category: Breakdown by product category. + // Mirrors: odoo/addons/purchase/report/purchase_report.py (grouped by categ_id) + m.RegisterMethod("get_purchases_by_category", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT COALESCE(pc.name, 'Uncategorized') AS category, + COUNT(DISTINCT pol.order_id) AS orders, + SUM(pol.product_qty) AS qty, + COALESCE(SUM(pol.price_subtotal::float8), 0) AS spending + FROM purchase_order_line pol + JOIN purchase_order po ON po.id = pol.order_id AND po.state IN ('purchase', 'done') + LEFT JOIN product_product pp ON pp.id = pol.product_id + LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id + LEFT JOIN product_category pc ON pc.id = pt.categ_id + WHERE pol.product_id IS NOT NULL + GROUP BY pc.name + ORDER BY spending DESC`) + if err != nil { + return nil, fmt.Errorf("purchase_report: category query: %w", err) + } + defer rows.Close() + + var results []map[string]interface{} + for rows.Next() { + var name string + var orders int64 + var qty, spending float64 + if err := rows.Scan(&name, &orders, &qty, &spending); err != nil { + continue + } + results = append(results, map[string]interface{}{ + "category": name, + "orders": orders, + "qty": qty, + "spending": spending, + }) + } + return results, nil + }) + + // get_bill_status_analysis: Invoice/bill status analysis. + // Mirrors: odoo/addons/purchase/report/purchase_report.py (invoice_status grouping) + m.RegisterMethod("get_bill_status_analysis", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT COALESCE(invoice_status, 'no') AS status, + COUNT(*) AS count, + COALESCE(SUM(amount_total::float8), 0) AS spending + FROM purchase_order + WHERE state IN ('purchase', 'done') + GROUP BY invoice_status + ORDER BY spending DESC`) + if err != nil { + return nil, fmt.Errorf("purchase_report: bill status query: %w", err) + } + defer rows.Close() + + var results []map[string]interface{} + for rows.Next() { + var status string + var count int64 + var spending float64 + if err := rows.Scan(&status, &count, &spending); err != nil { + continue + } + results = append(results, map[string]interface{}{ + "status": status, + "count": count, + "spending": spending, + }) + } + return results, nil + }) + + // get_receipt_analysis: Receipt/delivery status analysis. + // Mirrors: odoo/addons/purchase/report/purchase_report.py (receipt_status grouping) + m.RegisterMethod("get_receipt_analysis", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT + CASE + WHEN COALESCE(SUM(pol.qty_received), 0) = 0 THEN 'pending' + WHEN SUM(pol.qty_received) < SUM(pol.product_qty) THEN 'partial' + ELSE 'full' + END AS status, + COUNT(DISTINCT po.id) AS count, + COALESCE(SUM(po.amount_total::float8), 0) AS spending + FROM purchase_order po + LEFT JOIN purchase_order_line pol ON pol.order_id = po.id + WHERE po.state IN ('purchase', 'done') + GROUP BY po.id`) + if err != nil { + return nil, fmt.Errorf("purchase_report: receipt analysis query: %w", err) + } + defer rows.Close() + + // Aggregate the per-PO results + statusMap := map[string]map[string]interface{}{ + "pending": {"status": "pending", "count": int64(0), "spending": float64(0)}, + "partial": {"status": "partial", "count": int64(0), "spending": float64(0)}, + "full": {"status": "full", "count": int64(0), "spending": float64(0)}, + } + for rows.Next() { + var status string + var count int64 + var spending float64 + if err := rows.Scan(&status, &count, &spending); err != nil { + continue + } + if entry, ok := statusMap[status]; ok { + entry["count"] = entry["count"].(int64) + count + entry["spending"] = entry["spending"].(float64) + spending + } + } + + var results []map[string]interface{} + for _, v := range statusMap { + if v["count"].(int64) > 0 { + results = append(results, v) + } + } + return results, nil + }) +} diff --git a/addons/sale/models/init.go b/addons/sale/models/init.go index edfcb5d..531bb5a 100644 --- a/addons/sale/models/init.go +++ b/addons/sale/models/init.go @@ -7,6 +7,16 @@ func Init() { initSaleOrderLine() initResPartnerSaleExtension() initSaleMargin() + initSaleOrderTemplate() + initSaleOrderTemplateLine() + initSaleOrderTemplateOption() + initSaleReport() + initSaleOrderWarnMsg() + initSaleAdvancePaymentWizard() + initSaleOrderExtension() + initSaleOrderLineExtension() + initSaleOrderDiscount() + initResPartnerSaleExtension2() } // initResPartnerSaleExtension extends res.partner with sale-specific fields. diff --git a/addons/sale/models/sale_order_extend.go b/addons/sale/models/sale_order_extend.go new file mode 100644 index 0000000..7469121 --- /dev/null +++ b/addons/sale/models/sale_order_extend.go @@ -0,0 +1,443 @@ +package models + +import ( + "fmt" + "time" + + "odoo-go/pkg/orm" +) + +// initSaleOrderExtension extends sale.order with template support, additional workflow +// methods, and computed fields. +// Mirrors: odoo/addons/sale/models/sale_order.py (additional fields) +// odoo/addons/sale_management/models/sale_order.py (template fields) +func initSaleOrderExtension() { + so := orm.ExtendModel("sale.order") + + // -- Template & Additional Fields -- + so.AddFields( + orm.Many2one("sale_order_template_id", "sale.order.template", orm.FieldOpts{ + String: "Quotation Template", + }), + orm.Boolean("is_expired", orm.FieldOpts{ + String: "Expired", Compute: "_compute_is_expired", + }), + orm.Char("client_order_ref", orm.FieldOpts{String: "Customer Reference"}), + orm.Char("origin", orm.FieldOpts{String: "Source Document"}), + orm.Boolean("locked", orm.FieldOpts{String: "Locked", Readonly: true}), + orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Salesperson", Index: true}), + orm.Many2one("team_id", "crm.team", orm.FieldOpts{String: "Sales Team"}), + orm.Char("reference", orm.FieldOpts{String: "Payment Reference"}), + orm.Datetime("commitment_date", orm.FieldOpts{String: "Delivery Date"}), + orm.Datetime("date_last_order_followup", orm.FieldOpts{String: "Last Follow-up Date"}), + orm.Text("internal_note", orm.FieldOpts{String: "Internal Note"}), + ) + + // -- Computed: is_expired -- + // An SO is expired when validity_date is in the past and state is still draft/sent. + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_is_expired() + so.RegisterCompute("is_expired", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + soID := rs.IDs()[0] + var validityDate *time.Time + var state string + env.Tx().QueryRow(env.Ctx(), + `SELECT validity_date, state FROM sale_order WHERE id = $1`, soID, + ).Scan(&validityDate, &state) + + expired := false + if validityDate != nil && (state == "draft" || state == "sent") { + expired = validityDate.Before(time.Now().Truncate(24 * time.Hour)) + } + return orm.Values{"is_expired": expired}, nil + }) + + // -- Computed: _compute_invoice_status (extends the base) -- + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_invoice_status() + so.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + soID := rs.IDs()[0] + + var state string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(state, 'draft') FROM sale_order WHERE id = $1`, soID).Scan(&state) + + // Only compute for confirmed/done orders + if state != "sale" && state != "done" { + return orm.Values{"invoice_status": "no"}, nil + } + + // Check line quantities + var totalQty, totalInvoiced float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(product_uom_qty), 0), COALESCE(SUM(qty_invoiced), 0) + FROM sale_order_line WHERE order_id = $1 + AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`, + soID).Scan(&totalQty, &totalInvoiced) + + status := "no" + if totalQty > 0 { + if totalInvoiced >= totalQty { + status = "invoiced" + } else if totalInvoiced > 0 { + status = "to invoice" + } else { + status = "to invoice" + } + } + return orm.Values{"invoice_status": status}, nil + }) + + // action_quotation_sent: Mark the SO as "sent" (quotation has been emailed). + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_quotation_sent() + so.RegisterMethod("action_quotation_sent", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, soID := range rs.IDs() { + var state string + env.Tx().QueryRow(env.Ctx(), + `SELECT state FROM sale_order WHERE id = $1`, soID).Scan(&state) + if state != "draft" { + continue + } + env.Tx().Exec(env.Ctx(), + `UPDATE sale_order SET state = 'sent' WHERE id = $1`, soID) + } + return true, nil + }) + + // action_lock: Lock a confirmed sale order to prevent modifications. + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_lock() + so.RegisterMethod("action_lock", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, soID := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE sale_order SET locked = true WHERE id = $1`, soID) + } + return true, nil + }) + + // action_unlock: Unlock a confirmed sale order. + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_unlock() + so.RegisterMethod("action_unlock", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, soID := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE sale_order SET locked = false WHERE id = $1`, soID) + } + return true, nil + }) + + // action_view_delivery: Open delivery orders linked to this sale order. + // Mirrors: odoo/addons/sale_stock/models/sale_order.py SaleOrder.action_view_delivery() + so.RegisterMethod("action_view_delivery", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + soID := rs.IDs()[0] + var soName string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, '') FROM sale_order WHERE id = $1`, soID).Scan(&soName) + + rows, err := env.Tx().Query(env.Ctx(), + `SELECT id FROM stock_picking WHERE origin = $1`, soName) + if err != nil { + return nil, fmt.Errorf("sale: view delivery query: %w", err) + } + defer rows.Close() + + var pickingIDs []interface{} + for rows.Next() { + var id int64 + rows.Scan(&id) + pickingIDs = append(pickingIDs, id) + } + + if len(pickingIDs) == 1 { + return map[string]interface{}{ + "type": "ir.actions.act_window", "res_model": "stock.picking", + "res_id": pickingIDs[0], "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, "target": "current", + }, nil + } + return map[string]interface{}{ + "type": "ir.actions.act_window", "res_model": "stock.picking", + "view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "domain": []interface{}{[]interface{}{"id", "in", pickingIDs}}, "target": "current", + }, nil + }) + + // preview_quotation: Generate a preview URL for the quotation portal. + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.preview_sale_order() + so.RegisterMethod("preview_quotation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + soID := rs.IDs()[0] + return map[string]interface{}{ + "type": "ir.actions.act_url", + "url": fmt.Sprintf("/my/orders/%d", soID), + "target": "new", + }, nil + }) + + // action_open_discount_wizard: Open the discount wizard. + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_open_discount_wizard() + so.RegisterMethod("action_open_discount_wizard", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "sale.order.discount", + "view_mode": "form", + "target": "new", + }, nil + }) + + // _get_order_confirmation_date: Return the date when the SO was confirmed. + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._get_order_confirmation_date() + so.RegisterMethod("_get_order_confirmation_date", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + soID := rs.IDs()[0] + var dateOrder *time.Time + var state string + env.Tx().QueryRow(env.Ctx(), + `SELECT date_order, state FROM sale_order WHERE id = $1`, soID).Scan(&dateOrder, &state) + if state == "sale" || state == "done" { + return dateOrder, nil + } + return nil, nil + }) + + // _compute_amount_to_invoice: Compute total amount still to invoice. + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amounts() + so.RegisterMethod("_compute_amount_to_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + soID := rs.IDs()[0] + var total, invoiced float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(price_subtotal::float8), 0), + COALESCE(SUM(qty_invoiced * price_unit * (1 - COALESCE(discount,0)/100))::float8, 0) + FROM sale_order_line WHERE order_id = $1 + AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`, + soID).Scan(&total, &invoiced) + return total - invoiced, nil + }) +} + +// initSaleOrderLineExtension extends sale.order.line with additional fields and methods. +// Mirrors: odoo/addons/sale/models/sale_order_line.py (additional fields) +func initSaleOrderLineExtension() { + sol := orm.ExtendModel("sale.order.line") + + sol.AddFields( + orm.Many2one("salesman_id", "res.users", orm.FieldOpts{String: "Salesperson"}), + orm.Selection("invoice_status", []orm.SelectionItem{ + {Value: "upselling", Label: "Upselling Opportunity"}, + {Value: "invoiced", Label: "Fully Invoiced"}, + {Value: "to invoice", Label: "To Invoice"}, + {Value: "no", Label: "Nothing to Invoice"}, + }, orm.FieldOpts{String: "Invoice Status", Compute: "_compute_invoice_status_line"}), + orm.Boolean("is_downpayment", orm.FieldOpts{String: "Is a down payment"}), + orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), + orm.Float("product_packaging_qty", orm.FieldOpts{String: "Packaging Quantity"}), + orm.Char("product_type", orm.FieldOpts{String: "Product Type"}), + orm.Boolean("product_updatable", orm.FieldOpts{String: "Can Edit Product", Default: true}), + orm.Float("price_reduce", orm.FieldOpts{ + String: "Price Reduce", Compute: "_compute_price_reduce", + }), + orm.Float("price_reduce_taxinc", orm.FieldOpts{ + String: "Price Reduce Tax inc", Compute: "_compute_price_reduce_taxinc", + }), + orm.Float("price_reduce_taxexcl", orm.FieldOpts{ + String: "Price Reduce Tax excl", Compute: "_compute_price_reduce_taxexcl", + }), + orm.Monetary("untaxed_amount_to_invoice", orm.FieldOpts{ + String: "Untaxed Amount To Invoice", Compute: "_compute_untaxed_amount_to_invoice", + CurrencyField: "currency_id", + }), + orm.Monetary("untaxed_amount_invoiced", orm.FieldOpts{ + String: "Untaxed Amount Invoiced", Compute: "_compute_untaxed_amount_invoiced", + CurrencyField: "currency_id", + }), + ) + + // _compute_invoice_status_line: Per-line invoice status. + // Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_invoice_status() + sol.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + + var qty, qtyInvoiced float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(product_uom_qty, 0), COALESCE(qty_invoiced, 0) + FROM sale_order_line WHERE id = $1`, lineID, + ).Scan(&qty, &qtyInvoiced) + + status := "no" + if qty > 0 { + if qtyInvoiced >= qty { + status = "invoiced" + } else { + status = "to invoice" + } + } + return orm.Values{"invoice_status": status}, nil + }) + + // _compute_price_reduce: Unit price after discount. + sol.RegisterCompute("price_reduce", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var price, discount float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(price_unit, 0), COALESCE(discount, 0) FROM sale_order_line WHERE id = $1`, + lineID).Scan(&price, &discount) + return orm.Values{"price_reduce": price * (1 - discount/100)}, nil + }) + + // _compute_price_reduce_taxinc: Reduced price including taxes. + sol.RegisterCompute("price_reduce_taxinc", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var priceTotal, qty float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(price_total, 0), COALESCE(product_uom_qty, 1) FROM sale_order_line WHERE id = $1`, + lineID).Scan(&priceTotal, &qty) + val := float64(0) + if qty > 0 { + val = priceTotal / qty + } + return orm.Values{"price_reduce_taxinc": val}, nil + }) + + // _compute_price_reduce_taxexcl: Reduced price excluding taxes. + sol.RegisterCompute("price_reduce_taxexcl", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var priceSubtotal, qty float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(price_subtotal, 0), COALESCE(product_uom_qty, 1) FROM sale_order_line WHERE id = $1`, + lineID).Scan(&priceSubtotal, &qty) + val := float64(0) + if qty > 0 { + val = priceSubtotal / qty + } + return orm.Values{"price_reduce_taxexcl": val}, nil + }) + + // _compute_untaxed_amount_to_invoice + sol.RegisterCompute("untaxed_amount_to_invoice", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var qty, qtyInvoiced, price, discount float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(product_uom_qty, 0), COALESCE(qty_invoiced, 0), + COALESCE(price_unit, 0), COALESCE(discount, 0) + FROM sale_order_line WHERE id = $1`, lineID, + ).Scan(&qty, &qtyInvoiced, &price, &discount) + remaining := qty - qtyInvoiced + if remaining < 0 { + remaining = 0 + } + return orm.Values{"untaxed_amount_to_invoice": remaining * price * (1 - discount/100)}, nil + }) + + // _compute_untaxed_amount_invoiced + sol.RegisterCompute("untaxed_amount_invoiced", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var qtyInvoiced, price, discount float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(qty_invoiced, 0), COALESCE(price_unit, 0), COALESCE(discount, 0) + FROM sale_order_line WHERE id = $1`, lineID, + ).Scan(&qtyInvoiced, &price, &discount) + return orm.Values{"untaxed_amount_invoiced": qtyInvoiced * price * (1 - discount/100)}, nil + }) + + // _compute_qty_invoiced: Compute invoiced quantity from linked invoice lines. + // Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_invoiced() + sol.RegisterCompute("qty_invoiced", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var qtyInvoiced float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(qty_invoiced, 0) FROM sale_order_line WHERE id = $1`, lineID, + ).Scan(&qtyInvoiced) + return orm.Values{"qty_invoiced": qtyInvoiced}, nil + }) + + // _compute_qty_to_invoice: Quantity to invoice = qty - qty_invoiced (if delivered policy: qty_delivered - qty_invoiced). + // Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_to_invoice() + sol.RegisterCompute("qty_to_invoice", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var qty, qtyInvoiced float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(product_uom_qty, 0), COALESCE(qty_invoiced, 0) + FROM sale_order_line WHERE id = $1`, lineID, + ).Scan(&qty, &qtyInvoiced) + toInvoice := qty - qtyInvoiced + if toInvoice < 0 { + toInvoice = 0 + } + return orm.Values{"qty_to_invoice": toInvoice}, nil + }) +} + +// initSaleOrderDiscount registers the sale.order.discount wizard. +// Mirrors: odoo/addons/sale/wizard/sale_order_discount.py +func initSaleOrderDiscount() { + m := orm.NewModel("sale.order.discount", orm.ModelOpts{ + Description: "Sale Order Discount Wizard", + Type: orm.ModelTransient, + }) + + m.AddFields( + orm.Float("discount", orm.FieldOpts{String: "Discount (%)", Required: true}), + orm.Many2one("sale_order_id", "sale.order", orm.FieldOpts{String: "Sale Order"}), + ) + + // action_apply_discount: Apply the discount to all lines of the SO. + // Mirrors: odoo/addons/sale/wizard/sale_order_discount.py action_apply_discount() + m.RegisterMethod("action_apply_discount", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + wizID := rs.IDs()[0] + var discount float64 + var orderID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(discount, 0), COALESCE(sale_order_id, 0) + FROM sale_order_discount WHERE id = $1`, wizID, + ).Scan(&discount, &orderID) + + if orderID == 0 { + return nil, fmt.Errorf("sale_discount: no sale order linked") + } + + _, err := env.Tx().Exec(env.Ctx(), + `UPDATE sale_order_line SET discount = $1 + WHERE order_id = $2 + AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`, + discount, orderID) + if err != nil { + return nil, fmt.Errorf("sale_discount: apply discount: %w", err) + } + + return true, nil + }) +} + +// initResPartnerSaleExtension2 adds additional sale-specific computed fields to res.partner. +// Mirrors: odoo/addons/sale/models/res_partner.py (additional fields) +func initResPartnerSaleExtension2() { + partner := orm.ExtendModel("res.partner") + + partner.AddFields( + orm.Monetary("sale_order_total", orm.FieldOpts{ + String: "Total Sales", Compute: "_compute_sale_order_total", CurrencyField: "currency_id", + }), + ) + + partner.RegisterCompute("sale_order_total", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + partnerID := rs.IDs()[0] + var total float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(amount_total::float8), 0) FROM sale_order + WHERE partner_id = $1 AND state IN ('sale', 'done')`, partnerID, + ).Scan(&total) + return orm.Values{"sale_order_total": total}, nil + }) +} diff --git a/addons/sale/models/sale_report.go b/addons/sale/models/sale_report.go new file mode 100644 index 0000000..f270237 --- /dev/null +++ b/addons/sale/models/sale_report.go @@ -0,0 +1,421 @@ +package models + +import ( + "fmt" + "time" + + "odoo-go/pkg/orm" +) + +// initSaleReport registers sale.report — a transient model for sales analysis. +// Mirrors: odoo/addons/sale/report/sale_report.py +// +// class SaleReport(models.Model): +// _name = 'sale.report' +// _description = 'Sales Analysis Report' +// _auto = False +func initSaleReport() { + m := orm.NewModel("sale.report", orm.ModelOpts{ + Description: "Sales 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("partner_id", "res.partner", orm.FieldOpts{String: "Customer"}), + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), + orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{String: "Product Template"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Salesperson"}), + orm.Many2one("team_id", "crm.team", orm.FieldOpts{String: "Sales Team"}), + orm.Many2one("categ_id", "product.category", orm.FieldOpts{String: "Product Category"}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "Quotation"}, + {Value: "sale", Label: "Sales Order"}, + {Value: "done", Label: "Done"}, + {Value: "cancel", Label: "Cancelled"}, + }, orm.FieldOpts{String: "Status"}), + ) + + // get_sales_data: Retrieve aggregated sales data for dashboards and reports. + // Mirrors: odoo/addons/sale/report/sale_report.py (the SQL view query logic) + // Returns: { months, top_products, top_customers, summary } + m.RegisterMethod("get_sales_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + // ── Revenue by month (last 12 months) ── + monthRows, err := env.Tx().Query(env.Ctx(), ` + SELECT date_trunc('month', so.date_order) AS month, + COUNT(DISTINCT so.id) AS order_count, + COALESCE(SUM(so.amount_total::float8), 0) AS revenue, + COALESCE(AVG(so.amount_total::float8), 0) AS avg_order + FROM sale_order so + WHERE so.state IN ('sale', 'done') + GROUP BY month + ORDER BY month DESC + LIMIT 12`) + if err != nil { + return nil, fmt.Errorf("sale_report: monthly revenue query: %w", err) + } + defer monthRows.Close() + + var months []map[string]interface{} + for monthRows.Next() { + var month time.Time + var cnt int64 + var rev, avg float64 + if err := monthRows.Scan(&month, &cnt, &rev, &avg); err != nil { + continue + } + months = append(months, map[string]interface{}{ + "month": month.Format("2006-01"), + "orders": cnt, + "revenue": rev, + "avg_order": avg, + }) + } + + // ── Top 10 products by revenue ── + prodRows, err := env.Tx().Query(env.Ctx(), ` + SELECT pt.name, + SUM(sol.product_uom_qty) AS qty, + COALESCE(SUM(sol.price_subtotal::float8), 0) AS revenue, + COUNT(DISTINCT sol.order_id) AS order_count + FROM sale_order_line sol + JOIN sale_order so ON so.id = sol.order_id AND so.state IN ('sale', 'done') + JOIN product_product pp ON pp.id = sol.product_id + JOIN product_template pt ON pt.id = pp.product_tmpl_id + WHERE sol.product_id IS NOT NULL + GROUP BY pt.name + ORDER BY revenue DESC + LIMIT 10`) + if err != nil { + return nil, fmt.Errorf("sale_report: top products query: %w", err) + } + defer prodRows.Close() + + var products []map[string]interface{} + for prodRows.Next() { + var name string + var qty, rev float64 + var orderCnt int64 + if err := prodRows.Scan(&name, &qty, &rev, &orderCnt); err != nil { + continue + } + products = append(products, map[string]interface{}{ + "product": name, + "qty": qty, + "revenue": rev, + "orders": orderCnt, + }) + } + + // ── Top 10 customers by revenue ── + custRows, err := env.Tx().Query(env.Ctx(), ` + SELECT p.name, + COUNT(DISTINCT so.id) AS orders, + COALESCE(SUM(so.amount_total::float8), 0) AS revenue, + COALESCE(AVG(so.amount_total::float8), 0) AS avg_order + FROM sale_order so + JOIN res_partner p ON p.id = so.partner_id + WHERE so.state IN ('sale', 'done') + GROUP BY p.id, p.name + ORDER BY revenue DESC + LIMIT 10`) + if err != nil { + return nil, fmt.Errorf("sale_report: top customers query: %w", err) + } + defer custRows.Close() + + var customers []map[string]interface{} + for custRows.Next() { + var name string + var cnt int64 + var rev, avg float64 + if err := custRows.Scan(&name, &cnt, &rev, &avg); err != nil { + continue + } + customers = append(customers, map[string]interface{}{ + "customer": name, + "orders": cnt, + "revenue": rev, + "avg_order": avg, + }) + } + + // ── Summary totals ── + var totalOrders int64 + var totalRevenue, avgOrderValue float64 + env.Tx().QueryRow(env.Ctx(), ` + SELECT COUNT(*), COALESCE(SUM(amount_total::float8), 0), COALESCE(AVG(amount_total::float8), 0) + FROM sale_order WHERE state IN ('sale', 'done') + `).Scan(&totalOrders, &totalRevenue, &avgOrderValue) + + return map[string]interface{}{ + "months": months, + "top_products": products, + "top_customers": customers, + "summary": map[string]interface{}{ + "total_orders": totalOrders, + "total_revenue": totalRevenue, + "avg_order_value": avgOrderValue, + }, + }, nil + }) + + // get_sales_by_salesperson: Breakdown by salesperson. + // Mirrors: odoo/addons/sale/report/sale_report.py (grouped by user_id) + m.RegisterMethod("get_sales_by_salesperson", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT COALESCE(rp.name, 'Unassigned') AS salesperson, + COUNT(DISTINCT so.id) AS orders, + COALESCE(SUM(so.amount_total::float8), 0) AS revenue, + COALESCE(AVG(so.amount_total::float8), 0) AS avg_order + FROM sale_order so + LEFT JOIN res_users ru ON ru.id = so.user_id + LEFT JOIN res_partner rp ON rp.id = ru.partner_id + WHERE so.state IN ('sale', 'done') + GROUP BY rp.name + ORDER BY revenue DESC`) + if err != nil { + return nil, fmt.Errorf("sale_report: salesperson query: %w", err) + } + defer rows.Close() + + var results []map[string]interface{} + for rows.Next() { + var name string + var orders int64 + var rev, avg float64 + if err := rows.Scan(&name, &orders, &rev, &avg); err != nil { + continue + } + results = append(results, map[string]interface{}{ + "salesperson": name, + "orders": orders, + "revenue": rev, + "avg_order": avg, + }) + } + return results, nil + }) + + // get_sales_by_category: Breakdown by product category. + // Mirrors: odoo/addons/sale/report/sale_report.py (grouped by categ_id) + m.RegisterMethod("get_sales_by_category", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT COALESCE(pc.name, 'Uncategorized') AS category, + COUNT(DISTINCT sol.order_id) AS orders, + SUM(sol.product_uom_qty) AS qty, + COALESCE(SUM(sol.price_subtotal::float8), 0) AS revenue + FROM sale_order_line sol + JOIN sale_order so ON so.id = sol.order_id AND so.state IN ('sale', 'done') + LEFT JOIN product_product pp ON pp.id = sol.product_id + LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id + LEFT JOIN product_category pc ON pc.id = pt.categ_id + WHERE sol.product_id IS NOT NULL + GROUP BY pc.name + ORDER BY revenue DESC`) + if err != nil { + return nil, fmt.Errorf("sale_report: category query: %w", err) + } + defer rows.Close() + + var results []map[string]interface{} + for rows.Next() { + var name string + var orders int64 + var qty, rev float64 + if err := rows.Scan(&name, &orders, &qty, &rev); err != nil { + continue + } + results = append(results, map[string]interface{}{ + "category": name, + "orders": orders, + "qty": qty, + "revenue": rev, + }) + } + return results, nil + }) + + // get_invoice_analysis: Invoice status analysis. + // Mirrors: odoo/addons/sale/report/sale_report.py (invoice_status grouping) + m.RegisterMethod("get_invoice_analysis", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT COALESCE(invoice_status, 'no') AS status, + COUNT(*) AS count, + COALESCE(SUM(amount_total::float8), 0) AS revenue + FROM sale_order + WHERE state IN ('sale', 'done') + GROUP BY invoice_status + ORDER BY revenue DESC`) + if err != nil { + return nil, fmt.Errorf("sale_report: invoice analysis query: %w", err) + } + defer rows.Close() + + var results []map[string]interface{} + for rows.Next() { + var status string + var count int64 + var rev float64 + if err := rows.Scan(&status, &count, &rev); err != nil { + continue + } + results = append(results, map[string]interface{}{ + "status": status, + "count": count, + "revenue": rev, + }) + } + return results, nil + }) +} + +// initSaleOrderWarnMsg registers the sale.order.onchange.warning transient model. +// Mirrors: odoo/addons/sale/wizard/sale_order_cancel.py (cancel warning dialog) +func initSaleOrderWarnMsg() { + m := orm.NewModel("sale.order.cancel", orm.ModelOpts{ + Description: "Sale Order Cancel", + Type: orm.ModelTransient, + }) + + m.AddFields( + orm.Many2one("order_id", "sale.order", orm.FieldOpts{String: "Sale Order"}), + orm.Text("display_name", orm.FieldOpts{String: "Warning"}), + ) + + // action_cancel: Confirm the cancellation of the sale order. + m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + wizID := rs.IDs()[0] + var orderID int64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT order_id FROM sale_order_cancel WHERE id = $1`, wizID).Scan(&orderID) + if err != nil { + return nil, fmt.Errorf("sale_cancel: read wizard %d: %w", wizID, err) + } + soRS := env.Model("sale.order").Browse(orderID) + soModel := orm.Registry.Get("sale.order") + if fn, ok := soModel.Methods["action_cancel"]; ok { + return fn(soRS) + } + return nil, fmt.Errorf("sale_cancel: action_cancel method not found") + }) +} + +// initSaleAdvancePaymentWizard registers the sale.advance.payment.inv wizard. +// Mirrors: odoo/addons/sale/wizard/sale_make_invoice_advance.py +// +// class SaleAdvancePaymentInv(models.TransientModel): +// _name = 'sale.advance.payment.inv' +// _description = 'Sales Advance Payment Invoice' +func initSaleAdvancePaymentWizard() { + m := orm.NewModel("sale.advance.payment.inv", orm.ModelOpts{ + Description: "Sales Advance Payment Invoice", + Type: orm.ModelTransient, + }) + + m.AddFields( + orm.Selection("advance_payment_method", []orm.SelectionItem{ + {Value: "delivered", Label: "Regular invoice"}, + {Value: "percentage", Label: "Down payment (percentage)"}, + {Value: "fixed", Label: "Down payment (fixed amount)"}, + }, orm.FieldOpts{String: "Create Invoice", Default: "delivered", Required: true}), + orm.Float("amount", orm.FieldOpts{String: "Down Payment Amount", Default: 0}), + orm.Boolean("has_down_payments", orm.FieldOpts{String: "Has down payments"}), + orm.Boolean("deduct_down_payments", orm.FieldOpts{String: "Deduct down payments", Default: true}), + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Down Payment Product"}), + orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), + orm.Float("fixed_amount", orm.FieldOpts{String: "Fixed Amount"}), + orm.Integer("count", orm.FieldOpts{String: "Order Count"}), + orm.Many2many("sale_order_ids", "sale.order", orm.FieldOpts{String: "Sale Orders"}), + ) + + // create_invoices: Generate invoices based on the wizard settings. + // Mirrors: odoo/addons/sale/wizard/sale_make_invoice_advance.py create_invoices() + m.RegisterMethod("create_invoices", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + wizID := rs.IDs()[0] + + // Read wizard settings + var method string + var amount, fixedAmount float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(advance_payment_method, 'delivered'), + COALESCE(amount, 0), COALESCE(fixed_amount, 0) + FROM sale_advance_payment_inv WHERE id = $1`, wizID, + ).Scan(&method, &amount, &fixedAmount) + + // Get linked sale order IDs from the M2M or from context + soRows, err := env.Tx().Query(env.Ctx(), + `SELECT sale_order_id FROM sale_order_sale_advance_payment_inv_rel + WHERE sale_advance_payment_inv_id = $1`, wizID) + if err != nil { + return nil, fmt.Errorf("sale_advance_wiz: read SO IDs: %w", err) + } + defer soRows.Close() + + var soIDs []int64 + for soRows.Next() { + var id int64 + soRows.Scan(&id) + soIDs = append(soIDs, id) + } + + if len(soIDs) == 0 { + return nil, fmt.Errorf("sale_advance_wiz: no sale orders linked") + } + + soModel := orm.Registry.Get("sale.order") + + switch method { + case "delivered": + // Create regular invoices for all linked SOs + soRS := env.Model("sale.order").Browse(soIDs...) + if fn, ok := soModel.Methods["create_invoices"]; ok { + return fn(soRS) + } + return nil, fmt.Errorf("sale_advance_wiz: create_invoices method not found") + case "percentage": + // Create down payment invoices + for _, soID := range soIDs { + soRS := env.Model("sale.order").Browse(soID) + if fn, ok := soModel.Methods["action_create_down_payment"]; ok { + if _, err := fn(soRS, amount); err != nil { + return nil, fmt.Errorf("sale_advance_wiz: down payment for SO %d: %w", soID, err) + } + } + } + return true, nil + case "fixed": + // Create fixed-amount down payment (treat as percentage by computing %) + for _, soID := range soIDs { + var total float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(amount_total::float8, 0) FROM sale_order WHERE id = $1`, soID).Scan(&total) + pct := float64(0) + if total > 0 { + pct = fixedAmount / total * 100 + } + soRS := env.Model("sale.order").Browse(soID) + if fn, ok := soModel.Methods["action_create_down_payment"]; ok { + if _, err := fn(soRS, pct); err != nil { + return nil, fmt.Errorf("sale_advance_wiz: fixed down payment for SO %d: %w", soID, err) + } + } + } + return true, nil + } + + return nil, fmt.Errorf("sale_advance_wiz: unknown method %q", method) + }) +} diff --git a/addons/sale/models/sale_template.go b/addons/sale/models/sale_template.go new file mode 100644 index 0000000..0c3f213 --- /dev/null +++ b/addons/sale/models/sale_template.go @@ -0,0 +1,292 @@ +package models + +import ( + "fmt" + + "odoo-go/pkg/orm" +) + +// initSaleOrderTemplate registers sale.order.template and sale.order.template.line. +// Mirrors: odoo/addons/sale_management/models/sale_order_template.py +// +// class SaleOrderTemplate(models.Model): +// _name = 'sale.order.template' +// _description = 'Quotation Template' +func initSaleOrderTemplate() { + m := orm.NewModel("sale.order.template", orm.ModelOpts{ + Description: "Quotation Template", + Order: "sequence, id", + }) + + // -- Identity -- + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Integer("number_of_days", orm.FieldOpts{String: "Validity (Days)", Default: 30}), + orm.Text("note", orm.FieldOpts{String: "Terms and Conditions", Translate: true}), + orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), + orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), + orm.Many2one("mail_template_id", "mail.template", orm.FieldOpts{String: "Confirmation Mail"}), + ) + + // -- Lines -- + m.AddFields( + orm.One2many("sale_order_template_line_ids", "sale.order.template.line", "sale_order_template_id", orm.FieldOpts{ + String: "Lines", + }), + orm.One2many("sale_order_template_option_ids", "sale.order.template.option", "sale_order_template_id", orm.FieldOpts{ + String: "Optional Products", + }), + ) + + // -- Computed: line_count -- + m.AddFields( + orm.Integer("line_count", orm.FieldOpts{ + String: "Line Count", Compute: "_compute_line_count", + }), + ) + m.RegisterCompute("line_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + templateID := rs.IDs()[0] + var count int + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM sale_order_template_line WHERE sale_order_template_id = $1`, + templateID).Scan(&count) + if err != nil { + count = 0 + } + return orm.Values{"line_count": count}, nil + }) + + // action_apply_to_order: Apply this template to a sale order. + // Mirrors: odoo/addons/sale_management/models/sale_order.py SaleOrder._onchange_sale_order_template_id() + // Copies template lines into the SO as order lines, and copies the template note. + m.RegisterMethod("action_apply_to_order", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("order_id required") + } + env := rs.Env() + templateID := rs.IDs()[0] + orderID, _ := args[0].(float64) + + // Read template lines + rows, err := env.Tx().Query(env.Ctx(), + `SELECT name, product_id, product_uom_qty, price_unit, discount, sequence + FROM sale_order_template_line + WHERE sale_order_template_id = $1 ORDER BY sequence`, templateID) + if err != nil { + return nil, fmt.Errorf("sale_template: read lines: %w", err) + } + defer rows.Close() + + lineRS := env.Model("sale.order.line") + for rows.Next() { + var name string + var prodID *int64 + var qty, price, disc float64 + var seq int + if err := rows.Scan(&name, &prodID, &qty, &price, &disc, &seq); err != nil { + return nil, fmt.Errorf("sale_template: scan line: %w", err) + } + vals := orm.Values{ + "order_id": int64(orderID), + "name": name, + "product_uom_qty": qty, + "price_unit": price, + "discount": disc, + "sequence": seq, + } + if prodID != nil { + vals["product_id"] = *prodID + } + if _, err := lineRS.Create(vals); err != nil { + return nil, fmt.Errorf("sale_template: create SO line: %w", err) + } + } + + // Copy template note to the SO + var note *string + env.Tx().QueryRow(env.Ctx(), + `SELECT note FROM sale_order_template WHERE id = $1`, templateID).Scan(¬e) + if note != nil { + env.Tx().Exec(env.Ctx(), + `UPDATE sale_order SET note = $1 WHERE id = $2`, *note, int64(orderID)) + } + + // Copy validity_date from number_of_days + var numDays int + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(number_of_days, 0) FROM sale_order_template WHERE id = $1`, + templateID).Scan(&numDays) + if numDays > 0 { + env.Tx().Exec(env.Ctx(), + `UPDATE sale_order SET validity_date = CURRENT_DATE + $1 WHERE id = $2`, + numDays, int64(orderID)) + } + + return true, nil + }) + + // action_open_template: Return an action to open this template's form view. + // Mirrors: odoo/addons/sale_management/models/sale_order_template.py + m.RegisterMethod("action_open_template", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + templateID := rs.IDs()[0] + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "sale.order.template", + "res_id": templateID, + "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, + "target": "current", + }, nil + }) +} + +// initSaleOrderTemplateLine registers sale.order.template.line. +// Mirrors: odoo/addons/sale_management/models/sale_order_template.py SaleOrderTemplateLine +func initSaleOrderTemplateLine() { + m := orm.NewModel("sale.order.template.line", orm.ModelOpts{ + Description: "Quotation Template Line", + Order: "sequence, id", + }) + + m.AddFields( + orm.Many2one("sale_order_template_id", "sale.order.template", orm.FieldOpts{ + String: "Template", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, + }), + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), + orm.Char("name", orm.FieldOpts{String: "Description", Required: true, Translate: true}), + orm.Float("product_uom_qty", orm.FieldOpts{String: "Quantity", Default: 1}), + orm.Many2one("product_uom_id", "uom.uom", orm.FieldOpts{String: "Unit of Measure"}), + orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}), + orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}), + orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), + orm.Selection("display_type", []orm.SelectionItem{ + {Value: "line_section", Label: "Section"}, + {Value: "line_note", Label: "Note"}, + }, orm.FieldOpts{String: "Display Type"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + ) + + // Onchange: product_id → name + price_unit + // Mirrors: odoo/addons/sale_management/models/sale_order_template.py SaleOrderTemplateLine._compute_name() + m.RegisterOnchange("product_id", func(env *orm.Environment, vals orm.Values) orm.Values { + result := make(orm.Values) + + var productID int64 + switch v := vals["product_id"].(type) { + case int64: + productID = v + case float64: + productID = int64(v) + case map[string]interface{}: + if id, ok := v["id"]; ok { + switch n := id.(type) { + case float64: + productID = int64(n) + case int64: + productID = n + } + } + } + if productID <= 0 { + return result + } + + var name string + var listPrice float64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(pt.name, ''), COALESCE(pt.list_price, 0) + FROM product_product pp + JOIN product_template pt ON pt.id = pp.product_tmpl_id + WHERE pp.id = $1`, productID, + ).Scan(&name, &listPrice) + if err != nil { + return result + } + + result["name"] = name + result["price_unit"] = listPrice + return result + }) + + // _compute_price_subtotal: qty * price * (1 - discount/100) + m.AddFields( + orm.Monetary("price_subtotal", orm.FieldOpts{ + String: "Subtotal", Compute: "_compute_price_subtotal", CurrencyField: "currency_id", + }), + ) + m.RegisterCompute("price_subtotal", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var qty, price, discount float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(product_uom_qty, 0), COALESCE(price_unit, 0), COALESCE(discount, 0) + FROM sale_order_template_line WHERE id = $1`, lineID, + ).Scan(&qty, &price, &discount) + return orm.Values{"price_subtotal": qty * price * (1 - discount/100)}, nil + }) +} + +// initSaleOrderTemplateOption registers sale.order.template.option. +// Mirrors: odoo/addons/sale_management/models/sale_order_template.py SaleOrderTemplateOption +func initSaleOrderTemplateOption() { + m := orm.NewModel("sale.order.template.option", orm.ModelOpts{ + Description: "Quotation Template Option", + Order: "sequence, id", + }) + + m.AddFields( + orm.Many2one("sale_order_template_id", "sale.order.template", orm.FieldOpts{ + String: "Template", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, + }), + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product", Required: true}), + orm.Char("name", orm.FieldOpts{String: "Description", Required: true, Translate: true}), + orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1}), + orm.Many2one("uom_id", "uom.uom", orm.FieldOpts{String: "Unit of Measure"}), + orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}), + orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}), + orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), + ) + + // Onchange: product_id → name + price_unit + m.RegisterOnchange("product_id", func(env *orm.Environment, vals orm.Values) orm.Values { + result := make(orm.Values) + + var productID int64 + switch v := vals["product_id"].(type) { + case int64: + productID = v + case float64: + productID = int64(v) + case map[string]interface{}: + if id, ok := v["id"]; ok { + switch n := id.(type) { + case float64: + productID = int64(n) + case int64: + productID = n + } + } + } + if productID <= 0 { + return result + } + + var name string + var listPrice float64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(pt.name, ''), COALESCE(pt.list_price, 0) + FROM product_product pp + JOIN product_template pt ON pt.id = pp.product_tmpl_id + WHERE pp.id = $1`, productID, + ).Scan(&name, &listPrice) + if err != nil { + return result + } + + result["name"] = name + result["price_unit"] = listPrice + return result + }) +} diff --git a/pkg/orm/domain_parse_test.go b/pkg/orm/domain_parse_test.go new file mode 100644 index 0000000..7417868 --- /dev/null +++ b/pkg/orm/domain_parse_test.go @@ -0,0 +1,277 @@ +package orm + +import "testing" + +func TestParseDomainStringSimple(t *testing.T) { + domain, err := ParseDomainString("[('name', '=', 'test')]", nil) + if err != nil { + t.Fatal(err) + } + if len(domain) != 1 { + t.Fatalf("expected 1 node, got %d", len(domain)) + } + cond, ok := domain[0].(Condition) + if !ok { + t.Fatal("expected Condition") + } + if cond.Field != "name" { + t.Errorf("field: %s", cond.Field) + } + if cond.Operator != "=" { + t.Errorf("op: %s", cond.Operator) + } + if cond.Value != "test" { + t.Errorf("value: %v", cond.Value) + } +} + +func TestParseDomainStringNumeric(t *testing.T) { + domain, err := ParseDomainString("[('age', '>', 18)]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + if cond.Value != int64(18) { + t.Errorf("expected int64(18), got %T %v", cond.Value, cond.Value) + } +} + +func TestParseDomainStringFloat(t *testing.T) { + domain, err := ParseDomainString("[('amount', '>', 99.5)]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + if cond.Value != float64(99.5) { + t.Errorf("expected float64(99.5), got %T %v", cond.Value, cond.Value) + } +} + +func TestParseDomainStringNegativeNumber(t *testing.T) { + domain, err := ParseDomainString("[('balance', '<', -100)]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + if cond.Value != int64(-100) { + t.Errorf("expected int64(-100), got %T %v", cond.Value, cond.Value) + } +} + +func TestParseDomainStringBoolean(t *testing.T) { + domain, err := ParseDomainString("[('active', '=', True)]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + if cond.Value != true { + t.Errorf("expected true, got %v", cond.Value) + } +} + +func TestParseDomainStringBooleanFalse(t *testing.T) { + domain, err := ParseDomainString("[('active', '=', False)]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + if cond.Value != false { + t.Errorf("expected false, got %v", cond.Value) + } +} + +func TestParseDomainStringList(t *testing.T) { + domain, err := ParseDomainString("[('id', 'in', [1, 2, 3])]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + vals, ok := cond.Value.([]int64) + if !ok { + t.Fatalf("expected []int64, got %T", cond.Value) + } + if len(vals) != 3 { + t.Errorf("expected 3, got %d", len(vals)) + } + if vals[0] != 1 || vals[1] != 2 || vals[2] != 3 { + t.Errorf("values: %v", vals) + } +} + +func TestParseDomainStringStringList(t *testing.T) { + domain, err := ParseDomainString("[('state', 'in', ['draft', 'sent'])]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + vals, ok := cond.Value.([]string) + if !ok { + t.Fatalf("expected []string, got %T", cond.Value) + } + if len(vals) != 2 { + t.Errorf("expected 2, got %d", len(vals)) + } +} + +func TestParseDomainStringEmptyList(t *testing.T) { + domain, err := ParseDomainString("[('id', 'in', [])]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + vals, ok := cond.Value.([]int64) + if !ok { + t.Fatalf("expected []int64, got %T", cond.Value) + } + if len(vals) != 0 { + t.Errorf("expected 0, got %d", len(vals)) + } +} + +func TestParseDomainStringOperators(t *testing.T) { + domain, err := ParseDomainString("['&', ('a', '=', 1), ('b', '=', 2)]", nil) + if err != nil { + t.Fatal(err) + } + if len(domain) != 3 { + t.Fatalf("expected 3 nodes, got %d", len(domain)) + } + if domain[0] != OpAnd { + t.Error("expected & operator") + } +} + +func TestParseDomainStringOrOperator(t *testing.T) { + domain, err := ParseDomainString("['|', ('a', '=', 1), ('b', '=', 2)]", nil) + if err != nil { + t.Fatal(err) + } + if len(domain) != 3 { + t.Fatalf("expected 3 nodes, got %d", len(domain)) + } + if domain[0] != OpOr { + t.Error("expected | operator") + } +} + +func TestParseDomainStringNotOperator(t *testing.T) { + domain, err := ParseDomainString("['!', ('active', '=', True)]", nil) + if err != nil { + t.Fatal(err) + } + if len(domain) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(domain)) + } + if domain[0] != OpNot { + t.Error("expected ! operator") + } +} + +func TestParseDomainStringEmpty(t *testing.T) { + domain, err := ParseDomainString("[]", nil) + if err != nil { + t.Fatal(err) + } + if len(domain) != 0 { + t.Errorf("expected 0 nodes, got %d", len(domain)) + } +} + +func TestParseDomainStringEmptyString(t *testing.T) { + domain, err := ParseDomainString("", nil) + if err != nil { + t.Fatal(err) + } + if len(domain) != 0 { + t.Errorf("expected 0 nodes, got %d", len(domain)) + } +} + +func TestParseDomainStringNone(t *testing.T) { + domain, err := ParseDomainString("[('field', '=', None)]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + if cond.Value != nil { + t.Errorf("expected nil, got %v", cond.Value) + } +} + +func TestParseDomainStringImplicitAnd(t *testing.T) { + // Multiple leaves without explicit operator should be implicitly ANDed + domain, err := ParseDomainString("[('a', '=', 1), ('b', '=', 2)]", nil) + if err != nil { + t.Fatal(err) + } + // normalizeDomainNodes wraps with And() → [&, leaf, leaf] = 3 nodes + if len(domain) != 3 { + t.Fatalf("expected 3 nodes (implicit AND), got %d", len(domain)) + } + if domain[0] != OpAnd { + t.Error("expected implicit & operator") + } +} + +func TestParseDomainStringDoubleQuotes(t *testing.T) { + domain, err := ParseDomainString(`[("name", "=", "test")]`, nil) + if err != nil { + t.Fatal(err) + } + if len(domain) != 1 { + t.Fatalf("expected 1 node, got %d", len(domain)) + } + cond := domain[0].(Condition) + if cond.Field != "name" { + t.Errorf("field: %s", cond.Field) + } + if cond.Value != "test" { + t.Errorf("value: %v", cond.Value) + } +} + +func TestParseDomainStringContextVar(t *testing.T) { + // Without env, context vars should resolve to int64(0) + domain, err := ParseDomainString("[('user_id', '=', user.id)]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + if cond.Value != int64(0) { + t.Errorf("expected int64(0), got %T %v", cond.Value, cond.Value) + } +} + +func TestParseDomainStringInvalidSyntax(t *testing.T) { + _, err := ParseDomainString("not a domain", nil) + if err == nil { + t.Error("expected error for invalid syntax") + } +} + +func TestParseDomainStringTupleAsList(t *testing.T) { + // Some domain_force uses tuple syntax for list values + domain, err := ParseDomainString("[('id', 'in', (1, 2, 3))]", nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + vals, ok := cond.Value.([]int64) + if !ok { + t.Fatalf("expected []int64, got %T", cond.Value) + } + if len(vals) != 3 { + t.Errorf("expected 3, got %d", len(vals)) + } +} + +func TestParseDomainStringEscapedQuote(t *testing.T) { + domain, err := ParseDomainString(`[('name', '=', 'it\'s')]`, nil) + if err != nil { + t.Fatal(err) + } + cond := domain[0].(Condition) + if cond.Value != "it's" { + t.Errorf("expected it's, got %v", cond.Value) + } +} diff --git a/pkg/orm/domain_test.go b/pkg/orm/domain_test.go new file mode 100644 index 0000000..29b558d --- /dev/null +++ b/pkg/orm/domain_test.go @@ -0,0 +1,445 @@ +package orm + +import ( + "fmt" + "testing" +) + +func TestDomainCompileEmpty(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + where, params, err := dc.Compile(nil) + if err != nil { + t.Fatal(err) + } + if where != "TRUE" { + t.Errorf("expected TRUE, got %s", where) + } + if len(params) != 0 { + t.Errorf("expected 0 params, got %d", len(params)) + } +} + +func TestDomainCompileSimpleLeaf(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("name", "=", "test")} + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"name" = $1` { + t.Errorf("got %s", where) + } + if len(params) != 1 || params[0] != "test" { + t.Errorf("params: %v", params) + } +} + +func TestDomainCompileNullCheck(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("name", "=", nil)} + where, _, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"name" IS NULL` { + t.Errorf("got %s", where) + } +} + +func TestDomainCompileFalseCheck(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("active", "=", false)} + where, _, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"active" IS NULL` { + t.Errorf("got %s", where) + } +} + +func TestDomainCompileNotEqualNull(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("name", "!=", nil)} + where, _, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"name" IS NOT NULL` { + t.Errorf("got %s", where) + } +} + +func TestDomainCompileIn(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("id", "in", []int64{1, 2, 3})} + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"id" IN ($1, $2, $3)` { + t.Errorf("got %s", where) + } + if len(params) != 3 { + t.Errorf("params: %v", params) + } +} + +func TestDomainCompileEmptyIn(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("id", "in", []int64{})} + where, _, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != "FALSE" { + t.Errorf("got %s", where) + } +} + +func TestDomainCompileNotIn(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("id", "not in", []int64{1, 2})} + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"id" NOT IN ($1, $2)` { + t.Errorf("got %s", where) + } + if len(params) != 2 { + t.Errorf("params: %v", params) + } +} + +func TestDomainCompileEmptyNotIn(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("id", "not in", []int64{})} + where, _, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != "TRUE" { + t.Errorf("got %s", where) + } +} + +func TestDomainCompileLike(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("name", "ilike", "test")} + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"name" ILIKE $1` { + t.Errorf("got %s", where) + } + if params[0] != "%test%" { + t.Errorf("expected %%test%%, got %v", params[0]) + } +} + +func TestDomainCompileLikeWithWildcard(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("name", "ilike", "test%")} + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"name" ILIKE $1` { + t.Errorf("got %s", where) + } + // Value already contains wildcard, should not be wrapped again + if params[0] != "test%" { + t.Errorf("expected test%%, got %v", params[0]) + } +} + +func TestDomainCompileNotLike(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("name", "not like", "foo")} + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"name" NOT LIKE $1` { + t.Errorf("got %s", where) + } + if params[0] != "%foo%" { + t.Errorf("expected %%foo%%, got %v", params[0]) + } +} + +func TestDomainCompileExactLike(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("name", "=like", "test")} + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"name" LIKE $1` { + t.Errorf("got %s", where) + } + // =like does NOT auto-wrap + if params[0] != "test" { + t.Errorf("expected test, got %v", params[0]) + } +} + +func TestDomainCompileExactIlike(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("name", "=ilike", "Test")} + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"name" ILIKE $1` { + t.Errorf("got %s", where) + } + if params[0] != "Test" { + t.Errorf("expected Test, got %v", params[0]) + } +} + +func TestDomainCompileAnd(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := And(Leaf("a", "=", 1), Leaf("b", "=", 2)) + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + expected := `("a" = $1 AND "b" = $2)` + if where != expected { + t.Errorf("expected %s, got %s", expected, where) + } + if len(params) != 2 { + t.Errorf("params: %v", params) + } +} + +func TestDomainCompileOr(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Or(Leaf("a", "=", 1), Leaf("b", "=", 2)) + where, _, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + expected := `("a" = $1 OR "b" = $2)` + if where != expected { + t.Errorf("expected %s, got %s", expected, where) + } +} + +func TestDomainCompileNot(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Not(Leaf("active", "=", true)) + where, _, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + expected := `(NOT "active" = $1)` + if where != expected { + t.Errorf("expected %s, got %s", expected, where) + } +} + +func TestDomainCompileInvalidOperator(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("a", "INVALID", 1)} + _, _, err := dc.Compile(domain) + if err == nil { + t.Error("expected error for invalid operator") + } +} + +func TestDomainCompileComparison(t *testing.T) { + ops := []string{"<", ">", "<=", ">="} + for _, op := range ops { + t.Run(op, func(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("age", op, 18)} + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + expected := fmt.Sprintf(`"age" %s $1`, op) + if where != expected { + t.Errorf("expected %s, got %s", expected, where) + } + if len(params) != 1 || params[0] != 18 { + t.Errorf("params: %v", params) + } + }) + } +} + +func TestDomainCompileInStrings(t *testing.T) { + dc := &DomainCompiler{model: &Model{table: "test"}} + domain := Domain{Leaf("state", "in", []string{"draft", "sent"})} + where, params, err := dc.Compile(domain) + if err != nil { + t.Fatal(err) + } + if where != `"state" IN ($1, $2)` { + t.Errorf("got %s", where) + } + if len(params) != 2 { + t.Errorf("params: %v", params) + } +} + +func TestAndEmpty(t *testing.T) { + d := And() + if d != nil { + t.Errorf("expected nil, got %v", d) + } +} + +func TestAndSingle(t *testing.T) { + d := And(Leaf("a", "=", 1)) + if len(d) != 1 { + t.Errorf("expected 1 node, got %d", len(d)) + } +} + +func TestOrEmpty(t *testing.T) { + d := Or() + if d != nil { + t.Errorf("expected nil, got %v", d) + } +} + +func TestOrSingle(t *testing.T) { + d := Or(Leaf("a", "=", 1)) + if len(d) != 1 { + t.Errorf("expected 1 node, got %d", len(d)) + } +} + +func TestOrMultiple(t *testing.T) { + d := Or(Leaf("a", "=", 1), Leaf("b", "=", 2), Leaf("c", "=", 3)) + // Should have 2 OR operators + 3 leaves = 5 nodes + if len(d) != 5 { + t.Errorf("expected 5 nodes, got %d", len(d)) + } +} + +func TestAndMultiple(t *testing.T) { + d := And(Leaf("a", "=", 1), Leaf("b", "=", 2), Leaf("c", "=", 3)) + // Should have 2 AND operators + 3 leaves = 5 nodes + if len(d) != 5 { + t.Errorf("expected 5 nodes, got %d", len(d)) + } +} + +func TestNotDomain(t *testing.T) { + d := Not(Leaf("active", "=", true)) + if len(d) != 2 { + t.Errorf("expected 2 nodes, got %d", len(d)) + } + if d[0] != OpNot { + t.Errorf("expected OpNot, got %v", d[0]) + } +} + +func TestLeafCreation(t *testing.T) { + c := Leaf("name", "=", "test") + if c.Field != "name" { + t.Errorf("field: %s", c.Field) + } + if c.Operator != "=" { + t.Errorf("operator: %s", c.Operator) + } + if c.Value != "test" { + t.Errorf("value: %v", c.Value) + } +} + +func TestWrapLikeValue(t *testing.T) { + tests := []struct { + name string + input Value + want Value + }{ + {"plain string", "test", "%test%"}, + {"already has %", "test%", "test%"}, + {"already has _", "test_val", "test_val"}, + {"non-string", 42, 42}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wrapLikeValue(tt.input) + if got != tt.want { + t.Errorf("wrapLikeValue(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestNormalizeSlice(t *testing.T) { + t.Run("[]int64", func(t *testing.T) { + result := normalizeSlice([]int64{1, 2, 3}) + if len(result) != 3 { + t.Errorf("expected 3, got %d", len(result)) + } + }) + t.Run("[]string", func(t *testing.T) { + result := normalizeSlice([]string{"a", "b"}) + if len(result) != 2 { + t.Errorf("expected 2, got %d", len(result)) + } + }) + t.Run("[]int", func(t *testing.T) { + result := normalizeSlice([]int{1, 2}) + if len(result) != 2 { + t.Errorf("expected 2, got %d", len(result)) + } + }) + t.Run("[]float64", func(t *testing.T) { + result := normalizeSlice([]float64{1.5, 2.5}) + if len(result) != 2 { + t.Errorf("expected 2, got %d", len(result)) + } + }) + t.Run("non-slice", func(t *testing.T) { + result := normalizeSlice("not a slice") + if result != nil { + t.Errorf("expected nil, got %v", result) + } + }) +} + +func TestToInt64Slice(t *testing.T) { + tests := []struct { + name string + val Value + want []int64 + }{ + {"int64", int64(5), []int64{5}}, + {"int", int(3), []int64{3}}, + {"int32", int32(7), []int64{7}}, + {"float64", float64(9), []int64{9}}, + {"[]int64", []int64{1, 2}, []int64{1, 2}}, + {"[]int", []int{3, 4}, []int64{3, 4}}, + {"string", "bad", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := toInt64Slice(tt.val) + if tt.want == nil { + if got != nil { + t.Errorf("expected nil, got %v", got) + } + return + } + if len(got) != len(tt.want) { + t.Fatalf("len: got %d, want %d", len(got), len(tt.want)) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("index %d: got %d, want %d", i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/pkg/orm/field_test.go b/pkg/orm/field_test.go new file mode 100644 index 0000000..6ccdeed --- /dev/null +++ b/pkg/orm/field_test.go @@ -0,0 +1,330 @@ +package orm + +import "testing" + +func TestFieldIsCopyable(t *testing.T) { + tests := []struct { + name string + field Field + want bool + }{ + {"regular char", Field{Name: "name", Type: TypeChar}, true}, + {"id field", Field{Name: "id", Type: TypeInteger}, false}, + {"create_uid", Field{Name: "create_uid", Type: TypeMany2one}, false}, + {"write_uid", Field{Name: "write_uid", Type: TypeMany2one}, false}, + {"create_date", Field{Name: "create_date", Type: TypeDatetime}, false}, + {"write_date", Field{Name: "write_date", Type: TypeDatetime}, false}, + {"password", Field{Name: "password", Type: TypeChar}, false}, + {"computed non-stored", Field{Name: "total", Type: TypeFloat, Compute: "x"}, false}, + {"computed stored", Field{Name: "total", Type: TypeFloat, Compute: "x", Store: true}, true}, + {"o2m", Field{Name: "lines", Type: TypeOne2many}, false}, + {"m2o", Field{Name: "partner_id", Type: TypeMany2one}, true}, + {"boolean", Field{Name: "active", Type: TypeBoolean}, true}, + {"explicit copy true", Field{Name: "ref", Type: TypeChar, Copy: true}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.field.IsCopyable() + if got != tt.want { + t.Errorf("IsCopyable() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFieldSQLType(t *testing.T) { + tests := []struct { + typ FieldType + want string + }{ + {TypeChar, "varchar"}, + {TypeText, "text"}, + {TypeHTML, "text"}, + {TypeInteger, "int4"}, + {TypeFloat, "numeric"}, + {TypeMonetary, "numeric"}, + {TypeBoolean, "bool"}, + {TypeDate, "date"}, + {TypeDatetime, "timestamp without time zone"}, + {TypeMany2one, "int4"}, + {TypeOne2many, ""}, + {TypeMany2many, ""}, + {TypeJson, "jsonb"}, + {TypeProperties, "jsonb"}, + {TypeBinary, "bytea"}, + {TypeSelection, "varchar"}, + {TypeReference, "varchar"}, + } + for _, tt := range tests { + t.Run(tt.typ.String(), func(t *testing.T) { + got := tt.typ.SQLType() + if got != tt.want { + t.Errorf("SQLType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFieldSQLTypeWithSize(t *testing.T) { + f := &Field{Type: TypeChar, Size: 64} + got := f.SQLType() + if got != "varchar(64)" { + t.Errorf("expected varchar(64), got %s", got) + } +} + +func TestFieldTypeString(t *testing.T) { + if TypeChar.String() != "char" { + t.Error("expected char") + } + if TypeMany2one.String() != "many2one" { + t.Error("expected many2one") + } + if TypeBoolean.String() != "boolean" { + t.Error("expected boolean") + } + if TypeText.String() != "text" { + t.Error("expected text") + } + if TypeInteger.String() != "integer" { + t.Error("expected integer") + } + if TypeFloat.String() != "float" { + t.Error("expected float") + } +} + +func TestFieldTypeIsRelational(t *testing.T) { + if !TypeMany2one.IsRelational() { + t.Error("m2o should be relational") + } + if !TypeOne2many.IsRelational() { + t.Error("o2m should be relational") + } + if !TypeMany2many.IsRelational() { + t.Error("m2m should be relational") + } + if TypeChar.IsRelational() { + t.Error("char should not be relational") + } + if TypeInteger.IsRelational() { + t.Error("integer should not be relational") + } + if TypeBoolean.IsRelational() { + t.Error("boolean should not be relational") + } +} + +func TestFieldTypeIsStored(t *testing.T) { + if !TypeChar.IsStored() { + t.Error("char should be stored") + } + if !TypeMany2one.IsStored() { + t.Error("m2o should be stored") + } + if TypeOne2many.IsStored() { + t.Error("o2m should not be stored") + } + if TypeMany2many.IsStored() { + t.Error("m2m should not be stored") + } + if !TypeBoolean.IsStored() { + t.Error("boolean should be stored") + } + if !TypeInteger.IsStored() { + t.Error("integer should be stored") + } +} + +func TestFieldIsStored(t *testing.T) { + tests := []struct { + name string + f Field + want bool + }{ + {"plain char", Field{Type: TypeChar}, true}, + {"computed not stored", Field{Type: TypeChar, Compute: "x"}, false}, + {"computed stored", Field{Type: TypeChar, Compute: "x", Store: true}, true}, + {"related not stored", Field{Type: TypeChar, Related: "partner_id.name"}, false}, + {"related stored", Field{Type: TypeChar, Related: "partner_id.name", Store: true}, true}, + {"o2m", Field{Type: TypeOne2many}, false}, + {"m2m", Field{Type: TypeMany2many}, false}, + {"m2o", Field{Type: TypeMany2one}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.f.IsStored() + if got != tt.want { + t.Errorf("IsStored() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFieldColumn(t *testing.T) { + f := &Field{Name: "partner_id", column: "partner_id"} + if f.Column() != "partner_id" { + t.Errorf("expected partner_id, got %s", f.Column()) + } + + f2 := &Field{Name: "custom", column: "custom_col"} + if f2.Column() != "custom_col" { + t.Errorf("expected custom_col, got %s", f2.Column()) + } + + // When column is empty, falls back to Name + f3 := &Field{Name: "fallback"} + if f3.Column() != "fallback" { + t.Errorf("expected fallback, got %s", f3.Column()) + } +} + +func TestFieldConstructors(t *testing.T) { + t.Run("Char", func(t *testing.T) { + f := Char("name", FieldOpts{String: "Name", Required: true}) + if f.Type != TypeChar { + t.Errorf("type: %s", f.Type) + } + if f.Name != "name" { + t.Errorf("name: %s", f.Name) + } + if !f.Required { + t.Error("expected required") + } + if f.String != "Name" { + t.Errorf("string: %s", f.String) + } + }) + + t.Run("Integer", func(t *testing.T) { + f := Integer("count", FieldOpts{}) + if f.Type != TypeInteger { + t.Errorf("type: %s", f.Type) + } + }) + + t.Run("Boolean", func(t *testing.T) { + f := Boolean("active", FieldOpts{Default: true}) + if f.Type != TypeBoolean { + t.Errorf("type: %s", f.Type) + } + if f.Default != true { + t.Error("expected default true") + } + }) + + t.Run("Many2one", func(t *testing.T) { + f := Many2one("partner_id", "res.partner", FieldOpts{String: "Partner"}) + if f.Type != TypeMany2one { + t.Errorf("type: %s", f.Type) + } + if f.Comodel != "res.partner" { + t.Errorf("comodel: %s", f.Comodel) + } + if !f.Index { + t.Error("M2O should be auto-indexed") + } + if f.OnDelete != OnDeleteSetNull { + t.Errorf("expected set null, got %s", f.OnDelete) + } + }) + + t.Run("One2many", func(t *testing.T) { + f := One2many("line_ids", "sale.order.line", "order_id", FieldOpts{}) + if f.Type != TypeOne2many { + t.Errorf("type: %s", f.Type) + } + if f.InverseField != "order_id" { + t.Errorf("inverse: %s", f.InverseField) + } + }) + + t.Run("Many2many", func(t *testing.T) { + f := Many2many("tag_ids", "res.partner.tag", FieldOpts{}) + if f.Type != TypeMany2many { + t.Errorf("type: %s", f.Type) + } + if f.Comodel != "res.partner.tag" { + t.Errorf("comodel: %s", f.Comodel) + } + }) + + t.Run("Text", func(t *testing.T) { + f := Text("description", FieldOpts{}) + if f.Type != TypeText { + t.Errorf("type: %s", f.Type) + } + }) + + t.Run("Float", func(t *testing.T) { + f := Float("amount", FieldOpts{}) + if f.Type != TypeFloat { + t.Errorf("type: %s", f.Type) + } + }) + + t.Run("Date", func(t *testing.T) { + f := Date("birthday", FieldOpts{}) + if f.Type != TypeDate { + t.Errorf("type: %s", f.Type) + } + }) + + t.Run("Datetime", func(t *testing.T) { + f := Datetime("created", FieldOpts{}) + if f.Type != TypeDatetime { + t.Errorf("type: %s", f.Type) + } + }) + + t.Run("Binary", func(t *testing.T) { + f := Binary("image", FieldOpts{}) + if f.Type != TypeBinary { + t.Errorf("type: %s", f.Type) + } + }) + + t.Run("Json", func(t *testing.T) { + f := Json("data", FieldOpts{}) + if f.Type != TypeJson { + t.Errorf("type: %s", f.Type) + } + }) + + t.Run("default label from name", func(t *testing.T) { + f := Char("my_field", FieldOpts{}) + if f.String != "my_field" { + t.Errorf("expected my_field as default label, got %s", f.String) + } + }) +} + +func TestFieldResolveDefault(t *testing.T) { + t.Run("nil default", func(t *testing.T) { + f := &Field{Default: nil} + if f.ResolveDefault() != nil { + t.Error("expected nil") + } + }) + + t.Run("string default", func(t *testing.T) { + f := &Field{Default: "hello"} + if f.ResolveDefault() != "hello" { + t.Error("expected hello") + } + }) + + t.Run("bool default", func(t *testing.T) { + f := &Field{Default: true} + if f.ResolveDefault() != true { + t.Error("expected true") + } + }) + + t.Run("int default", func(t *testing.T) { + f := &Field{Default: 42} + if f.ResolveDefault() != 42 { + t.Error("expected 42") + } + }) +} diff --git a/pkg/orm/model_test.go b/pkg/orm/model_test.go new file mode 100644 index 0000000..208eaf5 --- /dev/null +++ b/pkg/orm/model_test.go @@ -0,0 +1,373 @@ +package orm + +import ( + "fmt" + "testing" +) + +func TestNewModel(t *testing.T) { + m := NewModel("test.model.unit.new", ModelOpts{ + Description: "Test Model", + RecName: "name", + }) + if m.Name() != "test.model.unit.new" { + t.Errorf("name: %s", m.Name()) + } + if m.Table() != "test_model_unit_new" { + t.Errorf("table: %s", m.Table()) + } + if m.Description() != "Test Model" { + t.Errorf("desc: %s", m.Description()) + } + if m.RecName() != "name" { + t.Errorf("rec_name: %s", m.RecName()) + } +} + +func TestNewModelDefaults(t *testing.T) { + m := NewModel("test.model.defaults", ModelOpts{}) + if m.Order() != "id" { + t.Errorf("default order: %s", m.Order()) + } + if m.RecName() != "name" { + t.Errorf("default rec_name: %s", m.RecName()) + } + if m.IsAbstract() { + t.Error("should not be abstract") + } + if m.IsTransient() { + t.Error("should not be transient") + } +} + +func TestNewModelCustomTable(t *testing.T) { + m := NewModel("test.model.custom.table", ModelOpts{ + Table: "my_custom_table", + }) + if m.Table() != "my_custom_table" { + t.Errorf("table: %s", m.Table()) + } +} + +func TestNewModelAbstract(t *testing.T) { + m := NewModel("test.model.abstract", ModelOpts{ + Type: ModelAbstract, + }) + if m.IsAbstract() != true { + t.Error("should be abstract") + } + // Abstract models have no table + if m.Table() != "" { + t.Errorf("abstract should have no table, got %s", m.Table()) + } +} + +func TestNewModelTransient(t *testing.T) { + m := NewModel("test.model.transient", ModelOpts{ + Type: ModelTransient, + }) + if m.IsTransient() != true { + t.Error("should be transient") + } +} + +func TestModelMagicFields(t *testing.T) { + m := NewModel("test.model.magic", ModelOpts{}) + // Magic fields should be auto-created + if f := m.GetField("id"); f == nil { + t.Error("id field missing") + } + if f := m.GetField("display_name"); f == nil { + t.Error("display_name field missing") + } + if f := m.GetField("create_uid"); f == nil { + t.Error("create_uid field missing") + } + if f := m.GetField("create_date"); f == nil { + t.Error("create_date field missing") + } + if f := m.GetField("write_uid"); f == nil { + t.Error("write_uid field missing") + } + if f := m.GetField("write_date"); f == nil { + t.Error("write_date field missing") + } +} + +func TestModelAddFields(t *testing.T) { + m := NewModel("test.model.fields.add", ModelOpts{}) + m.AddFields( + Char("name", FieldOpts{String: "Name", Required: true}), + Integer("age", FieldOpts{String: "Age"}), + Boolean("active", FieldOpts{String: "Active", Default: true}), + ) + + if f := m.GetField("name"); f == nil { + t.Error("name field missing") + } + if f := m.GetField("age"); f == nil { + t.Error("age field missing") + } + if f := m.GetField("active"); f == nil { + t.Error("active field missing") + } + if f := m.GetField("nonexistent"); f != nil { + t.Error("should be nil") + } + + nameF := m.GetField("name") + if nameF.Type != TypeChar { + t.Error("expected char") + } + if !nameF.Required { + t.Error("expected required") + } + if nameF.String != "Name" { + t.Error("expected Name label") + } +} + +func TestModelAddFieldSetsModel(t *testing.T) { + m := NewModel("test.model.field.backref", ModelOpts{}) + f := Char("ref", FieldOpts{}) + m.AddField(f) + if f.model != m { + t.Error("field should have back-reference to model") + } +} + +func TestModelStoredFields(t *testing.T) { + m := NewModel("test.model.stored", ModelOpts{}) + m.AddFields( + Char("name", FieldOpts{}), + Char("computed_field", FieldOpts{Compute: "x"}), + One2many("lines", "other.model", "parent_id", FieldOpts{}), + ) + + stored := m.StoredFields() + // Should include magic fields + name, but not computed_field or o2m + nameFound := false + computedFound := false + linesFound := false + for _, f := range stored { + switch f.Name { + case "name": + nameFound = true + case "computed_field": + computedFound = true + case "lines": + linesFound = true + } + } + if !nameFound { + t.Error("name should be in stored fields") + } + if computedFound { + t.Error("computed_field should not be in stored fields") + } + if linesFound { + t.Error("o2m lines should not be in stored fields") + } +} + +func TestModelRegisterMethod(t *testing.T) { + m := NewModel("test.model.methods.reg", ModelOpts{}) + called := false + m.RegisterMethod("test_action", func(rs *Recordset, args ...interface{}) (interface{}, error) { + called = true + return "ok", nil + }) + + if _, ok := m.Methods["test_action"]; !ok { + t.Error("method not registered") + } + + result, err := m.Methods["test_action"](nil) + if err != nil { + t.Fatal(err) + } + if result != "ok" { + t.Error("expected ok") + } + if !called { + t.Error("method not called") + } +} + +func TestExtendModel(t *testing.T) { + NewModel("test.model.base.ext", ModelOpts{Description: "Base"}) + + ext := ExtendModel("test.model.base.ext") + ext.AddFields(Char("extra_field", FieldOpts{String: "Extra"})) + + base := Registry.Get("test.model.base.ext") + if f := base.GetField("extra_field"); f == nil { + t.Error("extension field missing") + } +} + +func TestExtendModelPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for missing model") + } + }() + ExtendModel("nonexistent.model.xyz.panic") +} + +func TestRegistryGet(t *testing.T) { + NewModel("test.registry.get.model", ModelOpts{}) + if m := Registry.Get("test.registry.get.model"); m == nil { + t.Error("model not found") + } + if m := Registry.Get("nonexistent.registry.model"); m != nil { + t.Error("should be nil") + } +} + +func TestRegistryMustGetPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for missing model") + } + }() + Registry.MustGet("nonexistent.mustget.model") +} + +func TestRegistryAll(t *testing.T) { + NewModel("test.registry.all.model", ModelOpts{}) + all := Registry.All() + found := false + for _, name := range all { + if name == "test.registry.all.model" { + found = true + break + } + } + if !found { + t.Error("model not in Registry.All()") + } +} + +func TestRegistryModels(t *testing.T) { + NewModel("test.registry.models.model", ModelOpts{}) + models := Registry.Models() + if _, ok := models["test.registry.models.model"]; !ok { + t.Error("model not in Registry.Models()") + } +} + +func TestModelSQLConstraint(t *testing.T) { + m := NewModel("test.model.constraint.sql", ModelOpts{}) + m.AddSQLConstraint("unique_name", "UNIQUE(name)", "Name must be unique") + if len(m.SQLConstraints) != 1 { + t.Error("constraint not added") + } + if m.SQLConstraints[0].Name != "unique_name" { + t.Error("wrong name") + } + if m.SQLConstraints[0].Definition != "UNIQUE(name)" { + t.Error("wrong definition") + } + if m.SQLConstraints[0].Message != "Name must be unique" { + t.Error("wrong message") + } +} + +func TestModelAddConstraint(t *testing.T) { + m := NewModel("test.model.constraint.func", ModelOpts{}) + m.AddConstraint(func(rs *Recordset) error { + return fmt.Errorf("test error") + }) + if len(m.Constraints) != 1 { + t.Error("constraint not added") + } +} + +func TestModelRegisterOnchange(t *testing.T) { + m := NewModel("test.model.onchange", ModelOpts{}) + m.RegisterOnchange("partner_id", func(env *Environment, vals Values) Values { + return Values{"name": "changed"} + }) + if m.OnchangeHandlers == nil { + t.Fatal("OnchangeHandlers should not be nil") + } + if _, ok := m.OnchangeHandlers["partner_id"]; !ok { + t.Error("onchange handler not registered") + } +} + +func TestModelRegisterInverse(t *testing.T) { + m := NewModel("test.model.inverse", ModelOpts{}) + m.AddFields(Char("computed", FieldOpts{Compute: "_compute_computed"})) + m.RegisterInverse("computed", func(rs *Recordset, args ...interface{}) (interface{}, error) { + return nil, nil + }) + + f := m.GetField("computed") + if f.Inverse != "_inverse_computed" { + t.Errorf("expected _inverse_computed, got %s", f.Inverse) + } + if _, ok := m.Methods["_inverse_computed"]; !ok { + t.Error("inverse method not registered") + } +} + +func TestModelCreateTableSQL(t *testing.T) { + m := NewModel("test.model.ddl", ModelOpts{}) + m.AddFields( + Char("name", FieldOpts{Required: true}), + Integer("count", FieldOpts{}), + ) + + sql := m.CreateTableSQL() + if sql == "" { + t.Fatal("expected non-empty SQL") + } + // Should contain the table name + if !containsStr(sql, `"test_model_ddl"`) { + t.Error("missing table name in DDL") + } + // Should contain name column + if !containsStr(sql, `"name"`) { + t.Error("missing name column in DDL") + } + // Should contain NOT NULL for required + if !containsStr(sql, "NOT NULL") { + t.Error("missing NOT NULL for required field") + } +} + +func TestModelCreateTableSQLAbstract(t *testing.T) { + m := NewModel("test.model.ddl.abstract", ModelOpts{Type: ModelAbstract}) + sql := m.CreateTableSQL() + if sql != "" { + t.Error("abstract model should have empty DDL") + } +} + +func TestModelFields(t *testing.T) { + m := NewModel("test.model.all.fields", ModelOpts{}) + m.AddFields(Char("name", FieldOpts{})) + fields := m.Fields() + if _, ok := fields["name"]; !ok { + t.Error("name not in Fields()") + } + if _, ok := fields["id"]; !ok { + t.Error("id not in Fields()") + } +} + +// containsStr is a test helper - checks if s contains substr +func containsStr(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && stringContains(s, substr)) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/orm/sanitize_test.go b/pkg/orm/sanitize_test.go new file mode 100644 index 0000000..3dfa284 --- /dev/null +++ b/pkg/orm/sanitize_test.go @@ -0,0 +1,109 @@ +package orm + +import "testing" + +func TestSanitizeFieldValueBoolFalse(t *testing.T) { + // false for boolean field should stay false + f := &Field{Type: TypeBoolean} + got := sanitizeFieldValue(f, false) + if got != false { + t.Errorf("expected false, got %v", got) + } +} + +func TestSanitizeFieldValueBoolTrue(t *testing.T) { + f := &Field{Type: TypeBoolean} + got := sanitizeFieldValue(f, true) + if got != true { + t.Errorf("expected true, got %v", got) + } +} + +func TestSanitizeFieldValueCharFalse(t *testing.T) { + // false for char field should become nil + f := &Field{Type: TypeChar} + got := sanitizeFieldValue(f, false) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestSanitizeFieldValueIntFalse(t *testing.T) { + f := &Field{Type: TypeInteger} + got := sanitizeFieldValue(f, false) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestSanitizeFieldValueFloatFalse(t *testing.T) { + f := &Field{Type: TypeFloat} + got := sanitizeFieldValue(f, false) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestSanitizeFieldValueM2OFalse(t *testing.T) { + f := &Field{Type: TypeMany2one} + got := sanitizeFieldValue(f, false) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestSanitizeFieldValueDateFalse(t *testing.T) { + f := &Field{Type: TypeDate} + got := sanitizeFieldValue(f, false) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestSanitizeFieldValueFloat64ToInt(t *testing.T) { + f := &Field{Type: TypeInteger} + got := sanitizeFieldValue(f, float64(42)) + if got != int64(42) { + t.Errorf("expected int64(42), got %T %v", got, got) + } +} + +func TestSanitizeFieldValueM2OFloat(t *testing.T) { + f := &Field{Type: TypeMany2one} + got := sanitizeFieldValue(f, float64(5)) + if got != int64(5) { + t.Errorf("expected int64(5), got %T %v", got, got) + } +} + +func TestSanitizeFieldValueNil(t *testing.T) { + f := &Field{Type: TypeChar} + got := sanitizeFieldValue(f, nil) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestSanitizeFieldValuePassthrough(t *testing.T) { + f := &Field{Type: TypeChar} + got := sanitizeFieldValue(f, "hello") + if got != "hello" { + t.Errorf("expected hello, got %v", got) + } +} + +func TestSanitizeFieldValueIntPassthrough(t *testing.T) { + f := &Field{Type: TypeInteger} + got := sanitizeFieldValue(f, int64(99)) + if got != int64(99) { + t.Errorf("expected int64(99), got %T %v", got, got) + } +} + +func TestSanitizeFieldValueFloatPassthrough(t *testing.T) { + f := &Field{Type: TypeFloat} + got := sanitizeFieldValue(f, float64(3.14)) + if got != float64(3.14) { + t.Errorf("expected 3.14, got %v", got) + } +}