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:
303
addons/hr/models/hr_payroll.go
Normal file
303
addons/hr/models/hr_payroll.go
Normal 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"}),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user