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"}), ) }