fusion_claims: cap MOD follow-up email flood with rolling 30-day window

Two daily MOD crons were fighting each other. _cron_mod_schedule_followups
created a mail.activity on every MOD order in quote_submitted/awaiting_funding;
_cron_mod_escalate_followups unconditionally deleted the activity after
sending its one-time reminder email. The activity was recreated every day
in a tight loop with no per-period cap — a legitimate 2-4 month wait for
a MOD funding decision would generate dozens of activity churn events and
a bulk email burst the first time the escalate cron ran against a backlog.

Fix:
- New fields x_fc_mod_followup_month_count / _month_start / _cap_notified
  (copy=False) track a rolling window per order.
- New config params mod_followup_max_per_month (default 2),
  mod_followup_window_days (30), mod_followup_max_per_cron_run (10).
- _send_mod_followup_email resets the window after 30 days, refuses to
  send past the cap, and posts a one-shot chatter note explaining why.
- _cron_mod_schedule_followups no longer recreates the activity when the
  cap has been hit and stops daily-bumping x_fc_mod_next_followup_date.
- _cron_mod_escalate_followups processes oldest-deadline-first with a
  per-run throttle, only unlinks the activity on a successful send so
  humans can still action capped cases manually.
- write() resets the rolling counters on any real MOD status change.

