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:
318
addons/account/models/account_followup.go
Normal file
318
addons/account/models/account_followup.go
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user