Files
goodie/addons/account/models/account_budget.go
Marc 0a76a2b9aa Account module massive expansion: 2499→5049 LOC (+2550)
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>
2026-04-03 21:59:50 +02:00

227 lines
7.6 KiB
Go

package models
import (
"time"
"odoo-go/pkg/orm"
)
// initAccountBudget registers budget planning models.
// Mirrors: odoo/addons/account_budget/models/account_budget.py
//
// crossovered.budget defines a budget with a date range and responsibility.
// crossovered.budget.lines defines individual budget lines per analytic account.
func initAccountBudget() {
// -- Budget Header --
m := orm.NewModel("crossovered.budget", orm.ModelOpts{
Description: "Budget",
Order: "date_from desc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Budget Name", Required: true}),
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Responsible"}),
orm.Date("date_from", orm.FieldOpts{String: "Start Date", Required: true}),
orm.Date("date_to", orm.FieldOpts{String: "End Date", Required: true}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "cancel", Label: "Cancelled"},
{Value: "confirm", Label: "Confirmed"},
{Value: "validate", Label: "Validated"},
{Value: "done", Label: "Done"},
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true}),
orm.One2many("crossovered_budget_line", "crossovered.budget.lines", "crossovered_budget_id", orm.FieldOpts{
String: "Budget Lines",
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
)
// action_budget_confirm: draft -> confirm
m.RegisterMethod("action_budget_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE crossovered_budget SET state = 'confirm' WHERE id = $1 AND state = 'draft'`, id)
}
return true, nil
})
// action_budget_validate: confirm -> validate
m.RegisterMethod("action_budget_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE crossovered_budget SET state = 'validate' WHERE id = $1 AND state = 'confirm'`, id)
}
return true, nil
})
// action_budget_done: validate -> done
m.RegisterMethod("action_budget_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE crossovered_budget SET state = 'done' WHERE id = $1 AND state = 'validate'`, id)
}
return true, nil
})
// action_budget_cancel: any -> cancel
m.RegisterMethod("action_budget_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE crossovered_budget SET state = 'cancel' WHERE id = $1`, id)
}
return true, nil
})
// action_budget_draft: cancel -> draft
m.RegisterMethod("action_budget_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE crossovered_budget SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id)
}
return true, nil
})
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
vals := orm.Values{
"state": "draft",
"date_from": time.Now().Format("2006-01-02"),
"date_to": time.Now().AddDate(1, 0, 0).Format("2006-01-02"),
}
companyID := env.CompanyID()
if companyID > 0 {
vals["company_id"] = companyID
}
return vals
}
// -- Budget Lines --
bl := orm.NewModel("crossovered.budget.lines", orm.ModelOpts{
Description: "Budget Line",
Order: "date_from",
})
bl.AddFields(
orm.Many2one("crossovered_budget_id", "crossovered.budget", orm.FieldOpts{
String: "Budget", Required: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("analytic_account_id", "account.analytic.account", orm.FieldOpts{
String: "Analytic Account",
}),
orm.Many2one("general_budget_id", "account.budget.post", orm.FieldOpts{
String: "Budgetary Position",
}),
orm.Date("date_from", orm.FieldOpts{String: "Start Date", Required: true}),
orm.Date("date_to", orm.FieldOpts{String: "End Date", Required: true}),
orm.Monetary("planned_amount", orm.FieldOpts{
String: "Planned Amount", Required: true, CurrencyField: "currency_id",
}),
orm.Monetary("practical_amount", orm.FieldOpts{
String: "Practical Amount", Compute: "_compute_practical_amount", CurrencyField: "currency_id",
}),
orm.Float("percentage", orm.FieldOpts{
String: "Achievement", Compute: "_compute_percentage",
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Related: "crossovered_budget_id.company_id",
}),
orm.Many2one("paid_date", "res.company", orm.FieldOpts{String: "Paid Date"}),
orm.Boolean("is_above_budget", orm.FieldOpts{
String: "Above Budget", Compute: "_compute_is_above_budget",
}),
)
// _compute_practical_amount: sum of posted journal entries for the analytic account
// in the budget line's date range.
// Mirrors: odoo/addons/account_budget/models/account_budget.py _compute_practical_amount()
bl.RegisterCompute("practical_amount", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var analyticID *int64
var dateFrom, dateTo string
env.Tx().QueryRow(env.Ctx(),
`SELECT analytic_account_id, date_from::text, date_to::text
FROM crossovered_budget_lines WHERE id = $1`, lineID,
).Scan(&analyticID, &dateFrom, &dateTo)
if analyticID == nil || *analyticID == 0 {
return orm.Values{"practical_amount": 0.0}, nil
}
var total float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(al.amount::float8), 0)
FROM account_analytic_line al
WHERE al.account_id = $1 AND al.date >= $2 AND al.date <= $3`,
*analyticID, dateFrom, dateTo,
).Scan(&total)
return orm.Values{"practical_amount": total}, nil
})
// _compute_percentage: practical / planned * 100
bl.RegisterCompute("percentage", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var planned, practical float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(planned_amount::float8, 0), COALESCE(practical_amount::float8, 0)
FROM crossovered_budget_lines WHERE id = $1`, lineID,
).Scan(&planned, &practical)
pct := 0.0
if planned != 0 {
pct = (practical / planned) * 100
}
return orm.Values{"percentage": pct}, nil
})
// _compute_is_above_budget
bl.RegisterCompute("is_above_budget", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var planned, practical float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(planned_amount::float8, 0), COALESCE(practical_amount::float8, 0)
FROM crossovered_budget_lines WHERE id = $1`, lineID,
).Scan(&planned, &practical)
above := false
if planned > 0 {
above = practical > planned
} else if planned < 0 {
above = practical < planned
}
return orm.Values{"is_above_budget": above}, nil
})
// -- Budgetary Position --
// account.budget.post groups accounts for budgeting purposes.
// Mirrors: odoo/addons/account_budget/models/account_budget.py AccountBudgetPost
bp := orm.NewModel("account.budget.post", orm.ModelOpts{
Description: "Budgetary Position",
Order: "name",
})
bp.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Many2many("account_ids", "account.account", orm.FieldOpts{
String: "Accounts",
Relation: "account_budget_post_account_rel",
Column1: "budget_post_id",
Column2: "account_id",
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
)
}