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>
227 lines
7.6 KiB
Go
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}),
|
|
)
|
|
}
|