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>
This commit is contained in:
226
addons/account/models/account_budget.go
Normal file
226
addons/account/models/account_budget.go
Normal file
@@ -0,0 +1,226 @@
|
||||
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}),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user