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, ¤cyID); 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 }) }