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

@@ -0,0 +1,303 @@
package models
import (
"fmt"
"sort"
"time"
"odoo-go/pkg/orm"
)
// initHrPayroll registers hr.salary.structure, hr.salary.rule, and hr.payslip models.
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py, hr_salary_rule.py, hr_payroll_structure.py
func initHrPayroll() {
// -- hr.salary.rule --
orm.NewModel("hr.salary.rule", orm.ModelOpts{
Description: "Salary Rule",
Order: "sequence, id",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
orm.Selection("category", []orm.SelectionItem{
{Value: "basic", Label: "Basic"},
{Value: "allowance", Label: "Allowance"},
{Value: "deduction", Label: "Deduction"},
{Value: "gross", Label: "Gross"},
{Value: "net", Label: "Net"},
}, orm.FieldOpts{String: "Category", Required: true, Default: "basic"}),
orm.Selection("amount_select", []orm.SelectionItem{
{Value: "fixed", Label: "Fixed Amount"},
{Value: "percentage", Label: "Percentage (%)"},
{Value: "code", Label: "Python/Go Code"},
}, orm.FieldOpts{String: "Amount Type", Required: true, Default: "fixed"}),
orm.Float("amount_fix", orm.FieldOpts{String: "Fixed Amount"}),
orm.Float("amount_percentage", orm.FieldOpts{String: "Percentage (%)"}),
orm.Char("amount_percentage_base", orm.FieldOpts{
String: "Percentage Based On",
Help: "Code of the rule whose result is used as the base for percentage calculation",
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 5}),
orm.Many2one("struct_id", "hr.salary.structure", orm.FieldOpts{String: "Salary Structure"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Text("note", orm.FieldOpts{String: "Description"}),
)
// -- hr.salary.structure --
orm.NewModel("hr.salary.structure", orm.ModelOpts{
Description: "Salary Structure",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.One2many("rule_ids", "hr.salary.rule", "struct_id", orm.FieldOpts{String: "Salary Rules"}),
orm.Text("note", orm.FieldOpts{String: "Description"}),
)
// -- hr.payslip --
m := orm.NewModel("hr.payslip", orm.ModelOpts{
Description: "Pay Slip",
Order: "number desc, id desc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Description"}),
orm.Char("number", orm.FieldOpts{String: "Reference", Readonly: true}),
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
String: "Employee", Required: true, Index: true,
}),
orm.Many2one("struct_id", "hr.salary.structure", orm.FieldOpts{
String: "Salary Structure", Required: true,
}),
orm.Many2one("contract_id", "hr.contract", orm.FieldOpts{String: "Contract"}),
orm.Date("date_from", orm.FieldOpts{String: "Date From", Required: true}),
orm.Date("date_to", orm.FieldOpts{String: "Date To", Required: true}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "verify", Label: "Waiting"},
{Value: "done", Label: "Done"},
{Value: "cancel", Label: "Rejected"},
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Monetary("net_wage", orm.FieldOpts{
String: "Net Wage", Compute: "_compute_net_wage", Store: true, CurrencyField: "currency_id",
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Text("note", orm.FieldOpts{String: "Notes"}),
)
// _compute_net_wage: Sum salary rule results stored in hr_payslip_line.
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py _compute_basic_net()
m.RegisterCompute("net_wage", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var net float64
// Net = sum of all line amounts (allowances positive, deductions negative)
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(
CASE WHEN category = 'deduction' THEN -amount ELSE amount END
), 0)
FROM hr_payslip_line WHERE slip_id = $1`, id,
).Scan(&net); err != nil {
return orm.Values{"net_wage": float64(0)}, nil
}
return orm.Values{"net_wage": net}, nil
})
// compute_sheet: Apply salary rules from the structure to compute payslip lines.
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py compute_sheet()
m.RegisterMethod("compute_sheet", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, slipID := range rs.IDs() {
// Read payslip data
var structID, contractID, employeeID int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT struct_id, COALESCE(contract_id, 0), employee_id
FROM hr_payslip WHERE id = $1`, slipID,
).Scan(&structID, &contractID, &employeeID); err != nil {
return nil, fmt.Errorf("hr.payslip: read %d: %w", slipID, err)
}
// Fetch contract wage as the base
var wage float64
if contractID > 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(wage, 0) FROM hr_contract WHERE id = $1`, contractID,
).Scan(&wage)
} else {
// Try to find open contract for the employee
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(wage, 0) FROM hr_contract
WHERE employee_id = $1 AND state = 'open'
ORDER BY date_start DESC LIMIT 1`, employeeID,
).Scan(&wage)
}
// Fetch salary rules for this structure, ordered by sequence
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id, name, code, COALESCE(category, 'basic'),
COALESCE(amount_select, 'fixed'),
COALESCE(amount_fix, 0), COALESCE(amount_percentage, 0),
COALESCE(amount_percentage_base, ''), sequence
FROM hr_salary_rule
WHERE struct_id = $1 AND COALESCE(active, true) = true
ORDER BY sequence, id`, structID)
if err != nil {
return nil, fmt.Errorf("hr.payslip: fetch rules for struct %d: %w", structID, err)
}
type rule struct {
id int64
name, code string
category string
amountSelect string
amountFix float64
amountPct float64
amountPctBase string
sequence int
}
var rules []rule
for rows.Next() {
var r rule
if err := rows.Scan(&r.id, &r.name, &r.code, &r.category,
&r.amountSelect, &r.amountFix, &r.amountPct, &r.amountPctBase, &r.sequence); err != nil {
rows.Close()
return nil, fmt.Errorf("hr.payslip: scan rule: %w", err)
}
rules = append(rules, r)
}
rows.Close()
sort.Slice(rules, func(i, j int) bool {
if rules[i].sequence != rules[j].sequence {
return rules[i].sequence < rules[j].sequence
}
return rules[i].id < rules[j].id
})
// Delete existing lines for re-computation
if _, err := env.Tx().Exec(env.Ctx(),
`DELETE FROM hr_payslip_line WHERE slip_id = $1`, slipID); err != nil {
return nil, fmt.Errorf("hr.payslip: clear lines for %d: %w", slipID, err)
}
// Compute each rule; track results by code for percentage-base lookups
codeResults := map[string]float64{
"BASIC": wage, // default base
}
for _, r := range rules {
var amount float64
switch r.amountSelect {
case "fixed":
amount = r.amountFix
case "percentage":
base := wage // default base is wage
if r.amountPctBase != "" {
if v, ok := codeResults[r.amountPctBase]; ok {
base = v
}
}
amount = base * r.amountPct / 100.0
default:
// "code" type — use fixed amount as fallback
amount = r.amountFix
}
codeResults[r.code] = amount
// Insert payslip line
if _, err := env.Tx().Exec(env.Ctx(),
`INSERT INTO hr_payslip_line
(slip_id, name, code, category, amount, sequence, salary_rule_id,
create_uid, write_uid, create_date, write_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, NOW(), NOW())`,
slipID, r.name, r.code, r.category, amount, r.sequence, r.id,
env.UID(),
); err != nil {
return nil, fmt.Errorf("hr.payslip: insert line for rule %s: %w", r.code, err)
}
}
// Update payslip state to verify and compute net_wage inline
var net float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(
CASE WHEN category = 'deduction' THEN -amount ELSE amount END
), 0) FROM hr_payslip_line WHERE slip_id = $1`, slipID,
).Scan(&net)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_payslip SET state = 'verify', net_wage = $1 WHERE id = $2`,
net, slipID); err != nil {
return nil, fmt.Errorf("hr.payslip: update state to verify %d: %w", slipID, err)
}
// Generate payslip number if empty
now := time.Now()
number := fmt.Sprintf("SLIP/%04d/%02d/%05d", now.Year(), now.Month(), slipID)
env.Tx().Exec(env.Ctx(),
`UPDATE hr_payslip SET number = $1 WHERE id = $2 AND (number IS NULL OR number = '')`,
number, slipID)
}
return true, nil
})
// action_done: verify → done (confirm payslip)
m.RegisterMethod("action_done", 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_payslip SET state = 'done' WHERE id = $1 AND state = 'verify'`, id); err != nil {
return nil, fmt.Errorf("hr.payslip: action_done %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_payslip SET state = 'cancel' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.payslip: action_cancel %d: %w", id, err)
}
}
return true, nil
})
// action_draft: cancel → draft
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_payslip SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id); err != nil {
return nil, fmt.Errorf("hr.payslip: action_draft %d: %w", id, err)
}
}
return true, nil
})
// -- hr.payslip.line — detail lines computed from salary rules --
orm.NewModel("hr.payslip.line", orm.ModelOpts{
Description: "Payslip Line",
Order: "sequence, id",
}).AddFields(
orm.Many2one("slip_id", "hr.payslip", orm.FieldOpts{
String: "Pay Slip", Required: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("salary_rule_id", "hr.salary.rule", orm.FieldOpts{String: "Rule"}),
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
orm.Selection("category", []orm.SelectionItem{
{Value: "basic", Label: "Basic"},
{Value: "allowance", Label: "Allowance"},
{Value: "deduction", Label: "Deduction"},
{Value: "gross", Label: "Gross"},
{Value: "net", Label: "Net"},
}, orm.FieldOpts{String: "Category"}),
orm.Float("amount", orm.FieldOpts{String: "Amount"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
)
}