package models import ( "fmt" "time" "odoo-go/pkg/orm" ) // initHrContract registers the hr.contract model with full lifecycle. // Mirrors: odoo/addons/hr_contract/models/hr_contract.py func initHrContract() { m := orm.NewModel("hr.contract", orm.ModelOpts{ Description: "Employee Contract", Order: "date_start desc", }) // -- Core Fields -- m.AddFields( orm.Char("name", orm.FieldOpts{String: "Contract Reference", Required: true}), orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{ String: "Employee", Required: true, Index: true, }), orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}), orm.Many2one("job_id", "hr.job", orm.FieldOpts{String: "Job Position"}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), ) // -- Contract Type & Duration -- m.AddFields( orm.Selection("contract_type", []orm.SelectionItem{ {Value: "permanent", Label: "Permanent"}, {Value: "fixed_term", Label: "Fixed Term"}, {Value: "probation", Label: "Probation"}, {Value: "freelance", Label: "Freelance / Contractor"}, {Value: "internship", Label: "Internship"}, }, orm.FieldOpts{String: "Contract Type", Default: "permanent"}), orm.Date("date_start", orm.FieldOpts{String: "Start Date", Required: true}), orm.Date("date_end", orm.FieldOpts{String: "End Date"}), orm.Integer("trial_period_days", orm.FieldOpts{String: "Trial Period (Days)"}), orm.Date("trial_date_end", orm.FieldOpts{String: "Trial End Date"}), orm.Integer("notice_period_days", orm.FieldOpts{ String: "Notice Period (Days)", Default: 30, }), ) // -- State Machine -- m.AddFields( orm.Selection("state", []orm.SelectionItem{ {Value: "draft", Label: "New"}, {Value: "open", Label: "Running"}, {Value: "pending", Label: "To Renew"}, {Value: "close", Label: "Expired"}, {Value: "cancel", Label: "Cancelled"}, }, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}), ) // -- Compensation -- m.AddFields( orm.Monetary("wage", orm.FieldOpts{ String: "Wage (Gross)", Required: true, CurrencyField: "currency_id", Help: "Gross monthly salary", }), orm.Selection("schedule_pay", []orm.SelectionItem{ {Value: "monthly", Label: "Monthly"}, {Value: "weekly", Label: "Weekly"}, {Value: "bi_weekly", Label: "Bi-Weekly"}, {Value: "yearly", Label: "Yearly"}, }, orm.FieldOpts{String: "Scheduled Pay", Default: "monthly"}), orm.Monetary("wage_annual", orm.FieldOpts{ String: "Annual Wage", Compute: "_compute_wage_annual", CurrencyField: "currency_id", }), orm.Monetary("bonus", orm.FieldOpts{ String: "Bonus", CurrencyField: "currency_id", }), orm.Monetary("transport_allowance", orm.FieldOpts{ String: "Transport Allowance", CurrencyField: "currency_id", }), orm.Monetary("meal_allowance", orm.FieldOpts{ String: "Meal Allowance", CurrencyField: "currency_id", }), orm.Monetary("other_allowance", orm.FieldOpts{ String: "Other Allowance", CurrencyField: "currency_id", }), orm.Monetary("total_compensation", orm.FieldOpts{ String: "Total Compensation", Compute: "_compute_total_compensation", CurrencyField: "currency_id", }), ) // -- Working Schedule -- m.AddFields( orm.Many2one("resource_calendar_id", "resource.calendar", orm.FieldOpts{ String: "Working Schedule", }), orm.Float("hours_per_week", orm.FieldOpts{String: "Hours per Week", Default: 40.0}), ) // -- History & Links -- m.AddFields( orm.Many2one("previous_contract_id", "hr.contract", orm.FieldOpts{ String: "Previous Contract", }), orm.Text("notes", orm.FieldOpts{String: "Notes"}), ) // -- Computed: days_remaining -- m.AddFields( orm.Integer("days_remaining", orm.FieldOpts{ String: "Days Remaining", Compute: "_compute_days_remaining", }), orm.Boolean("is_expired", orm.FieldOpts{ String: "Is Expired", Compute: "_compute_is_expired", }), orm.Boolean("is_expiring_soon", orm.FieldOpts{ String: "Expiring Soon", Compute: "_compute_is_expiring_soon", Help: "Contract expires within 30 days", }), ) // -- Computes -- m.RegisterCompute("days_remaining", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() id := rs.IDs()[0] var dateEnd *time.Time env.Tx().QueryRow(env.Ctx(), `SELECT date_end FROM hr_contract WHERE id = $1`, id).Scan(&dateEnd) if dateEnd == nil { return orm.Values{"days_remaining": int64(0)}, nil } days := int64(time.Until(*dateEnd).Hours() / 24) if days < 0 { days = 0 } return orm.Values{"days_remaining": days}, nil }) m.RegisterCompute("is_expired", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() id := rs.IDs()[0] var dateEnd *time.Time var state string env.Tx().QueryRow(env.Ctx(), `SELECT date_end, COALESCE(state, 'draft') FROM hr_contract WHERE id = $1`, id, ).Scan(&dateEnd, &state) expired := dateEnd != nil && dateEnd.Before(time.Now()) && state != "close" && state != "cancel" return orm.Values{"is_expired": expired}, nil }) m.RegisterCompute("is_expiring_soon", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() id := rs.IDs()[0] var dateEnd *time.Time var state string env.Tx().QueryRow(env.Ctx(), `SELECT date_end, COALESCE(state, 'draft') FROM hr_contract WHERE id = $1`, id, ).Scan(&dateEnd, &state) soon := false if dateEnd != nil && state == "open" { daysLeft := time.Until(*dateEnd).Hours() / 24 soon = daysLeft > 0 && daysLeft <= 30 } return orm.Values{"is_expiring_soon": soon}, nil }) m.RegisterCompute("wage_annual", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() id := rs.IDs()[0] var wage float64 var schedulePay string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(wage, 0), COALESCE(schedule_pay, 'monthly') FROM hr_contract WHERE id = $1`, id, ).Scan(&wage, &schedulePay) var annual float64 switch schedulePay { case "monthly": annual = wage * 12 case "weekly": annual = wage * 52 case "bi_weekly": annual = wage * 26 case "yearly": annual = wage } return orm.Values{"wage_annual": annual}, nil }) m.RegisterCompute("total_compensation", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() id := rs.IDs()[0] var wage, bonus, transport, meal, other float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(wage,0), COALESCE(bonus,0), COALESCE(transport_allowance,0), COALESCE(meal_allowance,0), COALESCE(other_allowance,0) FROM hr_contract WHERE id = $1`, id, ).Scan(&wage, &bonus, &transport, &meal, &other) return orm.Values{"total_compensation": wage + bonus + transport + meal + other}, nil }) // -- State Machine Methods -- // action_open: draft/pending → open (activate contract) m.RegisterMethod("action_open", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_contract SET state = 'open' WHERE id = $1 AND state IN ('draft', 'pending')`, id); err != nil { return nil, fmt.Errorf("hr.contract: open %d: %w", id, err) } // Set as current contract on employee if _, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_employee SET contract_id = $1 WHERE id = (SELECT employee_id FROM hr_contract WHERE id = $1)`, id); err != nil { return nil, fmt.Errorf("hr.contract: update employee contract link %d: %w", id, err) } } return true, nil }) // action_pending: open → pending (mark for renewal) m.RegisterMethod("action_pending", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_contract SET state = 'pending' WHERE id = $1 AND state = 'open'`, id); err != nil { return nil, fmt.Errorf("hr.contract: pending %d: %w", id, err) } } return true, nil }) // action_close: → close (expire contract) m.RegisterMethod("action_close", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_contract SET state = 'close' WHERE id = $1 AND state NOT IN ('cancel')`, id); err != nil { return nil, fmt.Errorf("hr.contract: close %d: %w", id, err) } } return true, nil }) // action_cancel: → cancel m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_contract SET state = 'cancel' WHERE id = $1 AND state IN ('draft', 'open', 'pending')`, id); err != nil { return nil, fmt.Errorf("hr.contract: cancel %d: %w", id, err) } } return true, nil }) // action_draft: → draft (reset) m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_contract SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id); err != nil { return nil, fmt.Errorf("hr.contract: draft %d: %w", id, err) } } return true, nil }) // action_renew: Create a new contract from this one (close current, create copy) // Mirrors: odoo/addons/hr_contract/models/hr_contract.py action_renew() m.RegisterMethod("action_renew", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() id := rs.IDs()[0] // Read current contract var employeeID, departmentID, jobID, companyID, currencyID, calendarID int64 var wage, bonus, transport, meal, other, hoursPerWeek float64 var contractType, schedulePay, name string var noticePeriod int err := env.Tx().QueryRow(env.Ctx(), `SELECT employee_id, COALESCE(department_id,0), COALESCE(job_id,0), COALESCE(company_id,0), COALESCE(currency_id,0), COALESCE(resource_calendar_id,0), COALESCE(wage,0), COALESCE(bonus,0), COALESCE(transport_allowance,0), COALESCE(meal_allowance,0), COALESCE(other_allowance,0), COALESCE(hours_per_week,40), COALESCE(contract_type,'permanent'), COALESCE(schedule_pay,'monthly'), COALESCE(name,''), COALESCE(notice_period_days,30) FROM hr_contract WHERE id = $1`, id, ).Scan(&employeeID, &departmentID, &jobID, &companyID, ¤cyID, &calendarID, &wage, &bonus, &transport, &meal, &other, &hoursPerWeek, &contractType, &schedulePay, &name, ¬icePeriod) if err != nil { return nil, fmt.Errorf("hr.contract: read for renew %d: %w", id, err) } // Close current contract if _, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_contract SET state = 'close' WHERE id = $1`, id); err != nil { return nil, fmt.Errorf("hr.contract: close for renewal %d: %w", id, err) } // Create new contract newVals := orm.Values{ "name": name + " (Renewal)", "employee_id": employeeID, "date_start": time.Now().Format("2006-01-02"), "wage": wage, "contract_type": contractType, "schedule_pay": schedulePay, "notice_period_days": noticePeriod, "bonus": bonus, "transport_allowance": transport, "meal_allowance": meal, "other_allowance": other, "hours_per_week": hoursPerWeek, "previous_contract_id": id, "state": "draft", } if departmentID > 0 { newVals["department_id"] = departmentID } if jobID > 0 { newVals["job_id"] = jobID } if companyID > 0 { newVals["company_id"] = companyID } if currencyID > 0 { newVals["currency_id"] = currencyID } if calendarID > 0 { newVals["resource_calendar_id"] = calendarID } contractRS := env.Model("hr.contract") newContract, err := contractRS.Create(newVals) if err != nil { return nil, fmt.Errorf("hr.contract: create renewal: %w", err) } return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "hr.contract", "res_id": newContract.ID(), "view_mode": "form", "views": [][]interface{}{{nil, "form"}}, "target": "current", }, nil }) // -- BeforeWrite: State Guard -- m.BeforeWrite = orm.StateGuard("hr_contract", "state IN ('close', 'cancel')", []string{"write_uid", "write_date", "state", "active"}, "cannot modify closed/cancelled contracts") } // initHrContractCron registers the contract expiration check cron job. // Should be called after initHrContract and cron system is ready. func initHrContractCron() { m := orm.ExtendModel("hr.contract") // _cron_check_expiring: Auto-close expired contracts, set pending for expiring soon. // Mirrors: odoo/addons/hr_contract/models/hr_contract.py _cron_check_expiring() m.RegisterMethod("_cron_check_expiring", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() today := time.Now().Format("2006-01-02") // Close expired contracts result, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_contract SET state = 'close' WHERE state = 'open' AND date_end IS NOT NULL AND date_end < $1`, today) if err != nil { return nil, fmt.Errorf("hr.contract: cron close expired: %w", err) } closed := result.RowsAffected() // Mark contracts expiring within 30 days as pending thirtyDays := time.Now().AddDate(0, 0, 30).Format("2006-01-02") result2, err := env.Tx().Exec(env.Ctx(), `UPDATE hr_contract SET state = 'pending' WHERE state = 'open' AND date_end IS NOT NULL AND date_end >= $1 AND date_end <= $2`, today, thirtyDays) if err != nil { return nil, fmt.Errorf("hr.contract: cron mark pending: %w", err) } pending := result2.RowsAffected() if closed > 0 || pending > 0 { fmt.Printf("hr.contract cron: closed %d, marked pending %d\n", closed, pending) } return true, nil }) }