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>
299 lines
11 KiB
Go
299 lines
11 KiB
Go
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
|
|
}
|