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

@@ -4,7 +4,7 @@
{
'name': 'Fusion Repairs',
'version': '19.0.1.2.2',
'version': '19.0.1.3.0',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """

View File

@@ -56,5 +56,11 @@
<field name="key">fusion_repairs.client_portal_rate_limit_per_hour</field>
<field name="value">10</field>
</record>
<!-- M3: post a loaner-offer activity if a repair has been open this long. -->
<record id="param_loaner_offer_threshold_days" model="ir.config_parameter">
<field name="key">fusion_repairs.loaner_offer_threshold_days</field>
<field name="value">3</field>
</record>
</data>
</odoo>

View File

@@ -30,5 +30,44 @@
<field name="active" eval="True"/>
</record>
<!-- X2: Day-before visit reminders. Runs every morning at 08:00. -->
<record id="cron_day_before_reminders" model="ir.cron">
<field name="name">Fusion Repairs: Day-before visit reminders</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="state">code</field>
<field name="code">model.cron_send_day_before_reminders()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="nextcall" eval="(DateTime.now().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="active" eval="True"/>
</record>
<!-- X4: Post-visit NPS. Runs hourly to send 24h after state=done. -->
<record id="cron_post_visit_nps" model="ir.cron">
<field name="name">Fusion Repairs: Send post-visit NPS emails</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="state">code</field>
<field name="code">model.cron_send_post_visit_nps()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="nextcall" eval="(DateTime.now() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="active" eval="True"/>
</record>
<!-- M3: Offer loaner activity for long-running repairs. Runs daily. -->
<record id="cron_offer_loaner" model="ir.cron">
<field name="name">Fusion Repairs: Offer loaner for long-running repairs</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="state">code</field>
<field name="code">model.cron_offer_loaner_for_long_repairs()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="nextcall" eval="(DateTime.now().replace(hour=8, minute=30, second=0, microsecond=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -50,5 +50,17 @@
<field name="sequence">40</field>
</record>
<!-- M3: Loaner offer for long-running repairs. -->
<record id="mail_activity_type_loaner_offer" model="mail.activity.type">
<field name="name">Repair: Offer Loaner</field>
<field name="summary">Offer the client a loaner unit while repair is in progress</field>
<field name="delay_count">1</field>
<field name="delay_unit">days</field>
<field name="delay_from">previous_activity</field>
<field name="res_model">repair.order</field>
<field name="icon">fa-handshake-o</field>
<field name="sequence">50</field>
</record>
</data>
</odoo>

View File

@@ -55,6 +55,89 @@
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- X2: Day-before visit reminder -->
<!-- ============================================================== -->
<record id="email_template_visit_day_before" model="mail.template">
<field name="name">Repair: Day-Before Visit Reminder</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">Reminder: technician visit tomorrow for {{ object.name }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="padding:32px 28px;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Reminder: our technician visits tomorrow</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Hello <t t-out="object.partner_id.name or 'there'"/>, this is a friendly
reminder that your service call <strong><t t-out="object.name"/></strong>
is scheduled for tomorrow.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<t t-foreach="object.x_fc_technician_task_ids[:1]" t-as="task">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Scheduled</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="task.scheduled_date" t-options="{'widget': 'date'}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Technician</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="task.technician_id.name or 'TBC'"/></td></tr>
</t>
<t t-if="object.product_id">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Equipment</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.product_id.display_name"/></td></tr>
</t>
</table>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">
<strong>Need to reschedule?</strong> Reply to this email or call our office.
Please make sure the equipment is accessible and any pets are secured.
</p>
</div>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- X4: Post-visit NPS / Google review -->
<!-- ============================================================== -->
<record id="email_template_post_visit_nps" model="mail.template">
<field name="name">Repair: Post-Visit NPS</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">How did we do, {{ object.partner_id.name or 'there' }}?</field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#38a169;"></div>
<div style="padding:32px 28px;">
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Thanks for trusting us with your equipment</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your service call <strong><t t-out="object.name"/></strong> is complete.
We would love to hear how it went - your feedback helps other clients
find us and helps us improve.
</p>
<t t-set="review_url" t-value="object.company_id.x_fc_google_review_url or 'https://www.google.com/search?q=' + (object.company_id.name or '')"/>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-att-href="review_url"
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
Leave a Google review
</a>
</div>
<p style="opacity:0.55;font-size:12px;margin:0;">
If anything is not right, please reply directly to this email - we will make it right.
</p>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- On-Call Safety Page -->
<!-- ============================================================== -->

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',

View File

@@ -24,6 +24,13 @@
class="btn-secondary"
invisible="state != 'done'"
groups="fusion_repairs.group_fusion_repairs_user"/>
<button name="action_offer_loaner"
type="object"
string="Offer Loaner"
class="btn-secondary"
icon="fa-handshake-o"
invisible="state in ('done', 'cancel')"
groups="fusion_repairs.group_fusion_repairs_user"/>
</xpath>
<!-- Smart buttons: Technician Tasks + Intake Answers + Original SO. -->