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:
Marc
2026-04-03 21:59:50 +02:00
parent b8fa4719ad
commit 0a76a2b9aa
11 changed files with 2572 additions and 0 deletions

View File

@@ -154,6 +154,20 @@ func initAccountMove() {
orm.Text("narration", orm.FieldOpts{String: "Terms and Conditions"}),
)
// -- Invoice Responsible & References --
m.AddFields(
orm.Many2one("invoice_user_id", "res.users", orm.FieldOpts{
String: "Salesperson", Help: "User responsible for this invoice",
}),
orm.Many2one("reversed_entry_id", "account.move", orm.FieldOpts{
String: "Reversed Entry", Help: "The move that was reversed to create this",
}),
orm.Char("access_url", orm.FieldOpts{String: "Portal Access URL", Compute: "_compute_access_url"}),
orm.Boolean("invoice_has_outstanding", orm.FieldOpts{
String: "Has Outstanding Payments", Compute: "_compute_invoice_has_outstanding",
}),
)
// -- Technical --
m.AddFields(
orm.Boolean("auto_post", orm.FieldOpts{String: "Auto-post"}),
@@ -161,6 +175,45 @@ func initAccountMove() {
orm.Integer("sequence_number", orm.FieldOpts{String: "Sequence Number"}),
)
// _compute_access_url: generates /my/invoices/<id> for portal access.
// Mirrors: odoo/addons/account/models/account_move.py _compute_access_url()
m.RegisterCompute("access_url", func(rs *orm.Recordset) (orm.Values, error) {
moveID := rs.IDs()[0]
return orm.Values{
"access_url": fmt.Sprintf("/my/invoices/%d", moveID),
}, nil
})
// _compute_invoice_has_outstanding: checks for outstanding payments.
// Mirrors: odoo/addons/account/models/account_move.py _compute_has_outstanding()
m.RegisterCompute("invoice_has_outstanding", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
moveID := rs.IDs()[0]
var partnerID *int64
var moveType string
env.Tx().QueryRow(env.Ctx(),
`SELECT partner_id, COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID,
).Scan(&partnerID, &moveType)
hasOutstanding := false
if partnerID != nil && *partnerID > 0 && (moveType == "out_invoice" || moveType == "out_refund") {
var count int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_move_line l
JOIN account_account a ON a.id = l.account_id
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
WHERE l.partner_id = $1 AND a.account_type = 'asset_receivable'
AND l.amount_residual < -0.005 AND l.reconciled = false`,
*partnerID).Scan(&count)
hasOutstanding = count > 0
}
return orm.Values{
"invoice_has_outstanding": hasOutstanding,
}, nil
})
// -- Computed Fields --
// _compute_amount: sums invoice lines to produce totals.
// Mirrors: odoo/addons/account/models/account_move.py AccountMove._compute_amount()
@@ -342,6 +395,116 @@ func initAccountMove() {
return true, nil
})
// action_reverse: creates a credit note (reversal) for the current move.
// Mirrors: odoo/addons/account/models/account_move.py action_reverse()
m.RegisterMethod("action_reverse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, moveID := range rs.IDs() {
// Read original move
var partnerID, journalID, companyID, currencyID int64
var moveType string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id,0), COALESCE(journal_id,0), COALESCE(company_id,0),
COALESCE(currency_id,0), COALESCE(move_type,'entry')
FROM account_move WHERE id = $1`, moveID,
).Scan(&partnerID, &journalID, &companyID, &currencyID, &moveType)
if err != nil {
return nil, fmt.Errorf("account: read move %d for reversal: %w", moveID, err)
}
// Determine reverse type
reverseType := moveType
switch moveType {
case "out_invoice":
reverseType = "out_refund"
case "in_invoice":
reverseType = "in_refund"
case "out_refund":
reverseType = "out_invoice"
case "in_refund":
reverseType = "in_invoice"
}
// Create reverse move
reverseRS := env.Model("account.move")
reverseMoveVals := orm.Values{
"move_type": reverseType,
"partner_id": partnerID,
"journal_id": journalID,
"company_id": companyID,
"currency_id": currencyID,
"reversed_entry_id": moveID,
"ref": fmt.Sprintf("Reversal of %d", moveID),
}
reverseMove, err := reverseRS.Create(reverseMoveVals)
if err != nil {
return nil, fmt.Errorf("account: create reverse move: %w", err)
}
// Copy lines with reversed debit/credit
lineRows, err := env.Tx().Query(env.Ctx(),
`SELECT account_id, name, COALESCE(debit::float8, 0), COALESCE(credit::float8, 0),
COALESCE(balance::float8, 0), COALESCE(quantity, 1), COALESCE(price_unit::float8, 0),
COALESCE(display_type, 'product'), partner_id, currency_id
FROM account_move_line WHERE move_id = $1`, moveID)
if err != nil {
return nil, fmt.Errorf("account: read lines for reversal: %w", err)
}
lineRS := env.Model("account.move.line")
var reverseLines []orm.Values
for lineRows.Next() {
var accID int64
var name string
var debit, credit, balance, qty, price float64
var displayType string
var lpID, lcurID *int64
if err := lineRows.Scan(&accID, &name, &debit, &credit, &balance, &qty, &price, &displayType, &lpID, &lcurID); err != nil {
lineRows.Close()
return nil, fmt.Errorf("account: scan line for reversal: %w", err)
}
lineVals := orm.Values{
"move_id": reverseMove.ID(),
"account_id": accID,
"name": name,
"debit": credit, // REVERSED
"credit": debit, // REVERSED
"balance": -balance, // REVERSED
"quantity": qty,
"price_unit": price,
"display_type": displayType,
"company_id": companyID,
"journal_id": journalID,
}
if lpID != nil {
lineVals["partner_id"] = *lpID
}
if lcurID != nil {
lineVals["currency_id"] = *lcurID
}
reverseLines = append(reverseLines, lineVals)
}
lineRows.Close()
for _, lv := range reverseLines {
if _, err := lineRS.Create(lv); err != nil {
return nil, fmt.Errorf("account: create reverse line: %w", err)
}
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "account.move",
"res_id": reverseMove.ID(),
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "current",
}, nil
}
return false, nil
})
// action_register_payment: opens the payment register wizard.
// Mirrors: odoo/addons/account/models/account_move.py action_register_payment()
m.RegisterMethod("action_register_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
@@ -873,6 +1036,30 @@ func initAccountMoveLine() {
}),
)
// -- Analytic & Tags --
m.AddFields(
orm.Many2many("tax_tag_ids", "account.account.tag", orm.FieldOpts{
String: "Tax Tags",
Relation: "account_move_line_account_tag_rel",
Column1: "line_id",
Column2: "tag_id",
}),
orm.Json("analytic_distribution", orm.FieldOpts{
String: "Analytic Distribution",
Help: "JSON distribution across analytic accounts, e.g. {\"42\": 100}",
}),
)
// -- Maturity & Related --
m.AddFields(
orm.Date("date_maturity", orm.FieldOpts{String: "Due Date"}),
orm.Selection("parent_state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "posted", Label: "Posted"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Parent State", Related: "move_id.state", Store: true}),
)
// -- Display --
m.AddFields(
orm.Selection("display_type", []orm.SelectionItem{
@@ -892,8 +1079,45 @@ func initAccountMoveLine() {
m.AddFields(
orm.Boolean("reconciled", orm.FieldOpts{String: "Reconciled"}),
orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Matching"}),
orm.Char("matching_number", orm.FieldOpts{
String: "Matching #", Compute: "_compute_matching_number",
Help: "P for partial, full reconcile name otherwise",
}),
)
// _compute_matching_number: derives the matching display from reconcile state.
// Mirrors: odoo/addons/account/models/account_move_line.py _compute_matching_number()
m.RegisterCompute("matching_number", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var fullRecID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT full_reconcile_id FROM account_move_line WHERE id = $1`, lineID,
).Scan(&fullRecID)
if fullRecID != nil && *fullRecID > 0 {
var name string
env.Tx().QueryRow(env.Ctx(),
`SELECT name FROM account_full_reconcile WHERE id = $1`, *fullRecID,
).Scan(&name)
return orm.Values{"matching_number": name}, nil
}
// Check if partially reconciled
var partialCount int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_partial_reconcile
WHERE debit_move_id = $1 OR credit_move_id = $1`, lineID,
).Scan(&partialCount)
if partialCount > 0 {
return orm.Values{"matching_number": "P"}, nil
}
return orm.Values{"matching_number": ""}, nil
})
// reconcile: matches debit lines against credit lines and creates
// account.partial.reconcile (and optionally account.full.reconcile) records.
// Mirrors: odoo/addons/account/models/account_move_line.py AccountMoveLine.reconcile()