Files
goodie/addons/hr/models/hr_expense.go
Marc 66383adf06 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>
2026-04-12 18:41:57 +02:00

353 lines
13 KiB
Go

package models
import (
"fmt"
"time"
"odoo-go/pkg/orm"
)
// initHrExpense registers the hr.expense and hr.expense.sheet models.
// Mirrors: odoo/addons/hr_expense/models/hr_expense.py
func initHrExpense() {
orm.NewModel("hr.expense", orm.ModelOpts{
Description: "Expense",
Order: "date desc, id desc",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Expense Type"}),
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
orm.Monetary("total_amount", orm.FieldOpts{String: "Total", Required: true, CurrencyField: "currency_id"}),
orm.Monetary("unit_amount", orm.FieldOpts{String: "Unit Price", CurrencyField: "currency_id"}),
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Many2one("sheet_id", "hr.expense.sheet", orm.FieldOpts{String: "Expense Report"}),
orm.Many2one("account_id", "account.account", orm.FieldOpts{String: "Account"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "To Submit"},
{Value: "reported", Label: "Submitted"},
{Value: "approved", Label: "Approved"},
{Value: "done", Label: "Paid"},
{Value: "refused", Label: "Refused"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
orm.Selection("payment_mode", []orm.SelectionItem{
{Value: "own_account", Label: "Employee (to reimburse)"},
{Value: "company_account", Label: "Company"},
}, orm.FieldOpts{String: "Payment By", Default: "own_account"}),
orm.Text("description", orm.FieldOpts{String: "Notes"}),
orm.Binary("receipt", orm.FieldOpts{String: "Receipt"}),
)
// -- Expense Methods --
// action_submit: draft → reported
exp := orm.Registry.Get("hr.expense")
if exp != nil {
exp.RegisterMethod("action_submit", 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_expense SET state = 'reported' WHERE id = $1 AND state = 'draft'`, id); err != nil {
return nil, fmt.Errorf("hr.expense: submit %d: %w", id, err)
}
}
return true, nil
})
// _action_validate_expense: Check that expense has amount > 0 and a receipt.
exp.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var amount float64
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(total_amount, 0), COALESCE(state, 'draft') FROM hr_expense WHERE id = $1`, id,
).Scan(&amount, &state)
if amount <= 0 {
return nil, fmt.Errorf("hr.expense: expense %d has no amount", id)
}
if state != "reported" {
return nil, fmt.Errorf("hr.expense: expense %d must be submitted first (state: %s)", id, state)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'approved' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense: validate %d: %w", id, err)
}
}
return true, nil
})
exp.RegisterMethod("action_refuse", 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_expense SET state = 'refused' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense: refuse %d: %w", id, err)
}
}
return true, nil
})
}
sheet := orm.NewModel("hr.expense.sheet", orm.ModelOpts{
Description: "Expense Report",
Order: "create_date desc",
})
sheet.AddFields(
orm.Char("name", orm.FieldOpts{String: "Report Name", Required: true}),
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}),
orm.One2many("expense_line_ids", "hr.expense", "sheet_id", orm.FieldOpts{String: "Expenses"}),
orm.Monetary("total_amount", orm.FieldOpts{String: "Total", Compute: "_compute_total", Store: true, CurrencyField: "currency_id"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "submit", Label: "Submitted"},
{Value: "approve", Label: "Approved"},
{Value: "post", Label: "Posted"},
{Value: "done", Label: "Paid"},
{Value: "cancel", Label: "Refused"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}),
orm.Integer("expense_count", orm.FieldOpts{String: "Expense Count", Compute: "_compute_expense_count"}),
)
// _compute_total: Sum of expense amounts.
sheet.RegisterCompute("total_amount", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var total float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(total_amount::float8), 0) FROM hr_expense WHERE sheet_id = $1`, id,
).Scan(&total)
return orm.Values{"total_amount": total}, nil
})
sheet.RegisterCompute("expense_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var count int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM hr_expense WHERE sheet_id = $1`, id).Scan(&count)
return orm.Values{"expense_count": count}, nil
})
// -- Expense Sheet Workflow Methods --
sheet.RegisterMethod("action_submit", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
// Validate: must have at least one expense line
var count int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM hr_expense WHERE sheet_id = $1`, id).Scan(&count)
if count == 0 {
return nil, fmt.Errorf("hr.expense.sheet: cannot submit empty report %d", id)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense_sheet SET state = 'submit' WHERE id = $1 AND state = 'draft'`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: submit %d: %w", id, err)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'reported' WHERE sheet_id = $1 AND state = 'draft'`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update lines for submit %d: %w", id, err)
}
}
return true, nil
})
sheet.RegisterMethod("action_approve", 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_expense_sheet SET state = 'approve' WHERE id = $1 AND state = 'submit'`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: approve %d: %w", id, err)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'approved' WHERE sheet_id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update lines for approve %d: %w", id, err)
}
}
return true, nil
})
sheet.RegisterMethod("action_refuse", 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_expense_sheet SET state = 'cancel' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: refuse %d: %w", id, err)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'refused' WHERE sheet_id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update lines for refuse %d: %w", id, err)
}
}
return true, nil
})
// action_post: Create a journal entry (account.move) from approved expense sheet.
// Debit: expense account, Credit: payable account.
// Mirrors: odoo/addons/hr_expense/models/hr_expense_sheet.py action_sheet_move_create()
sheet.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, sheetID := range rs.IDs() {
// Validate state = approve
var state string
var employeeID int64
var companyID *int64
var currencyID *int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(state, 'draft'), employee_id,
company_id, currency_id
FROM hr_expense_sheet WHERE id = $1`, sheetID,
).Scan(&state, &employeeID, &companyID, &currencyID); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: read %d: %w", sheetID, err)
}
if state != "approve" {
return nil, fmt.Errorf("hr.expense.sheet: can only post approved reports (sheet %d is %q)", sheetID, state)
}
// Fetch expense lines
expRows, err := env.Tx().Query(env.Ctx(),
`SELECT id, name, COALESCE(total_amount, 0), account_id
FROM hr_expense WHERE sheet_id = $1`, sheetID)
if err != nil {
return nil, fmt.Errorf("hr.expense.sheet: fetch expenses for %d: %w", sheetID, err)
}
type expLine struct {
id int64
name string
amount float64
accountID *int64
}
var lines []expLine
var total float64
for expRows.Next() {
var l expLine
if err := expRows.Scan(&l.id, &l.name, &l.amount, &l.accountID); err != nil {
continue
}
lines = append(lines, l)
total += l.amount
}
expRows.Close()
if len(lines) == 0 {
return nil, fmt.Errorf("hr.expense.sheet: no expenses to post on sheet %d", sheetID)
}
// Get employee's home address partner for payable line
var partnerID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT address_home_id FROM hr_employee WHERE id = $1`, employeeID,
).Scan(&partnerID)
// Create account.move
moveVals := orm.Values{
"move_type": "in_invoice",
"state": "draft",
"date": time.Now().Format("2006-01-02"),
}
if companyID != nil {
moveVals["company_id"] = *companyID
}
if currencyID != nil {
moveVals["currency_id"] = *currencyID
}
if partnerID != nil {
moveVals["partner_id"] = *partnerID
}
moveRS := env.Model("account.move")
move, err := moveRS.Create(moveVals)
if err != nil {
return nil, fmt.Errorf("hr.expense.sheet: create journal entry for %d: %w", sheetID, err)
}
moveID := move.ID()
// Create move lines: one debit line per expense, one credit (payable) line for total
for _, l := range lines {
debitVals := orm.Values{
"move_id": moveID,
"name": l.name,
"debit": l.amount,
"credit": float64(0),
}
if l.accountID != nil {
debitVals["account_id"] = *l.accountID
}
if partnerID != nil {
debitVals["partner_id"] = *partnerID
}
lineRS := env.Model("account.move.line")
if _, err := lineRS.Create(debitVals); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: create debit line: %w", err)
}
}
// Credit line (payable) — find payable account
var payableAccID int64
cid := int64(0)
if companyID != nil {
cid = *companyID
}
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account
WHERE account_type = 'liability_payable' AND company_id = $1
ORDER BY code LIMIT 1`, cid).Scan(&payableAccID)
creditVals := orm.Values{
"move_id": moveID,
"name": "Employee Expense Payable",
"debit": float64(0),
"credit": total,
}
if payableAccID > 0 {
creditVals["account_id"] = payableAccID
}
if partnerID != nil {
creditVals["partner_id"] = *partnerID
}
lineRS := env.Model("account.move.line")
if _, err := lineRS.Create(creditVals); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: create credit line: %w", err)
}
// Update expense sheet state and link to move
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense_sheet SET state = 'post', account_move_id = $1 WHERE id = $2`,
moveID, sheetID); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update state to post %d: %w", sheetID, err)
}
// Update expense line states
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'done' WHERE sheet_id = $1`, sheetID); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update expense states %d: %w", sheetID, err)
}
}
return true, nil
})
sheet.RegisterMethod("action_reset", 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_expense_sheet SET state = 'draft' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: reset %d: %w", id, err)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'draft' WHERE sheet_id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update lines for reset %d: %w", id, err)
}
}
return true, nil
})
}