diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index a8edb017..4e0a7a13 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -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': """ diff --git a/fusion_repairs/data/ir_config_parameter_data.xml b/fusion_repairs/data/ir_config_parameter_data.xml index a3d04c17..77bfa42d 100644 --- a/fusion_repairs/data/ir_config_parameter_data.xml +++ b/fusion_repairs/data/ir_config_parameter_data.xml @@ -56,5 +56,11 @@ fusion_repairs.client_portal_rate_limit_per_hour 10 + + + + fusion_repairs.loaner_offer_threshold_days + 3 + diff --git a/fusion_repairs/data/ir_cron_data.xml b/fusion_repairs/data/ir_cron_data.xml index c73b7009..df6fcb5a 100644 --- a/fusion_repairs/data/ir_cron_data.xml +++ b/fusion_repairs/data/ir_cron_data.xml @@ -30,5 +30,44 @@ + + + Fusion Repairs: Day-before visit reminders + + code + model.cron_send_day_before_reminders() + + 1 + days + + + + + + + Fusion Repairs: Send post-visit NPS emails + + code + model.cron_send_post_visit_nps() + + 1 + hours + + + + + + + Fusion Repairs: Offer loaner for long-running repairs + + code + model.cron_offer_loaner_for_long_repairs() + + 1 + days + + + + diff --git a/fusion_repairs/data/mail_activity_type_data.xml b/fusion_repairs/data/mail_activity_type_data.xml index 323594f4..518c3924 100644 --- a/fusion_repairs/data/mail_activity_type_data.xml +++ b/fusion_repairs/data/mail_activity_type_data.xml @@ -50,5 +50,17 @@ 40 + + + Repair: Offer Loaner + Offer the client a loaner unit while repair is in progress + 1 + days + previous_activity + repair.order + fa-handshake-o + 50 + + diff --git a/fusion_repairs/data/mail_template_data.xml b/fusion_repairs/data/mail_template_data.xml index 12ad7d93..0850ec6b 100644 --- a/fusion_repairs/data/mail_template_data.xml +++ b/fusion_repairs/data/mail_template_data.xml @@ -55,6 +55,89 @@ + + + + + Repair: Day-Before Visit Reminder + + Reminder: technician visit tomorrow for {{ object.name }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

+ +

+

Reminder: our technician visits tomorrow

+

+ Hello , this is a friendly + reminder that your service call + is scheduled for tomorrow. +

+ + + + + + + + +
Scheduled
Technician
Equipment
+
+

+ Need to reschedule? Reply to this email or call our office. + Please make sure the equipment is accessible and any pets are secured. +

+
+
+
+
+ {{ object.partner_id.lang }} + +
+ + + + + + Repair: Post-Visit NPS + + How did we do, {{ object.partner_id.name or 'there' }}? + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

+ +

+

Thanks for trusting us with your equipment

+

+ Your service call is complete. + We would love to hear how it went - your feedback helps other clients + find us and helps us improve. +

+ + +

+ If anything is not right, please reply directly to this email - we will make it right. +

+
+
+
+ {{ object.partner_id.lang }} + +
+ diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py index d11f0cb1..df64d395 100644 --- a/fusion_repairs/models/repair_order.py +++ b/fusion_repairs/models/repair_order.py @@ -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', diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml index 36b4bfb1..e2644fd1 100644 --- a/fusion_repairs/views/repair_order_views.xml +++ b/fusion_repairs/views/repair_order_views.xml @@ -24,6 +24,13 @@ class="btn-secondary" invisible="state != 'done'" groups="fusion_repairs.group_fusion_repairs_user"/> +