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}")