New models (12): - account.asset: depreciation (linear/degressive), journal entry generation - account.edi.format + account.edi.document: UBL 2.1 XML e-invoicing - account.followup.line: payment follow-up escalation levels - account.reconcile.model + lines: automatic bank reconciliation rules - crossovered.budget + lines + account.budget.post: budgeting system - account.cash.rounding: invoice rounding (UP/DOWN/HALF-UP) - account.payment.method + lines: payment method definitions - account.invoice.send: invoice sending wizard Enhanced existing: - account.move: action_reverse (credit notes), access_url, invoice_has_outstanding - account.move.line: tax_tag_ids, analytic_distribution, date_maturity, matching_number - Entry hash chain integrity (SHA-256, secure_sequence_number) - Report HTML rendering for all 6 report types - res.partner extended with followup status + overdue tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
500 lines
17 KiB
Go
500 lines
17 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
|
|
}
|
|
|
|
startDate := time.Now()
|
|
|
|
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, ¤cyID, &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
|
|
})
|
|
|
|
// -- 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",
|
|
}),
|
|
)
|
|
}
|