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

@@ -0,0 +1,298 @@
package models
import (
"fmt"
"strings"
"odoo-go/pkg/orm"
)
// initAccountReconcileModel registers account.reconcile.model — automatic reconciliation rules.
// Mirrors: odoo/addons/account/models/account_reconcile_model.py
//
// Reconcile models define rules that automatically match bank statement lines
// with open invoices or create write-off entries. Three rule types:
// - "writeoff_button": manual write-off via button
// - "writeoff_suggestion": auto-suggest write-off in bank reconciliation
// - "invoice_matching": auto-match with open invoices based on criteria
func initAccountReconcileModel() {
m := orm.NewModel("account.reconcile.model", orm.ModelOpts{
Description: "Reconcile Model",
Order: "sequence, id",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
orm.Selection("rule_type", []orm.SelectionItem{
{Value: "writeoff_button", Label: "Button to generate counterpart entry"},
{Value: "writeoff_suggestion", Label: "Rule to suggest counterpart entry"},
{Value: "invoice_matching", Label: "Rule to match invoices/bills"},
}, orm.FieldOpts{String: "Type", Default: "writeoff_button", Required: true}),
orm.Boolean("auto_reconcile", orm.FieldOpts{
String: "Auto-validate",
Help: "Validate the statement line automatically if the matched amount is close enough",
}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
// Matching criteria
orm.Boolean("match_nature", orm.FieldOpts{String: "Amount Nature", Default: true}),
orm.Selection("match_amount", []orm.SelectionItem{
{Value: "lower", Label: "Is Lower Than"},
{Value: "greater", Label: "Is Greater Than"},
{Value: "between", Label: "Is Between"},
}, orm.FieldOpts{String: "Amount Condition"}),
orm.Float("match_amount_min", orm.FieldOpts{String: "Amount Min"}),
orm.Float("match_amount_max", orm.FieldOpts{String: "Amount Max"}),
orm.Char("match_label", orm.FieldOpts{
String: "Label", Help: "Regex pattern to match the bank statement line label",
}),
orm.Selection("match_label_param", []orm.SelectionItem{
{Value: "contains", Label: "Contains"},
{Value: "not_contains", Label: "Not Contains"},
{Value: "match_regex", Label: "Match Regex"},
}, orm.FieldOpts{String: "Label Parameter", Default: "contains"}),
orm.Char("match_note", orm.FieldOpts{String: "Note", Help: "Match on the statement line notes"}),
orm.Selection("match_note_param", []orm.SelectionItem{
{Value: "contains", Label: "Contains"},
{Value: "not_contains", Label: "Not Contains"},
{Value: "match_regex", Label: "Match Regex"},
}, orm.FieldOpts{String: "Note Parameter", Default: "contains"}),
orm.Many2many("match_journal_ids", "account.journal", orm.FieldOpts{
String: "Journals",
Relation: "reconcile_model_journal_rel",
Column1: "model_id",
Column2: "journal_id",
}),
orm.Many2many("match_partner_ids", "res.partner", orm.FieldOpts{
String: "Partners",
Relation: "reconcile_model_partner_rel",
Column1: "model_id",
Column2: "partner_id",
}),
orm.Selection("match_partner_category_id", []orm.SelectionItem{
{Value: "customer", Label: "Customer"},
{Value: "supplier", Label: "Vendor"},
}, orm.FieldOpts{String: "Partner Category"}),
orm.Float("match_total_amount_param", orm.FieldOpts{
String: "Amount matching %",
Default: 100,
Help: "Percentage of the transaction amount to consider for matching",
}),
orm.Boolean("match_same_currency", orm.FieldOpts{
String: "Same Currency", Default: true,
}),
orm.Integer("past_months_limit", orm.FieldOpts{
String: "Search Months Limit",
Default: 18,
Help: "Number of months in the past to consider for matching",
}),
orm.Float("decimal_separator", orm.FieldOpts{String: "Decimal Separator"}),
// Write-off lines
orm.One2many("line_ids", "account.reconcile.model.line", "model_id", orm.FieldOpts{
String: "Write-off Lines",
}),
)
// apply_rules: try to match a bank statement line against this reconcile model.
// Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_rules()
m.RegisterMethod("apply_rules", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 1 {
return nil, fmt.Errorf("account: apply_rules requires a statement line ID")
}
env := rs.Env()
modelID := rs.IDs()[0]
stLineID, ok := toInt64Arg(args[0])
if !ok {
return nil, fmt.Errorf("account: invalid statement line ID")
}
// Read model config
var ruleType string
var autoReconcile bool
var matchLabel, matchLabelParam *string
var matchAmountMin, matchAmountMax float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(rule_type, 'writeoff_button'), COALESCE(auto_reconcile, false),
match_label, match_label_param,
COALESCE(match_amount_min, 0), COALESCE(match_amount_max, 0)
FROM account_reconcile_model WHERE id = $1`, modelID,
).Scan(&ruleType, &autoReconcile, &matchLabel, &matchLabelParam, &matchAmountMin, &matchAmountMax)
// Read statement line
var stAmount float64
var stLabel, stPaymentRef *string
var stPartnerID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(amount::float8, 0), payment_ref, narration, partner_id
FROM account_bank_statement_line WHERE id = $1`, stLineID,
).Scan(&stAmount, &stPaymentRef, &stLabel, &stPartnerID)
// Apply label matching
if matchLabel != nil && *matchLabel != "" && matchLabelParam != nil {
labelToCheck := ""
if stPaymentRef != nil {
labelToCheck = *stPaymentRef
}
if stLabel != nil {
labelToCheck += " " + *stLabel
}
switch *matchLabelParam {
case "contains":
if !strings.Contains(strings.ToLower(labelToCheck), strings.ToLower(*matchLabel)) {
return map[string]interface{}{"matched": false}, nil
}
case "not_contains":
if strings.Contains(strings.ToLower(labelToCheck), strings.ToLower(*matchLabel)) {
return map[string]interface{}{"matched": false}, nil
}
}
}
switch ruleType {
case "invoice_matching":
return applyInvoiceMatching(env, stLineID, stAmount, stPartnerID, autoReconcile)
case "writeoff_suggestion":
return applyWriteoffSuggestion(env, modelID, stLineID, stAmount)
default:
return map[string]interface{}{"matched": false, "rule_type": ruleType}, nil
}
})
// -- Reconcile Model Line (write-off definition) --
rml := orm.NewModel("account.reconcile.model.line", orm.ModelOpts{
Description: "Reconcile Model Line",
Order: "sequence, id",
})
rml.AddFields(
orm.Many2one("model_id", "account.reconcile.model", orm.FieldOpts{
String: "Model", Required: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("account_id", "account.account", orm.FieldOpts{
String: "Account", Required: true,
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Char("label", orm.FieldOpts{String: "Journal Item Label"}),
orm.Selection("amount_type", []orm.SelectionItem{
{Value: "percentage", Label: "Percentage of balance"},
{Value: "fixed", Label: "Fixed"},
{Value: "percentage_st_line", Label: "Percentage of statement line"},
{Value: "regex", Label: "From label"},
}, orm.FieldOpts{String: "Amount Type", Default: "percentage", Required: true}),
orm.Float("amount", orm.FieldOpts{String: "Write-off Amount", Default: 100}),
orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{
String: "Taxes",
Relation: "reconcile_model_line_tax_rel",
Column1: "line_id",
Column2: "tax_id",
}),
orm.Boolean("force_tax_included", orm.FieldOpts{String: "Tax Included in Price"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Related: "model_id.company_id",
}),
)
}
// applyInvoiceMatching tries to find an open invoice matching the statement line amount.
// Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_invoice_matching()
func applyInvoiceMatching(env *orm.Environment, stLineID int64, amount float64, partnerID *int64, autoReconcile bool) (interface{}, error) {
if partnerID == nil || *partnerID == 0 {
return map[string]interface{}{"matched": false, "reason": "no partner"}, nil
}
absAmount := amount
if absAmount < 0 {
absAmount = -absAmount
}
// Find open invoices for the partner with matching residual amount
var matchedMoveLineID int64
var matchedResidual float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT l.id, ABS(COALESCE(l.amount_residual::float8, 0))
FROM account_move_line l
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
JOIN account_account a ON a.id = l.account_id
WHERE l.partner_id = $1
AND a.account_type IN ('asset_receivable', 'liability_payable')
AND ABS(COALESCE(l.amount_residual::float8, 0)) > 0.005
AND ABS(ABS(COALESCE(l.amount_residual::float8, 0)) - $2) < $2 * 0.05
ORDER BY ABS(ABS(COALESCE(l.amount_residual::float8, 0)) - $2)
LIMIT 1`, *partnerID, absAmount,
).Scan(&matchedMoveLineID, &matchedResidual)
if err != nil || matchedMoveLineID == 0 {
return map[string]interface{}{"matched": false, "reason": "no matching invoice"}, nil
}
result := map[string]interface{}{
"matched": true,
"move_line_id": matchedMoveLineID,
"residual": matchedResidual,
"auto_validate": autoReconcile,
}
// If auto-reconcile, mark the statement line as reconciled
if autoReconcile {
env.Tx().Exec(env.Ctx(),
`UPDATE account_bank_statement_line SET move_line_id = $1, is_reconciled = true WHERE id = $2`,
matchedMoveLineID, stLineID)
}
return result, nil
}
// applyWriteoffSuggestion suggests a write-off entry based on the reconcile model's lines.
// Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_writeoff()
func applyWriteoffSuggestion(env *orm.Environment, modelID, stLineID int64, amount float64) (interface{}, error) {
// Read write-off lines for this model
rows, err := env.Tx().Query(env.Ctx(),
`SELECT account_id, COALESCE(label, ''), COALESCE(amount_type, 'percentage'),
COALESCE(amount, 100)
FROM account_reconcile_model_line
WHERE model_id = $1
ORDER BY sequence, id`, modelID)
if err != nil {
return map[string]interface{}{"matched": false}, nil
}
defer rows.Close()
var suggestions []map[string]interface{}
for rows.Next() {
var accountID int64
var label, amountType string
var pct float64
if err := rows.Scan(&accountID, &label, &amountType, &pct); err != nil {
continue
}
writeoffAmount := 0.0
switch amountType {
case "percentage":
writeoffAmount = amount * pct / 100
case "fixed":
writeoffAmount = pct
case "percentage_st_line":
writeoffAmount = amount * pct / 100
}
suggestions = append(suggestions, map[string]interface{}{
"account_id": accountID,
"label": label,
"amount": writeoffAmount,
})
}
if len(suggestions) == 0 {
return map[string]interface{}{"matched": false}, nil
}
return map[string]interface{}{
"matched": true,
"rule_type": "writeoff_suggestion",
"suggestions": suggestions,
}, nil
}