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:
@@ -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': """
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
<!-- ============================================================== -->
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
Reference in New Issue
Block a user