- 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>
353 lines
13 KiB
Go
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, ¤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
|
|
})
|
|
}
|