- 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>
304 lines
11 KiB
Go
304 lines
11 KiB
Go
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"}),
|
|
)
|
|
}
|