diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py
index 31441cc5..ee494cbb 100644
--- a/fusion_claims/__manifest__.py
+++ b/fusion_claims/__manifest__.py
@@ -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': """
diff --git a/fusion_claims/data/ir_config_parameter_data.xml b/fusion_claims/data/ir_config_parameter_data.xml
index d46c59b4..54739279 100644
--- a/fusion_claims/data/ir_config_parameter_data.xml
+++ b/fusion_claims/data/ir_config_parameter_data.xml
@@ -116,6 +116,22 @@
fusion_claims.mod_followup_escalation_days
3
+
+
+ fusion_claims.mod_followup_max_per_month
+ 2
+
+
+ fusion_claims.mod_followup_window_days
+ 30
+
+
+
+ fusion_claims.mod_followup_max_per_cron_run
+ 10
+
diff --git a/fusion_claims/models/sale_order.py b/fusion_claims/models/sale_order.py
index 21113a86..74ddd68d 100644
--- a/fusion_claims/models/sale_order.py
+++ b/fusion_claims/models/sale_order.py
@@ -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}")