Files
goodie/addons/account/models/account_asset.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

660 lines
23 KiB
Go

package models
import (
"fmt"
"math"
"time"
"odoo-go/pkg/orm"
)
// initAccountAsset registers account.asset — fixed asset management with depreciation.
// Mirrors: odoo/addons/account_asset/models/account_asset.py
//
// An asset represents a fixed asset (equipment, vehicle, building, etc.) that is
// depreciated over time. This model tracks the original value, current book value,
// and generates depreciation entries according to the chosen computation method.
func initAccountAsset() {
m := orm.NewModel("account.asset", orm.ModelOpts{
Description: "Asset",
Order: "acquisition_date desc, id desc",
})
// -- Identity --
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Asset Name", Required: true}),
orm.Selection("asset_type", []orm.SelectionItem{
{Value: "purchase", Label: "Asset"},
{Value: "sale", Label: "Deferred Revenue"},
{Value: "expense", Label: "Deferred Expense"},
}, orm.FieldOpts{String: "Asset Type", Default: "purchase", Required: true}),
)
// -- Accounts --
m.AddFields(
orm.Many2one("account_asset_id", "account.account", orm.FieldOpts{
String: "Asset Account", Help: "Account used to record the purchase of the asset",
}),
orm.Many2one("account_depreciation_id", "account.account", orm.FieldOpts{
String: "Depreciation Account", Help: "Account used in depreciation entries for the depreciation amount",
}),
orm.Many2one("account_depreciation_expense_id", "account.account", orm.FieldOpts{
String: "Expense Account", Help: "Account used in depreciation entries for the expense",
}),
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
)
// -- Values --
m.AddFields(
orm.Monetary("original_value", orm.FieldOpts{String: "Original Value", CurrencyField: "currency_id"}),
orm.Monetary("book_value", orm.FieldOpts{String: "Book Value", Compute: "_compute_book_value", Store: true, CurrencyField: "currency_id"}),
orm.Monetary("salvage_value", orm.FieldOpts{String: "Not Depreciable Value", CurrencyField: "currency_id", Default: 0.0}),
orm.Monetary("value_residual", orm.FieldOpts{String: "Depreciable Value", Compute: "_compute_value_residual", Store: true, CurrencyField: "currency_id"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
)
// -- Dates --
m.AddFields(
orm.Date("acquisition_date", orm.FieldOpts{String: "Acquisition Date"}),
orm.Date("prorata_date", orm.FieldOpts{String: "Prorata Date", Help: "Prorata temporis start date for first depreciation"}),
orm.Boolean("prorata_computation_type", orm.FieldOpts{String: "Prorata Computation", Default: false}),
)
// -- Depreciation Method --
m.AddFields(
orm.Selection("method", []orm.SelectionItem{
{Value: "linear", Label: "Straight Line"},
{Value: "degressive", Label: "Declining"},
{Value: "degressive_then_linear", Label: "Declining then Straight Line"},
}, orm.FieldOpts{String: "Computation Method", Default: "linear", Required: true}),
orm.Integer("method_number", orm.FieldOpts{String: "Number of Depreciations", Default: 5}),
orm.Selection("method_period", []orm.SelectionItem{
{Value: "1", Label: "Monthly"},
{Value: "12", Label: "Yearly"},
}, orm.FieldOpts{String: "Duration", Default: "12"}),
orm.Float("method_progress_factor", orm.FieldOpts{String: "Declining Factor", Default: 0.3}),
)
// -- Status --
m.AddFields(
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "open", Label: "Running"},
{Value: "paused", Label: "On Hold"},
{Value: "close", Label: "Closed"},
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
// -- Related Entries --
m.AddFields(
orm.Many2one("original_move_line_ids", "account.move.line", orm.FieldOpts{
String: "Original Journal Item",
}),
orm.One2many("depreciation_move_ids", "account.move", "asset_id", orm.FieldOpts{
String: "Depreciation Entries",
}),
orm.Integer("depreciation_entries_count", orm.FieldOpts{
String: "Depreciation Entry Count", Compute: "_compute_depreciation_entries_count",
}),
)
// -- Computed fields --
// _compute_book_value: original - posted depreciation + salvage
m.RegisterCompute("book_value", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
assetID := rs.IDs()[0]
var originalValue, salvageValue float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(original_value::float8, 0), COALESCE(salvage_value::float8, 0)
FROM account_asset WHERE id = $1`, assetID,
).Scan(&originalValue, &salvageValue)
var totalDepreciated float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(ABS(l.balance)::float8), 0)
FROM account_move m
JOIN account_move_line l ON l.move_id = m.id
WHERE m.asset_id = $1 AND m.state = 'posted'
AND l.account_id = (SELECT account_depreciation_expense_id FROM account_asset WHERE id = $1)`,
assetID,
).Scan(&totalDepreciated)
bookValue := originalValue - totalDepreciated
return orm.Values{"book_value": bookValue}, nil
})
// _compute_value_residual: depreciable = original - salvage
m.RegisterCompute("value_residual", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
assetID := rs.IDs()[0]
var originalValue, salvageValue float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(original_value::float8, 0), COALESCE(salvage_value::float8, 0)
FROM account_asset WHERE id = $1`, assetID,
).Scan(&originalValue, &salvageValue)
return orm.Values{"value_residual": originalValue - salvageValue}, nil
})
// _compute_depreciation_entries_count
m.RegisterCompute("depreciation_entries_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
assetID := rs.IDs()[0]
var count int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID,
).Scan(&count)
return orm.Values{"depreciation_entries_count": count}, nil
})
// -- Business Methods --
// action_set_to_running: draft -> open
// Mirrors: odoo/addons/account_asset/models/account_asset.py validate()
m.RegisterMethod("action_set_to_running", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM account_asset WHERE id = $1`, id).Scan(&state)
if state != "draft" {
return nil, fmt.Errorf("account: can only confirm draft assets (current: %s)", state)
}
env.Tx().Exec(env.Ctx(),
`UPDATE account_asset SET state = 'open' WHERE id = $1`, id)
}
return true, nil
})
// action_set_to_close: open -> close
// Mirrors: odoo/addons/account_asset/models/account_asset.py set_to_close()
m.RegisterMethod("action_set_to_close", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE account_asset SET state = 'close' WHERE id = $1`, id)
}
return true, nil
})
// action_pause: open -> paused
m.RegisterMethod("action_pause", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE account_asset SET state = 'paused' WHERE id = $1 AND state = 'open'`, id)
}
return true, nil
})
// action_resume: paused -> open
m.RegisterMethod("action_resume", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE account_asset SET state = 'open' WHERE id = $1 AND state = 'paused'`, id)
}
return true, nil
})
// action_set_to_draft: revert to draft
m.RegisterMethod("action_set_to_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE account_asset SET state = 'draft' WHERE id = $1`, id)
}
return true, nil
})
// action_compute_depreciation: compute the depreciation schedule.
// Mirrors: odoo/addons/account_asset/models/account_asset.py compute_depreciation_board()
//
// Supports linear (straight-line) and degressive (declining balance) methods.
// Returns the computed schedule as a list of periods with amounts.
m.RegisterMethod("action_compute_depreciation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
assetID := rs.IDs()[0]
var originalValue, salvageValue, progressFactor float64
var method, methodPeriod string
var numPeriods int
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(original_value::float8, 0), COALESCE(salvage_value::float8, 0),
COALESCE(method, 'linear'), COALESCE(method_number, 5),
COALESCE(method_period, '12'), COALESCE(method_progress_factor, 0.3)
FROM account_asset WHERE id = $1`, assetID,
).Scan(&originalValue, &salvageValue, &method, &numPeriods, &methodPeriod, &progressFactor)
if err != nil {
return nil, fmt.Errorf("account: read asset %d: %w", assetID, err)
}
depreciableValue := originalValue - salvageValue
if numPeriods <= 0 {
numPeriods = 1
}
if depreciableValue <= 0 {
return map[string]interface{}{"schedule": []map[string]interface{}{}}, nil
}
schedule := make([]map[string]interface{}, 0, numPeriods)
remaining := depreciableValue
// Determine period length in months
periodMonths := 12
if methodPeriod == "1" {
periodMonths = 1
}
// Use prorata_date or acquisition_date as start, fallback to now
startDate := time.Now()
var prorataDate, acquisitionDate *time.Time
env.Tx().QueryRow(env.Ctx(),
`SELECT prorata_date, acquisition_date FROM account_asset WHERE id = $1`, assetID,
).Scan(&prorataDate, &acquisitionDate)
if prorataDate != nil {
startDate = *prorataDate
} else if acquisitionDate != nil {
startDate = *acquisitionDate
}
switch method {
case "linear":
periodicAmount := depreciableValue / float64(numPeriods)
for i := 0; i < numPeriods; i++ {
amt := periodicAmount
if i == numPeriods-1 {
amt = remaining // last period gets remainder to avoid rounding
}
remaining -= amt
depDate := startDate.AddDate(0, periodMonths*(i+1), 0)
schedule = append(schedule, map[string]interface{}{
"period": i + 1,
"date": depDate.Format("2006-01-02"),
"amount": math.Round(amt*100) / 100,
"depreciated": math.Round((depreciableValue-remaining)*100) / 100,
"remaining_value": math.Round((remaining+salvageValue)*100) / 100,
})
}
case "degressive":
for i := 0; i < numPeriods; i++ {
amt := remaining * progressFactor
if amt < 0.01 {
amt = remaining
}
if i == numPeriods-1 {
amt = remaining
}
remaining -= amt
depDate := startDate.AddDate(0, periodMonths*(i+1), 0)
schedule = append(schedule, map[string]interface{}{
"period": i + 1,
"date": depDate.Format("2006-01-02"),
"amount": math.Round(amt*100) / 100,
"depreciated": math.Round((depreciableValue-remaining)*100) / 100,
"remaining_value": math.Round((remaining+salvageValue)*100) / 100,
})
}
case "degressive_then_linear":
// Use declining balance until it drops below straight-line, then switch
linearAmount := depreciableValue / float64(numPeriods)
for i := 0; i < numPeriods; i++ {
degressiveAmt := remaining * progressFactor
// Linear amount for remaining periods
remainingPeriods := numPeriods - i
linearRemaining := remaining / float64(remainingPeriods)
amt := degressiveAmt
if linearRemaining > degressiveAmt {
amt = linearRemaining // switch to linear
}
if amt < linearAmount*0.01 {
amt = remaining
}
if i == numPeriods-1 {
amt = remaining
}
remaining -= amt
depDate := startDate.AddDate(0, periodMonths*(i+1), 0)
schedule = append(schedule, map[string]interface{}{
"period": i + 1,
"date": depDate.Format("2006-01-02"),
"amount": math.Round(amt*100) / 100,
"depreciated": math.Round((depreciableValue-remaining)*100) / 100,
"remaining_value": math.Round((remaining+salvageValue)*100) / 100,
})
}
}
return map[string]interface{}{"schedule": schedule}, nil
})
// action_create_depreciation_moves: generate actual journal entries for depreciation.
// Mirrors: odoo/addons/account_asset/models/account_asset.py _generate_moves()
m.RegisterMethod("action_create_depreciation_moves", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
assetID := rs.IDs()[0]
var name string
var journalID, companyID, depAccountID, expenseAccountID int64
var currencyID *int64
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, ''), COALESCE(journal_id, 0), COALESCE(company_id, 0),
COALESCE(account_depreciation_id, 0), COALESCE(account_depreciation_expense_id, 0),
currency_id, COALESCE(state, 'draft')
FROM account_asset WHERE id = $1`, assetID,
).Scan(&name, &journalID, &companyID, &depAccountID, &expenseAccountID, &currencyID, &state)
if err != nil {
return nil, fmt.Errorf("account: read asset %d: %w", assetID, err)
}
if state != "open" {
return nil, fmt.Errorf("account: can only create depreciation moves for running assets")
}
if journalID == 0 || depAccountID == 0 || expenseAccountID == 0 {
return nil, fmt.Errorf("account: asset %d is missing journal or account configuration", assetID)
}
// Compute the schedule by calling the registered method
assetModel := orm.Registry.Get("account.asset")
if assetModel == nil {
return nil, fmt.Errorf("account: asset model not registered")
}
computeMethod, hasMethod := assetModel.Methods["action_compute_depreciation"]
if !hasMethod {
return nil, fmt.Errorf("account: action_compute_depreciation method not found")
}
scheduleResult, err := computeMethod(rs)
if err != nil {
return nil, err
}
scheduleMap, ok := scheduleResult.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("account: invalid schedule result")
}
scheduleRaw, _ := scheduleMap["schedule"].([]map[string]interface{})
if len(scheduleRaw) == 0 {
return nil, fmt.Errorf("account: empty depreciation schedule")
}
// Check how many depreciation moves already exist
var existingCount int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID,
).Scan(&existingCount)
// Only create the next un-created entry
if existingCount >= len(scheduleRaw) {
return nil, fmt.Errorf("account: all depreciation entries already created")
}
entry := scheduleRaw[existingCount]
amount, _ := toFloat(entry["amount"])
depDate, _ := entry["date"].(string)
if depDate == "" {
depDate = time.Now().Format("2006-01-02")
}
var curID int64
if currencyID != nil {
curID = *currencyID
} else {
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID,
).Scan(&curID)
}
// Create the depreciation journal entry
moveRS := env.Model("account.move")
move, err := moveRS.Create(orm.Values{
"move_type": "entry",
"ref": fmt.Sprintf("Depreciation: %s (%d/%d)", name, existingCount+1, len(scheduleRaw)),
"date": depDate,
"journal_id": journalID,
"company_id": companyID,
"currency_id": curID,
"asset_id": assetID,
})
if err != nil {
return nil, fmt.Errorf("account: create depreciation move: %w", err)
}
lineRS := env.Model("account.move.line")
// Debit: expense account
if _, err := lineRS.Create(orm.Values{
"move_id": move.ID(),
"account_id": expenseAccountID,
"name": fmt.Sprintf("Depreciation: %s", name),
"debit": amount,
"credit": 0.0,
"balance": amount,
"company_id": companyID,
"journal_id": journalID,
"currency_id": curID,
"display_type": "product",
}); err != nil {
return nil, fmt.Errorf("account: create expense line: %w", err)
}
// Credit: depreciation account
if _, err := lineRS.Create(orm.Values{
"move_id": move.ID(),
"account_id": depAccountID,
"name": fmt.Sprintf("Depreciation: %s", name),
"debit": 0.0,
"credit": amount,
"balance": -amount,
"company_id": companyID,
"journal_id": journalID,
"currency_id": curID,
"display_type": "product",
}); err != nil {
return nil, fmt.Errorf("account: create depreciation line: %w", err)
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "account.move",
"res_id": move.ID(),
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "current",
}, nil
})
// action_create_deferred_entries: generate recognition entries for deferred
// revenue (sale) or deferred expense assets.
// Mirrors: odoo/addons/account_asset/models/account_asset.py _generate_deferred_entries()
//
// Unlike depreciation (which expenses an asset), deferred entries recognise
// income or expense over time. Monthly amount = original_value / method_number.
// Debit: deferred account (asset/liability), Credit: income/expense account.
m.RegisterMethod("action_create_deferred_entries", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
assetID := rs.IDs()[0]
var name, assetType, state string
var journalID, companyID, assetAccountID, expenseAccountID int64
var currencyID *int64
var originalValue float64
var methodNumber int
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, ''), COALESCE(asset_type, 'purchase'),
COALESCE(journal_id, 0), COALESCE(company_id, 0),
COALESCE(account_asset_id, 0), COALESCE(account_depreciation_expense_id, 0),
currency_id, COALESCE(state, 'draft'),
COALESCE(original_value::float8, 0), COALESCE(method_number, 1)
FROM account_asset WHERE id = $1`, assetID,
).Scan(&name, &assetType, &journalID, &companyID,
&assetAccountID, &expenseAccountID, &currencyID, &state,
&originalValue, &methodNumber)
if err != nil {
return nil, fmt.Errorf("account: read asset %d: %w", assetID, err)
}
if assetType != "sale" && assetType != "expense" {
return nil, fmt.Errorf("account: deferred entries only apply to deferred revenue (sale) or deferred expense assets, got %q", assetType)
}
if state != "open" {
return nil, fmt.Errorf("account: can only create deferred entries for running assets")
}
if journalID == 0 || assetAccountID == 0 || expenseAccountID == 0 {
return nil, fmt.Errorf("account: asset %d is missing journal or account configuration", assetID)
}
if methodNumber <= 0 {
methodNumber = 1
}
monthlyAmount := math.Round(originalValue/float64(methodNumber)*100) / 100
// How many entries already exist?
var existingCount int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID,
).Scan(&existingCount)
if existingCount >= methodNumber {
return nil, fmt.Errorf("account: all deferred entries already created (%d/%d)", existingCount, methodNumber)
}
// Resolve currency
var curID int64
if currencyID != nil {
curID = *currencyID
} else {
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID,
).Scan(&curID)
}
// Determine start date
startDate := time.Now()
var acqDate *time.Time
env.Tx().QueryRow(env.Ctx(),
`SELECT acquisition_date FROM account_asset WHERE id = $1`, assetID,
).Scan(&acqDate)
if acqDate != nil {
startDate = *acqDate
}
entryDate := startDate.AddDate(0, existingCount+1, 0).Format("2006-01-02")
period := existingCount + 1
// Last entry absorbs rounding remainder
amount := monthlyAmount
if period == methodNumber {
var alreadyRecognised float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(ABS(l.balance)::float8), 0)
FROM account_move m
JOIN account_move_line l ON l.move_id = m.id
WHERE m.asset_id = $1
AND l.account_id = $2`, assetID, expenseAccountID,
).Scan(&alreadyRecognised)
amount = math.Round((originalValue-alreadyRecognised)*100) / 100
}
// Create the recognition journal entry
moveRS := env.Model("account.move")
move, err := moveRS.Create(orm.Values{
"move_type": "entry",
"ref": fmt.Sprintf("Deferred recognition: %s (%d/%d)", name, period, methodNumber),
"date": entryDate,
"journal_id": journalID,
"company_id": companyID,
"currency_id": curID,
"asset_id": assetID,
})
if err != nil {
return nil, fmt.Errorf("account: create deferred entry: %w", err)
}
lineRS := env.Model("account.move.line")
// Debit: deferred account (asset account — the balance sheet deferral)
if _, err := lineRS.Create(orm.Values{
"move_id": move.ID(),
"account_id": assetAccountID,
"name": fmt.Sprintf("Deferred recognition: %s", name),
"debit": amount,
"credit": 0.0,
"balance": amount,
"company_id": companyID,
"journal_id": journalID,
"currency_id": curID,
"display_type": "product",
}); err != nil {
return nil, fmt.Errorf("account: create deferred debit line: %w", err)
}
// Credit: income/expense account
if _, err := lineRS.Create(orm.Values{
"move_id": move.ID(),
"account_id": expenseAccountID,
"name": fmt.Sprintf("Deferred recognition: %s", name),
"debit": 0.0,
"credit": amount,
"balance": -amount,
"company_id": companyID,
"journal_id": journalID,
"currency_id": curID,
"display_type": "product",
}); err != nil {
return nil, fmt.Errorf("account: create deferred credit line: %w", err)
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "account.move",
"res_id": move.ID(),
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "current",
}, nil
})
// -- DefaultGet --
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
vals := orm.Values{
"acquisition_date": time.Now().Format("2006-01-02"),
"state": "draft",
"method": "linear",
"method_number": 5,
"method_period": "12",
}
companyID := env.CompanyID()
if companyID > 0 {
vals["company_id"] = companyID
var curID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID,
).Scan(&curID)
if curID > 0 {
vals["currency_id"] = curID
}
}
return vals
}
// Extend account.move with asset_id back-reference
initAccountMoveAssetExtension()
}
// initAccountMoveAssetExtension adds asset_id to account.move for depreciation entries.
func initAccountMoveAssetExtension() {
ext := orm.ExtendModel("account.move")
ext.AddFields(
orm.Many2one("asset_id", "account.asset", orm.FieldOpts{
String: "Asset", Help: "Asset linked to this depreciation entry",
}),
)
}