Deployed to fusion_claims v19.0.8.0.1 on odoo-westin (westin-v19,
36 affected orders) and odoo-mobility (mobility, 2 affected orders).
This commit is contained in:
gsinghpal
2026-04-08 00:01:19 -04:00
parent c30a61c93f
commit d60a75a391
3 changed files with 219 additions and 40 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.8.0.0',
'version': '19.0.8.0.1',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """

View File

@@ -116,6 +116,22 @@
<field name="key">fusion_claims.mod_followup_escalation_days</field>
<field name="value">3</field>
</record>
<!-- Hard cap on auto follow-up emails per order per 30-day window. -->
<record id="config_mod_followup_max_per_month" model="ir.config_parameter">
<field name="key">fusion_claims.mod_followup_max_per_month</field>
<field name="value">2</field>
</record>
<record id="config_mod_followup_window_days" model="ir.config_parameter">
<field name="key">fusion_claims.mod_followup_window_days</field>
<field name="value">30</field>
</record>
<!-- Per-cron-run throttle so a backlog of stale MOD cases cannot
blast 30+ emails in one minute. Remaining orders roll over to
the next day's cron run. -->
<record id="config_mod_followup_max_per_cron_run" model="ir.config_parameter">
<field name="key">fusion_claims.mod_followup_max_per_cron_run</field>
<field name="value">10</field>
</record>
<!-- ODSP Settings -->
<record id="config_sa_mobility_email" model="ir.config_parameter">

View File

@@ -723,8 +723,31 @@ class SaleOrder(models.Model):
x_fc_mod_followup_escalated = fields.Boolean(
string='Follow-up Escalated',
default=False,
copy=False,
help='True if an automatic follow-up email was sent because activity was not completed',
)
# Rolling 30-day window cap — prevents the MOD follow-up cron from sending
# unlimited reminders on cases that legitimately sit for 2-4 months waiting
# for a MOD funding decision. See fusion_claims.mod_followup_max_per_month.
x_fc_mod_followup_month_count = fields.Integer(
string='Follow-ups Sent (This Period)',
default=0,
copy=False,
help='Number of auto follow-up emails sent in the current 30-day rolling window. '
'Resets automatically when the window expires or the MOD status changes.',
)
x_fc_mod_followup_month_start = fields.Date(
string='Follow-up Period Start',
copy=False,
help='Date when the current 30-day follow-up window started.',
)
x_fc_mod_followup_cap_notified = fields.Boolean(
string='Follow-up Cap Notified',
default=False,
copy=False,
help='True if a chatter note has already been posted for the current '
'period explaining that the monthly follow-up cap was reached.',
)
# --- MOD Audit Trail dates ---
x_fc_mod_assessment_scheduled_date = fields.Date(string='Assessment Scheduled', tracking=True)
@@ -6358,6 +6381,21 @@ class SaleOrder(models.Model):
# ==================================================================
# MARCH OF DIMES STATUS-TRIGGERED EMAILS & SMS
# ==================================================================
if new_mod_status:
# Reset rolling follow-up counters on ANY real MOD status change —
# this is the natural "new chapter" moment. Applies even when
# skip_status_emails is set so imports/migrations also reset.
for order in self:
if not order._is_mod_sale():
continue
if order.x_fc_mod_status != new_mod_status:
order.with_context(skip_all_validations=True).write({
'x_fc_mod_followup_month_count': 0,
'x_fc_mod_followup_month_start': False,
'x_fc_mod_followup_escalated': False,
'x_fc_mod_followup_cap_notified': False,
})
if new_mod_status and not self.env.context.get('skip_status_emails'):
for order in self:
if not order._is_mod_sale():
@@ -7540,8 +7578,47 @@ class SaleOrder(models.Model):
'If you experience any issues, please contact us immediately.',
)
def _mod_followup_cap_state(self):
"""Return (within_cap, reset_needed, new_start, max_per_month) tuple.
Evaluates the rolling 30-day window for MOD follow-up emails so that
cases awaiting funding for months do not get spammed. See the
`fusion_claims.mod_followup_max_per_month` / `...window_days` config
parameters. Pure read — does not mutate the record.
"""
from datetime import timedelta
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
try:
max_per_month = int(ICP.get_param('fusion_claims.mod_followup_max_per_month', '2'))
except (TypeError, ValueError):
max_per_month = 2
try:
window_days = int(ICP.get_param('fusion_claims.mod_followup_window_days', '30'))
except (TypeError, ValueError):
window_days = 30
today = fields.Date.today()
start = self.x_fc_mod_followup_month_start
count = self.x_fc_mod_followup_month_count or 0
reset_needed = False
new_start = start
if not start or (today - start).days >= window_days:
reset_needed = True
new_start = today
count = 0
within_cap = count < max_per_month
return within_cap, reset_needed, new_start, max_per_month
def _send_mod_followup_email(self):
"""Auto-email to client when follow-up activity is not completed on time."""
"""Auto-email to client when follow-up activity is not completed on time.
Enforces a rolling 30-day cap (default 2/month) via
`fusion_claims.mod_followup_max_per_month` so that long-running cases
awaiting MOD funding do not receive endless reminders.
"""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
@@ -7550,6 +7627,45 @@ class SaleOrder(models.Model):
if not client or not client.email:
return False
# Cap check BEFORE any mail.mail is created.
within_cap, reset_needed, new_start, max_per_month = self._mod_followup_cap_state()
if reset_needed:
# New window — clear the one-shot cap-notified flag so the next
# cap-hit posts a fresh chatter note.
self.with_context(skip_all_validations=True).write({
'x_fc_mod_followup_month_start': new_start,
'x_fc_mod_followup_month_count': 0,
'x_fc_mod_followup_cap_notified': False,
})
if not within_cap:
# Monthly cap already reached — post ONE chatter note per window
# so office staff know why no reminder fired, then bail out.
if not self.x_fc_mod_followup_cap_notified:
try:
self.message_post(
body=_(
'MOD Follow-up paused: the maximum of %(n)s auto-reminders '
'per 30-day period has been reached for this case. '
'Automatic follow-ups will resume after the next MOD '
'status change or once the current 30-day window ends.'
) % {'n': max_per_month},
message_type='notification',
subtype_xmlid='mail.mt_note',
)
self.with_context(skip_all_validations=True).write({
'x_fc_mod_followup_cap_notified': True,
})
except Exception as e:
_logger.error(
f"Failed to post MOD follow-up cap note for {self.name}: {e}"
)
_logger.info(
f"MOD follow-up capped for {self.name} "
f"(count={self.x_fc_mod_followup_month_count}/{max_per_month})"
)
return False
client_name = client.name or 'Client'
sender_name = (self.user_id or self.env.user).name
followup_count = self.x_fc_mod_followup_count or 0
@@ -7577,6 +7693,7 @@ class SaleOrder(models.Model):
self.with_context(skip_all_validations=True).write({
'x_fc_mod_last_followup_date': fields.Date.today(),
'x_fc_mod_followup_count': followup_count + 1,
'x_fc_mod_followup_month_count': (self.x_fc_mod_followup_month_count or 0) + 1,
'x_fc_mod_followup_escalated': True,
})
self._email_chatter_log('MOD Follow-up auto-email sent (activity overdue)', client.email)
@@ -7668,7 +7785,14 @@ class SaleOrder(models.Model):
@api.model
def _cron_mod_schedule_followups(self):
"""Cron: Schedule bi-weekly follow-up activities for MOD cases awaiting funding."""
"""Cron: Schedule bi-weekly follow-up activities for MOD cases awaiting funding.
Only creates a new mail.activity when (a) no open follow-up activity
already exists for the order AND (b) the rolling 30-day follow-up cap
has not been reached. This prevents the old tight loop where the
schedule cron and the escalate cron endlessly recreated and deleted
the same activity day after day.
"""
from datetime import timedelta
ICP = self.env['ir.config_parameter'].sudo()
@@ -7683,78 +7807,117 @@ class SaleOrder(models.Model):
])
today = fields.Date.today()
activity_type = self.env.ref(
'fusion_claims.mail_activity_type_mod_followup', raise_if_not_found=False)
if not activity_type:
return
for order in orders:
try:
# Check if there's already an open activity of this type first —
# if so, do nothing. Not even a date bump (the old code bumped
# x_fc_mod_next_followup_date daily which had no effect but
# generated tracking noise).
existing = self.env['mail.activity'].search([
('res_model', '=', 'sale.order'),
('res_id', '=', order.id),
('activity_type_id', '=', activity_type.id),
], limit=1)
if existing:
continue
# No open activity. Respect the rolling cap before creating a
# new one — if the cap is hit, skip entirely and let the case
# sit quietly until the window resets or MOD status advances.
within_cap, _reset, _new_start, _max = order._mod_followup_cap_state()
if not within_cap:
_logger.info(
f"MOD follow-up skipped for {order.name}: monthly cap reached"
)
continue
next_date = order.x_fc_mod_next_followup_date
# If no next followup date set, or it's in the past, schedule one
if not next_date or next_date <= today:
# Calculate from last followup or quote submission date
base_date = order.x_fc_mod_last_followup_date or order.x_fc_case_submitted or today
new_followup = base_date + timedelta(days=interval_days)
if new_followup <= today:
new_followup = today + timedelta(days=1) # Schedule for tomorrow at minimum
if next_date and next_date > today:
# Existing schedule is still in the future; nothing to do.
continue
# Create scheduled activity
activity_type = self.env.ref(
'fusion_claims.mail_activity_type_mod_followup', raise_if_not_found=False)
if activity_type:
# Check if there's already an open activity of this type
existing = self.env['mail.activity'].search([
('res_model', '=', 'sale.order'),
('res_id', '=', order.id),
('activity_type_id', '=', activity_type.id),
], limit=1)
if not existing:
order.activity_schedule(
'fusion_claims.mail_activity_type_mod_followup',
date_deadline=new_followup,
user_id=(order.user_id or self.env.user).id,
summary=f'MOD Follow-up: Call {order.partner_id.name or "client"} for funding update',
)
base_date = order.x_fc_mod_last_followup_date or order.x_fc_case_submitted or today
new_followup = base_date + timedelta(days=interval_days)
if new_followup <= today:
new_followup = today + timedelta(days=1)
order.with_context(skip_all_validations=True).write({
'x_fc_mod_next_followup_date': new_followup,
})
_logger.info(f"Scheduled MOD follow-up for {order.name} on {new_followup}")
order.activity_schedule(
'fusion_claims.mail_activity_type_mod_followup',
date_deadline=new_followup,
user_id=(order.user_id or self.env.user).id,
summary=f'MOD Follow-up: Call {order.partner_id.name or "client"} for funding update',
)
order.with_context(skip_all_validations=True).write({
'x_fc_mod_next_followup_date': new_followup,
})
_logger.info(f"Scheduled MOD follow-up for {order.name} on {new_followup}")
except Exception as e:
_logger.error(f"Failed to schedule MOD follow-up for {order.name}: {e}")
@api.model
def _cron_mod_escalate_followups(self):
"""Cron: Send auto-email if follow-up activity is overdue (not completed within 3 days)."""
"""Cron: Send auto-email if follow-up activity is overdue.
The activity is only unlinked when (a) the MOD status has already
moved past the follow-up phase (cleanup), or (b) an email was actually
sent. When the rolling cap blocks the email we leave the activity in
place so the assignee still sees it on their dashboard — they can mark
it done manually when the external update arrives.
"""
from datetime import timedelta
ICP = self.env['ir.config_parameter'].sudo()
escalation_days = int(ICP.get_param('fusion_claims.mod_followup_escalation_days', '3'))
try:
max_per_run = int(ICP.get_param('fusion_claims.mod_followup_max_per_cron_run', '10'))
except (TypeError, ValueError):
max_per_run = 10
activity_type = self.env.ref(
'fusion_claims.mail_activity_type_mod_followup', raise_if_not_found=False)
if not activity_type:
return
# Find overdue follow-up activities
# Find overdue follow-up activities — process the oldest deadlines
# first so nothing starves when the per-run throttle kicks in.
cutoff_date = fields.Date.today() - timedelta(days=escalation_days)
overdue_activities = self.env['mail.activity'].search([
('res_model', '=', 'sale.order'),
('activity_type_id', '=', activity_type.id),
('date_deadline', '<=', cutoff_date),
])
], order='date_deadline asc, id asc')
sent_count = 0
for activity in overdue_activities:
if max_per_run and sent_count >= max_per_run:
_logger.info(
f"MOD follow-up cron reached per-run cap "
f"({max_per_run}); remaining orders will roll over to next run"
)
break
try:
order = self.browse(activity.res_id)
if not order.exists() or not order._is_mod_sale():
continue
if order.x_fc_mod_status not in ('quote_submitted', 'awaiting_funding'):
# Status moved past follow-up phase, clean up the activity
# Status moved past follow-up phase cleanup stale activity.
activity.unlink()
continue
# Only escalate once per activity
if not order.x_fc_mod_followup_escalated:
order._send_mod_followup_email()
# Clean up the overdue activity and let the scheduler create a new one
activity.unlink()
# Ask _send_mod_followup_email to decide: it enforces the
# rolling cap internally and returns True only on an actual
# send. If it returns False (cap reached or no email
# configured), leave the activity alone so a human can action
# it when the real update lands.
sent = order._send_mod_followup_email()
if sent:
activity.unlink()
sent_count += 1
except Exception as e:
_logger.error(f"Failed to escalate MOD follow-up for activity {activity.id}: {e}")