feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// initHrContract registers the hr.contract model.
|
||||
"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{
|
||||
@@ -10,22 +15,383 @@ func initHrContract() {
|
||||
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}),
|
||||
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.Monetary("wage", orm.FieldOpts{String: "Wage", Required: true, CurrencyField: "currency_id"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
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"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
}, 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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user