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,318 @@
package models
import (
"fmt"
"strings"
"odoo-go/pkg/orm"
)
// initAccountFollowup registers payment follow-up models.
// Mirrors: odoo/addons/account_followup/models/account_followup.py
//
// Follow-up levels define escalation steps for overdue payments:
// Level 1: Friendly reminder after X days
// Level 2: Formal notice after X days
// Level 3: Final warning / legal action after X days
func initAccountFollowup() {
// -- Follow-up Level --
fl := orm.NewModel("account.followup.line", orm.ModelOpts{
Description: "Follow-up Criteria",
Order: "delay",
})
fl.AddFields(
orm.Char("name", orm.FieldOpts{String: "Follow-Up Action", Required: true, Translate: true}),
orm.Integer("delay", orm.FieldOpts{
String: "Due Days",
Required: true,
Help: "Number of days after the due date of the invoice to trigger this action",
}),
orm.Text("description", orm.FieldOpts{
String: "Printed Message",
Translate: true,
Help: "Message printed on the follow-up report",
}),
orm.Text("email_body", orm.FieldOpts{
String: "Email Body",
Translate: true,
Help: "Email body for the follow-up email",
}),
orm.Char("email_subject", orm.FieldOpts{
String: "Email Subject",
Translate: true,
}),
orm.Boolean("send_email", orm.FieldOpts{
String: "Send Email",
Default: true,
}),
orm.Boolean("send_letter", orm.FieldOpts{
String: "Send Letter",
Default: false,
}),
orm.Boolean("manual_action", orm.FieldOpts{
String: "Manual Action",
Help: "Assign a manual action to be done",
}),
orm.Text("manual_action_note", orm.FieldOpts{
String: "Action To Do",
Help: "Description of the manual action to be taken",
}),
orm.Many2one("manual_action_responsible_id", "res.users", orm.FieldOpts{
String: "Assign a Responsible",
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true,
}),
orm.Selection("sms_template", []orm.SelectionItem{
{Value: "none", Label: "None"},
{Value: "default", Label: "Default"},
}, orm.FieldOpts{String: "SMS Template", Default: "none"}),
orm.Boolean("join_invoices", orm.FieldOpts{
String: "Attach Invoices",
Default: false,
Help: "Attach open invoice PDFs to the follow-up email",
}),
orm.Selection("auto_execute", []orm.SelectionItem{
{Value: "auto", Label: "Automatic"},
{Value: "manual", Label: "Manual"},
}, orm.FieldOpts{String: "Execution", Default: "manual"}),
)
// -- Follow-up Report (partner-level) --
// Extends res.partner with follow-up state tracking.
initFollowupPartnerExtension()
// -- Follow-up processing method on partner --
initFollowupProcess()
}
// initFollowupPartnerExtension adds follow-up tracking fields to res.partner.
func initFollowupPartnerExtension() {
ext := orm.ExtendModel("res.partner")
ext.AddFields(
orm.Selection("followup_status", []orm.SelectionItem{
{Value: "no_action_needed", Label: "No Action Needed"},
{Value: "with_overdue_invoices", Label: "With Overdue Invoices"},
{Value: "in_need_of_action", Label: "In Need of Action"},
}, orm.FieldOpts{String: "Follow-up Status", Compute: "_compute_followup_status"}),
orm.Many2one("followup_level_id", "account.followup.line", orm.FieldOpts{
String: "Follow-up Level",
}),
orm.Date("followup_next_action_date", orm.FieldOpts{
String: "Next Follow-up Date",
}),
orm.Text("followup_reminder", orm.FieldOpts{
String: "Customer Follow-up Note",
}),
orm.Many2one("followup_responsible_id", "res.users", orm.FieldOpts{
String: "Follow-up Responsible",
}),
orm.Integer("total_overdue_invoices", orm.FieldOpts{
String: "Total Overdue Invoices", Compute: "_compute_total_overdue",
}),
orm.Monetary("total_overdue_amount", orm.FieldOpts{
String: "Total Overdue Amount", Compute: "_compute_total_overdue",
}),
)
// _compute_followup_status
ext.RegisterCompute("followup_status", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
partnerID := rs.IDs()[0]
var overdueCount int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_move m
WHERE m.partner_id = $1 AND m.state = 'posted'
AND m.move_type IN ('out_invoice', 'out_receipt')
AND m.payment_state NOT IN ('paid', 'in_payment', 'reversed')
AND m.invoice_date_due < CURRENT_DATE`, partnerID,
).Scan(&overdueCount)
status := "no_action_needed"
if overdueCount > 0 {
// Check if there's a follow-up level set
var levelID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT followup_level_id FROM res_partner WHERE id = $1`, partnerID,
).Scan(&levelID)
if levelID != nil && *levelID > 0 {
status = "in_need_of_action"
} else {
status = "with_overdue_invoices"
}
}
return orm.Values{"followup_status": status}, nil
})
// _compute_total_overdue: computes both count and amount of overdue invoices
computeOverdue := func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
partnerID := rs.IDs()[0]
var count int
var total float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*), COALESCE(SUM(amount_residual::float8), 0)
FROM account_move
WHERE partner_id = $1 AND state = 'posted'
AND move_type IN ('out_invoice', 'out_receipt')
AND payment_state NOT IN ('paid', 'in_payment', 'reversed')
AND invoice_date_due < CURRENT_DATE`, partnerID,
).Scan(&count, &total)
return orm.Values{
"total_overdue_invoices": count,
"total_overdue_amount": total,
}, nil
}
ext.RegisterCompute("total_overdue_invoices", computeOverdue)
ext.RegisterCompute("total_overdue_amount", computeOverdue)
}
// initFollowupProcess registers the follow-up processing methods.
func initFollowupProcess() {
ext := orm.ExtendModel("res.partner")
// action_execute_followup: run follow-up for the partner.
// Mirrors: odoo/addons/account_followup/models/res_partner.py execute_followup()
ext.RegisterMethod("action_execute_followup", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, partnerID := range rs.IDs() {
// Find overdue invoices for this partner
rows, err := env.Tx().Query(env.Ctx(),
`SELECT m.id, m.name, m.invoice_date_due, m.amount_residual::float8,
CURRENT_DATE - m.invoice_date_due as overdue_days
FROM account_move m
WHERE m.partner_id = $1 AND m.state = 'posted'
AND m.move_type IN ('out_invoice', 'out_receipt')
AND m.payment_state NOT IN ('paid', 'in_payment', 'reversed')
AND m.invoice_date_due < CURRENT_DATE
ORDER BY m.invoice_date_due`, partnerID)
if err != nil {
return nil, fmt.Errorf("account: query overdue invoices for partner %d: %w", partnerID, err)
}
var maxOverdueDays int
var overdueInvoiceIDs []int64
for rows.Next() {
var invID int64
var invName string
var dueDate interface{}
var residual float64
var overdueDays int
if err := rows.Scan(&invID, &invName, &dueDate, &residual, &overdueDays); err != nil {
continue
}
overdueInvoiceIDs = append(overdueInvoiceIDs, invID)
if overdueDays > maxOverdueDays {
maxOverdueDays = overdueDays
}
}
rows.Close()
if len(overdueInvoiceIDs) == 0 {
continue
}
// Find the appropriate follow-up level based on overdue days
var companyID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(company_id, 1) FROM res_partner WHERE id = $1`, partnerID,
).Scan(&companyID)
var levelID *int64
var levelName *string
env.Tx().QueryRow(env.Ctx(),
`SELECT id, name FROM account_followup_line
WHERE company_id = $1 AND delay <= $2
ORDER BY delay DESC LIMIT 1`, companyID, maxOverdueDays,
).Scan(&levelID, &levelName)
if levelID != nil {
env.Tx().Exec(env.Ctx(),
`UPDATE res_partner SET followup_level_id = $1,
followup_next_action_date = CURRENT_DATE + INTERVAL '14 days'
WHERE id = $2`, *levelID, partnerID)
}
}
return true, nil
})
// get_followup_html: generate HTML report of overdue invoices for a partner.
// Mirrors: odoo/addons/account_followup/models/res_partner.py get_followup_html()
ext.RegisterMethod("get_followup_html", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
partnerID := rs.IDs()[0]
var partnerName string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, 'Unknown') FROM res_partner WHERE id = $1`, partnerID,
).Scan(&partnerName)
rows, err := env.Tx().Query(env.Ctx(),
`SELECT m.name, m.invoice_date_due, m.amount_residual::float8,
m.amount_total::float8, CURRENT_DATE - m.invoice_date_due as overdue_days
FROM account_move m
WHERE m.partner_id = $1 AND m.state = 'posted'
AND m.move_type IN ('out_invoice', 'out_receipt')
AND m.payment_state NOT IN ('paid', 'in_payment', 'reversed')
ORDER BY m.invoice_date_due`, partnerID)
if err != nil {
return "", fmt.Errorf("account: query invoices for followup: %w", err)
}
defer rows.Close()
var b strings.Builder
b.WriteString(`<style>
body{font-family:Arial;margin:20px}
table{width:100%;border-collapse:collapse;margin-top:12px}
th,td{border:1px solid #ddd;padding:6px 8px;text-align:right}
th{background:#875a7b;color:white}
td:first-child,th:first-child{text-align:left}
.overdue{color:#d9534f;font-weight:bold}
h2{color:#875a7b}
</style>`)
b.WriteString(fmt.Sprintf("<h2>Payment Follow-up: %s</h2>", partnerName))
b.WriteString(`<table><tr><th>Invoice</th><th>Due Date</th><th>Total</th><th>Amount Due</th><th>Overdue Days</th></tr>`)
var totalDue float64
for rows.Next() {
var invName string
var dueDate interface{}
var residual, total float64
var overdueDays int
if err := rows.Scan(&invName, &dueDate, &residual, &total, &overdueDays); err != nil {
continue
}
totalDue += residual
overdueClass := ""
if overdueDays > 30 {
overdueClass = ` class="overdue"`
}
b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%v</td><td>%.2f</td><td>%.2f</td><td%s>%d</td></tr>`,
invName, dueDate, total, residual, overdueClass, overdueDays))
}
b.WriteString(fmt.Sprintf(`<tr style="font-weight:bold;background:#f5f5f5"><td colspan="3">Total Due</td><td>%.2f</td><td></td></tr>`, totalDue))
b.WriteString("</table>")
// Add follow-up message if a level is set
var description *string
env.Tx().QueryRow(env.Ctx(),
`SELECT fl.description FROM res_partner p
JOIN account_followup_line fl ON fl.id = p.followup_level_id
WHERE p.id = $1`, partnerID,
).Scan(&description)
if description != nil && *description != "" {
b.WriteString(fmt.Sprintf(`<div style="margin-top:20px;padding:10px;background:#fff3cd;border:1px solid #ffc107;border-radius:4px">%s</div>`, *description))
}
return b.String(), nil
})
}