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:
Marc
2026-04-12 18:41:57 +02:00
parent 2c7c1e6c88
commit 66383adf06
87 changed files with 14696 additions and 654 deletions

View File

@@ -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, &currencyID, &calendarID,
&wage, &bonus, &transport, &meal, &other, &hoursPerWeek,
&contractType, &schedulePay, &name, &noticePeriod)
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
})
}