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:
@@ -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': """
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user