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>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
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
|
||||
@@ -35,10 +40,63 @@ func initHrExpense() {
|
||||
orm.Binary("receipt", orm.FieldOpts{String: "Receipt"}),
|
||||
)
|
||||
|
||||
orm.NewModel("hr.expense.sheet", orm.ModelOpts{
|
||||
// -- 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",
|
||||
}).AddFields(
|
||||
})
|
||||
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"}),
|
||||
@@ -55,5 +113,240 @@ func initHrExpense() {
|
||||
{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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user