Expand Sale/Purchase/Project + 102 ORM tests — +4633 LOC
Sale (1177→2321 LOC): - Quotation templates (apply to order, option lines) - Sales reports (by month, product, customer, salesperson, category) - Advance payment wizard (delivered/percentage/fixed modes) - SO cancel wizard, discount wizard - action_quotation_sent, action_lock/unlock, preview_quotation - Line computes: invoice_status, price_reduce, untaxed_amount - Partner extension: sale_order_total Purchase (478→1424 LOC): - Purchase reports (by month, category, bill status, receipt analysis) - Receipt creation from PO (action_create_picking) - 3-way matching: action_view_picking, action_view_invoice - button_approve, button_done, action_rfq_send - Line computes: price_subtotal/total with tax, product onchange - Partner extension: purchase_order_count/total Project (218→1161 LOC): - Project updates (status tracking: on_track/at_risk/off_track) - Milestones (deadline, reached tracking, task count) - Timesheet integration (account.analytic.line extension) - Timesheet reports (by project, employee, task, week) - Task recurrence model - Task: planned/effective/remaining hours, progress, subtask hours - Project: allocated/remaining hours, profitability actions ORM Tests (102 tests, 0→1257 LOC): - domain_test.go: 32 tests (compile, operators, AND/OR/NOT, null) - field_test.go: 15 tests (IsCopyable, SQLType, IsRelational, IsStored) - model_test.go: 21 tests (NewModel, AddFields, RegisterMethod, ExtendModel) - domain_parse_test.go: 21 tests (parse Python domain strings) - sanitize_test.go: 13 tests (false→nil, type conversions) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,4 +6,12 @@ func Init() {
|
||||
initProjectMilestone()
|
||||
initProjectProject()
|
||||
initProjectTask()
|
||||
initProjectUpdate()
|
||||
initProjectTimesheetExtension()
|
||||
initTimesheetReport()
|
||||
initProjectProjectExtension()
|
||||
initProjectTaskExtension()
|
||||
initProjectMilestoneExtension()
|
||||
initProjectTaskRecurrence()
|
||||
initProjectSharingWizard()
|
||||
}
|
||||
|
||||
651
addons/project/models/project_extend.go
Normal file
651
addons/project/models/project_extend.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
237
addons/project/models/project_timesheet.go
Normal file
237
addons/project/models/project_timesheet.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initProjectTimesheetExtension extends account.analytic.line with project/timesheet fields.
|
||||
// In Odoo, timesheets are account.analytic.line records with project_id set.
|
||||
// Mirrors: odoo/addons/hr_timesheet/models/hr_timesheet.py
|
||||
//
|
||||
// class AccountAnalyticLine(models.Model):
|
||||
// _inherit = 'account.analytic.line'
|
||||
func initProjectTimesheetExtension() {
|
||||
al := orm.ExtendModel("account.analytic.line")
|
||||
|
||||
al.AddFields(
|
||||
orm.Many2one("project_id", "project.project", orm.FieldOpts{
|
||||
String: "Project", Index: true,
|
||||
}),
|
||||
orm.Many2one("task_id", "project.task", orm.FieldOpts{
|
||||
String: "Task", Index: true,
|
||||
}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||
String: "Employee", Index: true,
|
||||
}),
|
||||
orm.Float("unit_amount", orm.FieldOpts{String: "Duration (Hours)"}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "User"}),
|
||||
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
|
||||
orm.Selection("encoding_uom_id", []orm.SelectionItem{
|
||||
{Value: "hours", Label: "Hours"},
|
||||
{Value: "days", Label: "Days"},
|
||||
}, orm.FieldOpts{String: "Encoding UoM", Default: "hours"}),
|
||||
)
|
||||
|
||||
// DefaultGet: set date to today, employee from current user
|
||||
al.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := make(orm.Values)
|
||||
vals["date"] = time.Now().Format("2006-01-02")
|
||||
if env.UID() > 0 {
|
||||
vals["user_id"] = env.UID()
|
||||
// Try to find the employee linked to this user
|
||||
var empID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM hr_employee WHERE user_id = $1 LIMIT 1`, env.UID()).Scan(&empID)
|
||||
if err == nil && empID > 0 {
|
||||
vals["employee_id"] = empID
|
||||
// Also set department
|
||||
var deptID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT department_id FROM hr_employee WHERE id = $1`, empID).Scan(&deptID)
|
||||
if deptID > 0 {
|
||||
vals["department_id"] = deptID
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals
|
||||
}
|
||||
}
|
||||
|
||||
// initTimesheetReport registers a transient model for timesheet reporting.
|
||||
// Mirrors: odoo/addons/hr_timesheet/report/hr_timesheet_report.py
|
||||
func initTimesheetReport() {
|
||||
m := orm.NewModel("hr.timesheet.report", orm.ModelOpts{
|
||||
Description: "Timesheet Analysis Report",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Date("date_from", orm.FieldOpts{String: "Start Date"}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "End Date"}),
|
||||
orm.Many2one("project_id", "project.project", orm.FieldOpts{String: "Project"}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee"}),
|
||||
orm.Many2one("task_id", "project.task", orm.FieldOpts{String: "Task"}),
|
||||
)
|
||||
|
||||
// get_timesheet_data: Aggregated timesheet data for reporting.
|
||||
// Returns: { by_project, by_employee, by_task, summary }
|
||||
m.RegisterMethod("get_timesheet_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// ── Hours by project ──
|
||||
projRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(pp.name, 'No Project') AS project,
|
||||
SUM(aal.unit_amount) AS hours,
|
||||
COUNT(*) AS entries
|
||||
FROM account_analytic_line aal
|
||||
LEFT JOIN project_project pp ON pp.id = aal.project_id
|
||||
WHERE aal.project_id IS NOT NULL
|
||||
GROUP BY pp.name
|
||||
ORDER BY hours DESC
|
||||
LIMIT 20`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timesheet_report: by project query: %w", err)
|
||||
}
|
||||
defer projRows.Close()
|
||||
|
||||
var byProject []map[string]interface{}
|
||||
for projRows.Next() {
|
||||
var name string
|
||||
var hours float64
|
||||
var entries int64
|
||||
if err := projRows.Scan(&name, &hours, &entries); err != nil {
|
||||
continue
|
||||
}
|
||||
byProject = append(byProject, map[string]interface{}{
|
||||
"project": name,
|
||||
"hours": hours,
|
||||
"entries": entries,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Hours by employee ──
|
||||
empRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(he.name, 'Unknown') AS employee,
|
||||
SUM(aal.unit_amount) AS hours,
|
||||
COUNT(*) AS entries,
|
||||
COUNT(DISTINCT aal.project_id) AS projects
|
||||
FROM account_analytic_line aal
|
||||
LEFT JOIN hr_employee he ON he.id = aal.employee_id
|
||||
WHERE aal.project_id IS NOT NULL
|
||||
GROUP BY he.name
|
||||
ORDER BY hours DESC
|
||||
LIMIT 20`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timesheet_report: by employee query: %w", err)
|
||||
}
|
||||
defer empRows.Close()
|
||||
|
||||
var byEmployee []map[string]interface{}
|
||||
for empRows.Next() {
|
||||
var name string
|
||||
var hours float64
|
||||
var entries, projects int64
|
||||
if err := empRows.Scan(&name, &hours, &entries, &projects); err != nil {
|
||||
continue
|
||||
}
|
||||
byEmployee = append(byEmployee, map[string]interface{}{
|
||||
"employee": name,
|
||||
"hours": hours,
|
||||
"entries": entries,
|
||||
"projects": projects,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Hours by task ──
|
||||
taskRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(pt.name, 'No Task') AS task,
|
||||
COALESCE(pp.name, 'No Project') AS project,
|
||||
SUM(aal.unit_amount) AS hours,
|
||||
COUNT(*) AS entries
|
||||
FROM account_analytic_line aal
|
||||
LEFT JOIN project_task pt ON pt.id = aal.task_id
|
||||
LEFT JOIN project_project pp ON pp.id = aal.project_id
|
||||
WHERE aal.project_id IS NOT NULL AND aal.task_id IS NOT NULL
|
||||
GROUP BY pt.name, pp.name
|
||||
ORDER BY hours DESC
|
||||
LIMIT 20`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timesheet_report: by task query: %w", err)
|
||||
}
|
||||
defer taskRows.Close()
|
||||
|
||||
var byTask []map[string]interface{}
|
||||
for taskRows.Next() {
|
||||
var taskName, projName string
|
||||
var hours float64
|
||||
var entries int64
|
||||
if err := taskRows.Scan(&taskName, &projName, &hours, &entries); err != nil {
|
||||
continue
|
||||
}
|
||||
byTask = append(byTask, map[string]interface{}{
|
||||
"task": taskName,
|
||||
"project": projName,
|
||||
"hours": hours,
|
||||
"entries": entries,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Summary ──
|
||||
var totalHours float64
|
||||
var totalEntries int64
|
||||
env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COALESCE(SUM(unit_amount), 0), COUNT(*)
|
||||
FROM account_analytic_line WHERE project_id IS NOT NULL
|
||||
`).Scan(&totalHours, &totalEntries)
|
||||
|
||||
return map[string]interface{}{
|
||||
"by_project": byProject,
|
||||
"by_employee": byEmployee,
|
||||
"by_task": byTask,
|
||||
"summary": map[string]interface{}{
|
||||
"total_hours": totalHours,
|
||||
"total_entries": totalEntries,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_timesheet_by_week: Weekly breakdown of timesheet hours.
|
||||
m.RegisterMethod("get_timesheet_by_week", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT date_trunc('week', aal.date) AS week,
|
||||
SUM(aal.unit_amount) AS hours,
|
||||
COUNT(*) AS entries,
|
||||
COUNT(DISTINCT aal.employee_id) AS employees
|
||||
FROM account_analytic_line aal
|
||||
WHERE aal.project_id IS NOT NULL
|
||||
GROUP BY week
|
||||
ORDER BY week DESC
|
||||
LIMIT 12`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timesheet_report: weekly query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var week time.Time
|
||||
var hours float64
|
||||
var entries, employees int64
|
||||
if err := rows.Scan(&week, &hours, &entries, &employees); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"week": week.Format("2006-01-02"),
|
||||
"hours": hours,
|
||||
"entries": entries,
|
||||
"employees": employees,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
})
|
||||
}
|
||||
69
addons/project/models/project_update.go
Normal file
69
addons/project/models/project_update.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -4,4 +4,9 @@ func Init() {
|
||||
initPurchaseOrder()
|
||||
initPurchaseOrderLine()
|
||||
initPurchaseAgreement()
|
||||
initPurchaseReport()
|
||||
initPurchaseOrderExtension()
|
||||
initPurchaseOrderLineExtension()
|
||||
initResPartnerPurchaseExtension()
|
||||
initPurchaseOrderAmount()
|
||||
}
|
||||
|
||||
671
addons/purchase/models/purchase_extend.go
Normal file
671
addons/purchase/models/purchase_extend.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
292
addons/purchase/models/purchase_report.go
Normal file
292
addons/purchase/models/purchase_report.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
443
addons/sale/models/sale_order_extend.go
Normal file
443
addons/sale/models/sale_order_extend.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
421
addons/sale/models/sale_report.go
Normal file
421
addons/sale/models/sale_report.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
292
addons/sale/models/sale_template.go
Normal file
292
addons/sale/models/sale_template.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
277
pkg/orm/domain_parse_test.go
Normal file
277
pkg/orm/domain_parse_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
445
pkg/orm/domain_test.go
Normal file
445
pkg/orm/domain_test.go
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
330
pkg/orm/field_test.go
Normal file
330
pkg/orm/field_test.go
Normal file
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
373
pkg/orm/model_test.go
Normal file
373
pkg/orm/model_test.go
Normal file
@@ -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
|
||||
}
|
||||
109
pkg/orm/sanitize_test.go
Normal file
109
pkg/orm/sanitize_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user