Files
Odoo-Modules/Fusion Accounting/models/res_partner_followup.py
gsinghpal 84c009416e feat: add fusion_odoo_fixes module for default Odoo patches
- New standalone module to collect fixes for default Odoo behavior
- Fix #1: account_followup never clears followup_next_action_date
  when invoices are paid, causing collection emails to fully-paid
  clients. Hooks into _invoice_paid_hook to auto-clear stale data.
- Harden Fusion Accounting followup queries with amount_residual > 0
  filter and add balance check before sending emails

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 03:31:14 -05:00

205 lines
7.7 KiB
Python

# Part of Fusion Accounting. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class FusionPartnerFollowup(models.Model):
"""Extends the partner model with payment follow-up capabilities.
Adds fields and methods for tracking overdue invoices, determining
the appropriate follow-up level, and launching the follow-up
workflow directly from the partner form.
"""
_inherit = 'res.partner'
# ---- Follow-up Fields ----
followup_level_id = fields.Many2one(
comodel_name='fusion.followup.level',
string="Follow-up Level",
company_dependent=True,
tracking=True,
help="Current follow-up escalation level for this partner.",
)
followup_next_action_date = fields.Date(
string="Next Follow-up Date",
company_dependent=True,
tracking=True,
help="Date on which the next follow-up action should be performed.",
)
followup_responsible_id = fields.Many2one(
comodel_name='res.users',
string="Follow-up Responsible",
company_dependent=True,
tracking=True,
help="User responsible for managing payment collection for this partner.",
)
# ---- Computed Indicators ----
fusion_overdue_amount = fields.Monetary(
string="Total Overdue Amount",
compute='_compute_fusion_overdue_amount',
currency_field='currency_id',
help="Total amount of overdue receivables for the current company.",
)
fusion_overdue_count = fields.Integer(
string="Overdue Invoice Count",
compute='_compute_fusion_overdue_amount',
help="Number of overdue invoices for the current company.",
)
# --------------------------------------------------
# Computed Fields
# --------------------------------------------------
@api.depends('credit')
def _compute_fusion_overdue_amount(self):
"""Compute the total overdue receivable amount and invoice count.
Uses the receivable move lines that are posted, unreconciled,
and past their maturity date.
"""
today = fields.Date.context_today(self)
for partner in self:
overdue_data = partner.get_overdue_invoices()
partner.fusion_overdue_amount = sum(
line.amount_residual for line in overdue_data
)
partner.fusion_overdue_count = len(overdue_data.mapped('move_id'))
# --------------------------------------------------
# Public Methods
# --------------------------------------------------
def get_overdue_invoices(self):
"""Return unpaid receivable move lines that are past due.
Searches for posted, unreconciled journal items on receivable
accounts where the maturity date is earlier than today and
there is still an outstanding balance.
:returns: An ``account.move.line`` recordset.
"""
self.ensure_one()
today = fields.Date.context_today(self)
return self.env['account.move.line'].search([
('partner_id', '=', self.commercial_partner_id.id),
('company_id', '=', self.env.company.id),
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('amount_residual', '>', 0),
('date_maturity', '<', today),
])
def get_overdue_amount(self):
"""Return the total overdue receivable amount for this partner.
:returns: A float representing the overdue monetary amount.
"""
self.ensure_one()
overdue_lines = self.get_overdue_invoices()
return sum(overdue_lines.mapped('amount_residual'))
def action_open_followup(self):
"""Open the follow-up form view for this partner.
Locates or creates the ``fusion.followup.line`` record for the
current partner and company, then opens the form view for it.
:returns: An ``ir.actions.act_window`` dictionary.
"""
self.ensure_one()
followup_line = self.env['fusion.followup.line'].search([
('partner_id', '=', self.id),
('company_id', '=', self.env.company.id),
], limit=1)
if not followup_line:
# Assign first level automatically
first_level = self.env['fusion.followup.level'].search([
('company_id', '=', self.env.company.id),
], order='sequence, id', limit=1)
followup_line = self.env['fusion.followup.line'].create({
'partner_id': self.id,
'company_id': self.env.company.id,
'followup_level_id': first_level.id if first_level else False,
})
return {
'type': 'ir.actions.act_window',
'name': _("Payment Follow-up: %s", self.display_name),
'res_model': 'fusion.followup.line',
'res_id': followup_line.id,
'view_mode': 'form',
'target': 'current',
}
# --------------------------------------------------
# Scheduled Actions
# --------------------------------------------------
@api.model
def compute_partners_needing_followup(self):
"""Scheduled action: find partners with overdue invoices and create
or update their follow-up tracking records.
This method is called daily by ``ir.cron``. It scans for partners
that have at least one overdue receivable and ensures each one has
a ``fusion.followup.line`` record. Existing records are refreshed
so their computed fields stay current.
:returns: ``True``
"""
today = fields.Date.context_today(self)
companies = self.env['res.company'].search([])
for company in companies:
# Find partners with overdue receivables in this company
overdue_lines = self.env['account.move.line'].search([
('company_id', '=', company.id),
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('date_maturity', '<', today),
('amount_residual', '>', 0),
])
partner_ids = overdue_lines.mapped(
'partner_id.commercial_partner_id'
).ids
if not partner_ids:
continue
# Fetch existing tracking records for these partners
existing_lines = self.env['fusion.followup.line'].search([
('partner_id', 'in', partner_ids),
('company_id', '=', company.id),
])
existing_partner_ids = existing_lines.mapped('partner_id').ids
# Determine the first follow-up level for new records
first_level = self.env['fusion.followup.level'].search([
('company_id', '=', company.id),
], order='sequence, id', limit=1)
# Create tracking records for partners that don't have one yet
new_partner_ids = set(partner_ids) - set(existing_partner_ids)
if new_partner_ids:
self.env['fusion.followup.line'].create([{
'partner_id': pid,
'company_id': company.id,
'followup_level_id': first_level.id if first_level else False,
} for pid in new_partner_ids])
# Refresh computed fields on all relevant records
all_lines = self.env['fusion.followup.line'].search([
('partner_id', 'in', partner_ids),
('company_id', '=', company.id),
])
all_lines.compute_followup_status()
return True