- 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>
205 lines
7.7 KiB
Python
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
|