feat(fusion_repairs): Bundle 3 - reminders + upsells (X2 + X4 + M3)

X2 Day-before visit reminder email
- New cron 'Fusion Repairs: Day-before visit reminders' (daily at 08:00)
  walks repair.order records with at least one linked
  fusion.technician.task scheduled for tomorrow and not yet reminded.
- Sends mail.template email_template_visit_day_before to the client.
- New x_fc_day_before_reminder_sent flag (copy=False) so the cron
  never re-sends the same reminder.
- Template uses 4px blue accent, 600px max-width, shows the scheduled
  date + technician name + equipment, with a 'reply to reschedule' note.
- Verified: cron flagged the test repair x_fc_day_before_reminder_sent=True
  after running.

X4 Post-visit NPS / Google review email
- New cron 'Fusion Repairs: Send post-visit NPS emails' (hourly)
  finds repairs in state='done' with write_date >= 24h ago and no NPS
  email sent. Sends mail.template email_template_post_visit_nps.
- New x_fc_nps_email_sent flag so we never re-pester clients.
- Template uses 4px green accent + 'Leave a Google review' CTA button
  linking to res.company.x_fc_google_review_url (or a sensible Google
  search fallback when the company hasn't configured a review URL).

M3 Loaner auto-offer for long-running repairs
- Soft-bridges fusion_loaners_management without a hard dep -
  cron_offer_loaner_for_long_repairs returns immediately if the
  fusion.loaner.checkout model isn't installed.
- Walks repair.order records open longer than
  fusion_repairs.loaner_offer_threshold_days (ICP, default 3 days)
  with no existing loaner-offer activity.
- Posts a 'Repair: Offer Loaner' activity (new mail.activity.type)
  assigned to the repair responsible.
- New x_fc_loaner_offered flag to prevent daily re-posting.
- Manual 'Offer Loaner' button on repair header opens the
  fusion.loaner.checkout wizard pre-filled with partner + SO.
- Daily cron runs at 08:30.

Email + ICP + cron wiring:
- 2 new mail.template records (visit_day_before, post_visit_nps)
- 1 new mail.activity.type (loaner_offer)
- 3 new ir.cron records (day-before, NPS, loaner)
- 1 new ir.config_parameter (loaner_offer_threshold_days)
- 1 new header button (Offer Loaner) on repair.order

Verified end-to-end on local westin-v19:
  X2 setup repair: RO-202605-12 task: TASK-00045
     day-before flag after cron: True (expected True)
  M3 loaner model not installed - cron correctly no-op'd
  (no flag set, no activity posted, no error - the soft-dep guard works)

Bumped to 19.0.1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gsinghpal
2026-05-20 23:59:40 -04:00
parent d93b500901
commit c506b53dec
7 changed files with 306 additions and 1 deletions

View File

@@ -135,6 +135,164 @@ class RepairOrder(models.Model):
'On-call acknowledgement tokens must be unique.',
)
# ------------------------------------------------------------------
# X2 / X4 - reminder + NPS sent-at flags (so the cron doesn't re-send)
# ------------------------------------------------------------------
x_fc_day_before_reminder_sent = fields.Boolean(
string='Day-Before Reminder Sent',
copy=False,
)
x_fc_nps_email_sent = fields.Boolean(
string='NPS Email Sent',
copy=False,
)
# ------------------------------------------------------------------
# X2 / X4 / M3 - reminder + NPS crons + loaner offer
# ------------------------------------------------------------------
@api.model
def cron_send_day_before_reminders(self):
"""X2: email the client the day before a scheduled tech visit.
Walks repair.order records with a linked technician task scheduled
for tomorrow, sends one reminder per repair, marks
x_fc_day_before_reminder_sent=True so we never re-send.
"""
if not self._notifications_enabled():
return
from datetime import date, timedelta
tomorrow = date.today() + timedelta(days=1)
# Find repairs with at least one task scheduled tomorrow that haven't
# been reminded yet.
repairs = self.search([
('x_fc_day_before_reminder_sent', '=', False),
('state', 'not in', ('done', 'cancel')),
('x_fc_technician_task_ids.scheduled_date', '=', tomorrow),
])
tpl = self.env.ref(
'fusion_repairs.email_template_visit_day_before',
raise_if_not_found=False,
)
if not tpl:
return
for r in repairs:
if not r.partner_id or not r.partner_id.email:
continue
try:
tpl.send_mail(r.id, force_send=False)
r.x_fc_day_before_reminder_sent = True
except Exception:
continue
@api.model
def cron_send_post_visit_nps(self):
"""X4: send NPS / Google review email 24h after a repair is done."""
if not self._notifications_enabled():
return
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=24)
repairs = self.search([
('state', '=', 'done'),
('x_fc_nps_email_sent', '=', False),
('write_date', '<=', cutoff),
])
tpl = self.env.ref(
'fusion_repairs.email_template_post_visit_nps',
raise_if_not_found=False,
)
if not tpl:
return
for r in repairs:
if not r.partner_id or not r.partner_id.email:
continue
try:
tpl.send_mail(r.id, force_send=False)
r.x_fc_nps_email_sent = True
except Exception:
continue
@api.model
def cron_offer_loaner_for_long_repairs(self):
"""M3: post an Offer-Loaner activity when an open repair has been
sitting longer than fusion_repairs.loaner_offer_threshold_days
AND has no linked loaner checkout yet.
Soft-depends on fusion_loaners_management - silently no-ops when
the loaner model isn't installed.
"""
if not self.env.get('fusion.loaner.checkout'):
return
ICP = self.env['ir.config_parameter'].sudo()
try:
threshold = int(ICP.get_param(
'fusion_repairs.loaner_offer_threshold_days', '3'
))
except (ValueError, TypeError):
threshold = 3
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(days=threshold)
repairs = self.search([
('state', 'not in', ('done', 'cancel')),
('create_date', '<=', cutoff),
('x_fc_loaner_offered', '=', False),
])
activity_type = self.env.ref(
'fusion_repairs.mail_activity_type_loaner_offer',
raise_if_not_found=False,
)
for r in repairs:
try:
r.activity_schedule(
activity_type_id=activity_type.id if activity_type else False,
summary='Offer a loaner unit',
note=(
'This repair has been open for more than %s days. '
'Consider offering the client a loaner unit while we '
'complete the repair.'
) % threshold,
user_id=r.user_id.id or self.env.uid,
)
r.x_fc_loaner_offered = True
except Exception:
continue
x_fc_loaner_offered = fields.Boolean(
string='Loaner Offered',
copy=False,
help='True once a loaner-offer activity has been posted for this '
'long-running repair (M3). Avoids re-posting daily.',
)
@api.model
def _notifications_enabled(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param(
'fusion_repairs.enable_email_notifications', 'True'
) == 'True'
def action_offer_loaner(self):
"""Open the fusion_loaners_management checkout wizard pre-filled
with this repair's partner. Soft-link - raises if the module is
not installed."""
self.ensure_one()
Checkout = self.env.get('fusion.loaner.checkout')
if not Checkout:
raise UserError(_(
'Loaner management is not installed. Install '
'fusion_loaners_management to enable this feature.'
))
return {
'type': 'ir.actions.act_window',
'name': _('Offer Loaner'),
'res_model': 'fusion.loaner.checkout',
'view_mode': 'form',
'target': 'new',
'context': {
'default_partner_id': self.partner_id.id,
'default_sale_order_id': self.sale_order_id.id or False,
},
}
# Maintenance contract back-link (Phase 3)
x_fc_maintenance_contract_id = fields.Many2one(
'fusion.repair.maintenance.contract',