- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
320 lines
11 KiB
Go
320 lines
11 KiB
Go
package models
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"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>", html.EscapeString(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
|
|
})
|
|
}
|