package models import ( "fmt" "time" "odoo-go/pkg/orm" ) // initHrLeaveType registers the hr.leave.type model. // Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py func initHrLeaveType() { orm.NewModel("hr.leave.type", orm.ModelOpts{ Description: "Time Off Type", Order: "sequence, id", }).AddFields( orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}), orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 100}), orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), orm.Selection("leave_validation_type", []orm.SelectionItem{ {Value: "no_validation", Label: "No Validation"}, {Value: "hr", Label: "By Time Off Officer"}, {Value: "manager", Label: "By Employee's Approver"}, {Value: "both", Label: "By Employee's Approver and Time Off Officer"}, }, orm.FieldOpts{String: "Approval", Default: "hr"}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), orm.Integer("color", orm.FieldOpts{String: "Color"}), orm.Boolean("requires_allocation", orm.FieldOpts{String: "Requires Allocation", Default: true}), orm.Float("max_allowed", orm.FieldOpts{String: "Max Days Allowed"}), ) } // initHrLeave registers the hr.leave model. // Mirrors: odoo/addons/hr_holidays/models/hr_leave.py func initHrLeave() { m := orm.NewModel("hr.leave", orm.ModelOpts{ Description: "Time Off", Order: "date_from desc", }) m.AddFields( orm.Char("name", orm.FieldOpts{String: "Description"}), orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}), orm.Many2one("holiday_status_id", "hr.leave.type", orm.FieldOpts{String: "Time Off Type", Required: true}), orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}), orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}), orm.Datetime("date_from", orm.FieldOpts{String: "Start Date", Required: true}), orm.Datetime("date_to", orm.FieldOpts{String: "End Date", Required: true}), orm.Float("number_of_days", orm.FieldOpts{String: "Duration (Days)"}), orm.Selection("state", []orm.SelectionItem{ {Value: "draft", Label: "To Submit"}, {Value: "confirm", Label: "To Approve"}, {Value: "validate1", Label: "Second Approval"}, {Value: "validate", Label: "Approved"}, {Value: "refuse", Label: "Refused"}, }, orm.FieldOpts{String: "Status", Default: "draft"}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), orm.Text("notes", orm.FieldOpts{String: "Reasons"}), ) // action_approve: Manager approves leave request (first approval). // For leave types with 'both' validation, moves to validate1 (second approval needed). // Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_approve() m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM hr_leave WHERE id = $1`, id, ).Scan(&state) if err != nil { return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err) } if state != "confirm" && state != "validate1" { return nil, fmt.Errorf("hr.leave: can only approve leaves in 'To Approve' or 'Second Approval' state (leave %d is %q)", id, state) } // Check if second approval is needed var validationType string if err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(lt.leave_validation_type, 'hr') FROM hr_leave l JOIN hr_leave_type lt ON lt.id = l.holiday_status_id WHERE l.id = $1`, id, ).Scan(&validationType); err != nil { validationType = "hr" // safe default } newState := "validate" if validationType == "both" && state == "confirm" { newState = "validate1" } _, err = env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = $1 WHERE id = $2`, newState, id) if err != nil { return nil, fmt.Errorf("hr.leave: approve leave %d: %w", id, err) } } return true, nil }) // action_validate: Final validation (second approval if needed). // Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_validate() m.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM hr_leave WHERE id = $1`, id, ).Scan(&state) if err != nil { return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err) } if state != "confirm" && state != "validate1" { return nil, fmt.Errorf("hr.leave: can only validate leaves in 'To Approve' or 'Second Approval' state (leave %d is %q)", id, state) } _, err = env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'validate' WHERE id = $1`, id) if err != nil { return nil, fmt.Errorf("hr.leave: validate leave %d: %w", id, err) } } return true, nil }) // action_refuse: Manager refuses leave request. // Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_refuse() m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM hr_leave WHERE id = $1`, id, ).Scan(&state) if err != nil { return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err) } if state == "draft" { return nil, fmt.Errorf("hr.leave: cannot refuse a draft leave (leave %d). Submit it first", id) } if state == "validate" { return nil, fmt.Errorf("hr.leave: cannot refuse an already approved leave (leave %d). Reset to draft first", id) } _, err = env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'refuse' WHERE id = $1`, id) if err != nil { return nil, fmt.Errorf("hr.leave: refuse leave %d: %w", id, err) } } return true, nil }) // action_draft: Reset leave to draft state. // Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_draft() m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM hr_leave WHERE id = $1`, id, ).Scan(&state) if err != nil { return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err) } if state != "confirm" && state != "refuse" { return nil, fmt.Errorf("hr.leave: can only reset to draft from 'To Approve' or 'Refused' state (leave %d is %q)", id, state) } _, err = env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'draft' WHERE id = $1`, id) if err != nil { return nil, fmt.Errorf("hr.leave: reset to draft leave %d: %w", id, err) } } return true, nil }) // action_confirm: Submit leave for approval (draft -> confirm). // Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_confirm() m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM hr_leave WHERE id = $1`, id, ).Scan(&state) if err != nil { return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err) } if state != "draft" { return nil, fmt.Errorf("hr.leave: can only confirm draft leaves (leave %d is %q)", id, state) } _, err = env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'confirm' WHERE id = $1`, id) if err != nil { return nil, fmt.Errorf("hr.leave: confirm leave %d: %w", id, err) } } return true, nil }) } // initHrLeaveExtensions adds additional leave methods. func initHrLeaveExtensions() { leave := orm.ExtendModel("hr.leave") // action_approve_batch: Approve multiple leave requests at once (manager workflow). // Mirrors: odoo/addons/hr_holidays/models/hr_leave.py action_approve (multi) leave.RegisterMethod("action_approve_batch", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() ids := rs.IDs() if len(ids) == 0 { return true, nil } // Leaves with 'both' validation: confirm → validate1 (not directly to validate) r1, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_leave l SET state = 'validate1' WHERE l.id = ANY($1) AND l.state = 'confirm' AND EXISTS ( SELECT 1 FROM hr_leave_type lt WHERE lt.id = l.holiday_status_id AND lt.leave_validation_type = 'both' )`, ids) if err != nil { return nil, fmt.Errorf("hr.leave: batch approve (first step): %w", err) } // Non-'both' confirm → validate, and existing validate1 → validate // Exclude IDs that were just set to validate1 above r2, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_leave l SET state = 'validate' WHERE l.id = ANY($1) AND (l.state = 'validate1' OR (l.state = 'confirm' AND NOT EXISTS ( SELECT 1 FROM hr_leave_type lt WHERE lt.id = l.holiday_status_id AND lt.leave_validation_type = 'both' )))`, ids) if err != nil { return nil, fmt.Errorf("hr.leave: batch approve: %w", err) } count := r1.RowsAffected() + r2.RowsAffected() return map[string]interface{}{"approved": count}, nil }) // _compute_number_of_days: Auto-compute duration from date_from/date_to. leave.RegisterCompute("number_of_days", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() id := rs.IDs()[0] var days float64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(EXTRACT(EPOCH FROM (date_to - date_from)) / 86400.0, 0) FROM hr_leave WHERE id = $1`, id).Scan(&days); err != nil { return orm.Values{"number_of_days": float64(0)}, nil } if days < 0 { days = 0 } return orm.Values{"number_of_days": days}, nil }) } // initHrLeaveReport registers the hr.leave.report transient model. // Mirrors: odoo/addons/hr_holidays/report/hr_leave_report.py func initHrLeaveReport() { m := orm.NewModel("hr.leave.report", orm.ModelOpts{ Description: "Time Off Summary Report", Type: orm.ModelTransient, }) m.AddFields( orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee"}), orm.Date("date_from", orm.FieldOpts{String: "From"}), orm.Date("date_to", orm.FieldOpts{String: "To"}), ) // get_leave_summary: Returns leave days grouped by leave type for an employee in a date range. // Mirrors: odoo/addons/hr_holidays/report/hr_leave_report.py m.RegisterMethod("get_leave_summary", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() // Parse kwargs var employeeID int64 dateFrom := "2000-01-01" dateTo := "2099-12-31" if len(args) > 0 { if kw, ok := args[0].(map[string]interface{}); ok { if v, ok := kw["employee_id"]; ok { switch vid := v.(type) { case int64: employeeID = vid case float64: employeeID = int64(vid) case int: employeeID = int64(vid) } } if v, ok := kw["date_from"].(string); ok && v != "" { dateFrom = v } if v, ok := kw["date_to"].(string); ok && v != "" { dateTo = v } } } if employeeID == 0 { return nil, fmt.Errorf("hr.leave.report: employee_id is required") } // Approved leaves grouped by type rows, err := env.Tx().Query(env.Ctx(), `SELECT lt.id, lt.name, COALESCE(SUM(l.number_of_days), 0) AS total_days, COUNT(l.id) AS leave_count FROM hr_leave l JOIN hr_leave_type lt ON lt.id = l.holiday_status_id WHERE l.employee_id = $1 AND l.state = 'validate' AND l.date_from::date >= $2 AND l.date_to::date <= $3 GROUP BY lt.id, lt.name ORDER BY lt.name`, employeeID, dateFrom, dateTo) if err != nil { return nil, fmt.Errorf("hr.leave.report: query leaves: %w", err) } defer rows.Close() var summary []map[string]interface{} var totalDays float64 for rows.Next() { var typeID int64 var typeName string var days float64 var count int if err := rows.Scan(&typeID, &typeName, &days, &count); err != nil { continue } summary = append(summary, map[string]interface{}{ "leave_type_id": typeID, "leave_type_name": typeName, "total_days": days, "leave_count": count, }) totalDays += days } // Remaining allocation per type allocRows, err := env.Tx().Query(env.Ctx(), `SELECT lt.id, lt.name, COALESCE(SUM(a.number_of_days), 0) AS allocated FROM hr_leave_allocation a JOIN hr_leave_type lt ON lt.id = a.holiday_status_id WHERE a.employee_id = $1 AND a.state = 'validate' GROUP BY lt.id, lt.name ORDER BY lt.name`, employeeID) if err != nil { return nil, fmt.Errorf("hr.leave.report: query allocations: %w", err) } defer allocRows.Close() var allocations []map[string]interface{} for allocRows.Next() { var typeID int64 var typeName string var allocated float64 if err := allocRows.Scan(&typeID, &typeName, &allocated); err != nil { continue } allocations = append(allocations, map[string]interface{}{ "leave_type_id": typeID, "leave_type_name": typeName, "allocated_days": allocated, }) } return map[string]interface{}{ "employee_id": employeeID, "date_from": dateFrom, "date_to": dateTo, "leaves": summary, "allocations": allocations, "total_days": totalDays, }, nil }) } // initHrLeaveTypeExtensions adds remaining quota computation to leave types. func initHrLeaveTypeExtensions() { lt := orm.ExtendModel("hr.leave.type") lt.AddFields( orm.Float("remaining_leaves", orm.FieldOpts{ String: "Remaining Leaves", Compute: "_compute_remaining_leaves", Help: "Remaining leaves for current employee (allocated - taken)", }), ) // _compute_remaining_quota: Calculate remaining leaves per type for current user's employee. // Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py _compute_leaves() lt.RegisterCompute("remaining_leaves", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() typeID := rs.IDs()[0] // Get current user's employee var employeeID int64 env.Tx().QueryRow(env.Ctx(), `SELECT id FROM hr_employee WHERE user_id = $1 LIMIT 1`, env.UID(), ).Scan(&employeeID) if employeeID == 0 { return orm.Values{"remaining_leaves": float64(0)}, nil } // Allocated days for this type (approved allocations) var allocated float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(number_of_days), 0) FROM hr_leave_allocation WHERE employee_id = $1 AND holiday_status_id = $2 AND state = 'validate'`, employeeID, typeID).Scan(&allocated) // Used days for this type (approved leaves, current fiscal year) var used float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(number_of_days), 0) FROM hr_leave WHERE employee_id = $1 AND holiday_status_id = $2 AND state = 'validate' AND date_from >= date_trunc('year', CURRENT_DATE)`, employeeID, typeID).Scan(&used) return orm.Values{"remaining_leaves": allocated - used}, nil }) } // initHrLeaveAllocation registers the hr.leave.allocation model. // Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py func initHrLeaveAllocation() { m := orm.NewModel("hr.leave.allocation", orm.ModelOpts{ Description: "Time Off Allocation", Order: "create_date desc", }) m.AddFields( orm.Char("name", orm.FieldOpts{String: "Description"}), orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}), orm.Many2one("holiday_status_id", "hr.leave.type", orm.FieldOpts{String: "Time Off Type", Required: true}), orm.Float("number_of_days", orm.FieldOpts{String: "Duration (Days)", Required: true}), orm.Selection("state", []orm.SelectionItem{ {Value: "draft", Label: "To Submit"}, {Value: "confirm", Label: "To Approve"}, {Value: "validate", Label: "Approved"}, {Value: "refuse", Label: "Refused"}, }, orm.FieldOpts{String: "Status", Default: "draft"}), orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), orm.Selection("allocation_type", []orm.SelectionItem{ {Value: "regular", Label: "Regular Allocation"}, {Value: "accrual", Label: "Accrual Allocation"}, }, orm.FieldOpts{String: "Allocation Type", Default: "regular"}), orm.Float("accrual_increment", orm.FieldOpts{ String: "Monthly Accrual Increment", Help: "Number of days added each month for accrual allocations", }), orm.Date("last_accrual_date", orm.FieldOpts{ String: "Last Accrual Date", Help: "Date when the last accrual increment was applied", }), ) // action_approve: Approve allocation request. // Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py HrLeaveAllocation.action_approve() m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM hr_leave_allocation WHERE id = $1`, id, ).Scan(&state) if err != nil { return nil, fmt.Errorf("hr.leave.allocation: read state for %d: %w", id, err) } if state != "confirm" && state != "draft" { return nil, fmt.Errorf("hr.leave.allocation: can only approve allocations in 'To Submit' or 'To Approve' state (allocation %d is %q)", id, state) } _, err = env.Tx().Exec(env.Ctx(), `UPDATE hr_leave_allocation SET state = 'validate' WHERE id = $1`, id) if err != nil { return nil, fmt.Errorf("hr.leave.allocation: approve allocation %d: %w", id, err) } } return true, nil }) // action_refuse: Refuse allocation request. // Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py HrLeaveAllocation.action_refuse() m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM hr_leave_allocation WHERE id = $1`, id, ).Scan(&state) if err != nil { return nil, fmt.Errorf("hr.leave.allocation: read state for %d: %w", id, err) } if state == "validate" { return nil, fmt.Errorf("hr.leave.allocation: cannot refuse an already approved allocation (allocation %d)", id) } _, err = env.Tx().Exec(env.Ctx(), `UPDATE hr_leave_allocation SET state = 'refuse' WHERE id = $1`, id) if err != nil { return nil, fmt.Errorf("hr.leave.allocation: refuse allocation %d: %w", id, err) } } return true, nil }) } // initHrLeaveAccrualCron registers the accrual allocation cron method. // Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py _cron_accrual_allocation() func initHrLeaveAccrualCron() { alloc := orm.ExtendModel("hr.leave.allocation") // _cron_accrual_allocation: Auto-increment approved accrual-type allocations monthly. // For each approved accrual allocation whose last_accrual_date is more than a month ago // (or NULL), add accrual_increment days to number_of_days and update last_accrual_date. alloc.RegisterMethod("_cron_accrual_allocation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() today := time.Now().Format("2006-01-02") // Find all approved accrual allocations due for increment // Due = last_accrual_date is NULL or more than 30 days ago rows, err := env.Tx().Query(env.Ctx(), `SELECT id, COALESCE(number_of_days, 0), COALESCE(accrual_increment, 0) FROM hr_leave_allocation WHERE state = 'validate' AND allocation_type = 'accrual' AND COALESCE(accrual_increment, 0) > 0 AND (last_accrual_date IS NULL OR last_accrual_date <= CURRENT_DATE - INTERVAL '30 days')`) if err != nil { return nil, fmt.Errorf("hr.leave.allocation: accrual cron query: %w", err) } type accrualRow struct { id int64 days float64 increment float64 } var pending []accrualRow for rows.Next() { var r accrualRow if err := rows.Scan(&r.id, &r.days, &r.increment); err != nil { continue } pending = append(pending, r) } rows.Close() var updated int64 for _, r := range pending { newDays := r.days + r.increment if _, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_leave_allocation SET number_of_days = $1, last_accrual_date = $2 WHERE id = $3`, newDays, today, r.id); err != nil { return nil, fmt.Errorf("hr.leave.allocation: accrual update %d: %w", r.id, err) } updated++ } if updated > 0 { fmt.Printf("hr.leave.allocation accrual cron: incremented %d allocations\n", updated) } return map[string]interface{}{"updated": updated}, nil }) }