diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index 38d00ee4..b7d826a7 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -69,6 +69,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. # Data (must load before views that reference records) 'data/ir_sequence_data.xml', 'data/ir_config_parameter_data.xml', + 'data/ir_cron_data.xml', 'data/mail_activity_type_data.xml', 'data/mail_template_data.xml', 'data/repair_product_category_data.xml', @@ -78,6 +79,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'views/intake_template_views.xml', 'views/service_catalog_views.xml', 'views/repair_warranty_views.xml', + 'views/maintenance_contract_views.xml', 'views/repair_order_views.xml', 'views/res_partner_views.xml', 'views/res_users_views.xml', @@ -85,6 +87,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. # Portal templates 'views/portal_sales_rep_templates.xml', 'views/portal_client_repair_templates.xml', + 'views/portal_maintenance_templates.xml', # Wizards 'wizard/repair_intake_wizard_views.xml', 'wizard/repair_visit_report_wizard_views.xml', diff --git a/fusion_repairs/controllers/__init__.py b/fusion_repairs/controllers/__init__.py index cd1bd59e..441e717a 100644 --- a/fusion_repairs/controllers/__init__.py +++ b/fusion_repairs/controllers/__init__.py @@ -4,3 +4,4 @@ from . import portal_sales_rep_repair from . import portal_client_repair +from . import portal_maintenance_booking diff --git a/fusion_repairs/controllers/portal_maintenance_booking.py b/fusion_repairs/controllers/portal_maintenance_booking.py new file mode 100644 index 00000000..6b1b6cec --- /dev/null +++ b/fusion_repairs/controllers/portal_maintenance_booking.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Client maintenance booking portal. + +The maintenance reminder email contains a tokenized URL: + /repairs/maintenance/book/ + +Clicking it lands the client on a single-page form where they can confirm +a preferred date. On submit, a repair.order is spawned via the same +intake service (source='client_portal') and the contract's next reminder +band is locked so we don't keep nagging them. +""" + +import logging + +from odoo import _, fields, http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class MaintenanceBookingPortal(http.Controller): + + def _resolve_contract(self, token): + if not token: + return None + Contract = request.env['fusion.repair.maintenance.contract'].sudo() + contract = Contract.search([('booking_token', '=', token)], limit=1) + if not contract or contract.state != 'active': + return None + return contract + + @http.route('/repairs/maintenance/book/', type='http', + auth='public', website=True, sitemap=False) + def maintenance_book_get(self, token, **kw): + contract = self._resolve_contract(token) + if not contract: + return request.render('fusion_repairs.portal_maintenance_invalid_token', {}) + already = bool(contract.booking_repair_id) + return request.render('fusion_repairs.portal_maintenance_book', { + 'contract': contract, + 'already_booked': already, + 'default_date': fields.Date.context_today(request.env.user).isoformat(), + }) + + @http.route('/repairs/maintenance/book//confirm', type='http', + auth='public', methods=['POST'], csrf=True, website=True) + def maintenance_book_post(self, token, **post): + contract = self._resolve_contract(token) + if not contract: + return request.render('fusion_repairs.portal_maintenance_invalid_token', {}) + + if contract.booking_repair_id: + return request.redirect(f'/repairs/maintenance/book/{token}?ok=already') + + preferred_date = (post.get('preferred_date') or '').strip() + scheduled = False + if preferred_date: + try: + scheduled = fields.Date.from_string(preferred_date) + except ValueError: + scheduled = False + + repair = contract.create_repair_from_booking(scheduled_date=scheduled) + return request.render('fusion_repairs.portal_maintenance_thanks', { + 'contract': contract, + 'repair': repair, + }) diff --git a/fusion_repairs/data/ir_cron_data.xml b/fusion_repairs/data/ir_cron_data.xml new file mode 100644 index 00000000..5f9e95ed --- /dev/null +++ b/fusion_repairs/data/ir_cron_data.xml @@ -0,0 +1,19 @@ + + + + + + + Fusion Repairs: Send maintenance due reminders + + code + model.cron_send_due_reminders() + + 1 + days + + + + + + diff --git a/fusion_repairs/data/ir_sequence_data.xml b/fusion_repairs/data/ir_sequence_data.xml index 37c8f7d7..dd24383f 100644 --- a/fusion_repairs/data/ir_sequence_data.xml +++ b/fusion_repairs/data/ir_sequence_data.xml @@ -12,5 +12,16 @@ 1 + + + + Repair Maintenance Contract + fusion.repair.maintenance.contract + MC/ + 5 + 1 + 1 + + diff --git a/fusion_repairs/data/mail_template_data.xml b/fusion_repairs/data/mail_template_data.xml index 06571eeb..8ce6e0a9 100644 --- a/fusion_repairs/data/mail_template_data.xml +++ b/fusion_repairs/data/mail_template_data.xml @@ -55,6 +55,52 @@ + + + + + Repair: Maintenance Due Reminder + + {{ object.company_id.name }} - Time to schedule your equipment maintenance + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

+ +

+

Your equipment is due for maintenance

+

+ Hello , your + + is due for its next scheduled maintenance visit on + . +

+ +
+

+ Regular maintenance keeps your equipment safe and reliable. Use the + button above to confirm and we will reach out to schedule a time that works for you. +

+
+

+ Contract reference . + If you no longer have this equipment, you can ignore this email. +

+
+
+
+ {{ object.partner_id.lang }} + +
+ diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py index 0f4d53fe..541abf49 100644 --- a/fusion_repairs/models/__init__.py +++ b/fusion_repairs/models/__init__.py @@ -8,6 +8,7 @@ from . import intake_question from . import intake_answer from . import service_catalog from . import repair_warranty +from . import maintenance_contract from . import product_template from . import res_partner from . import res_users diff --git a/fusion_repairs/models/maintenance_contract.py b/fusion_repairs/models/maintenance_contract.py new file mode 100644 index 00000000..bed7e5ff --- /dev/null +++ b/fusion_repairs/models/maintenance_contract.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Maintenance contracts. + +One contract per sold unit (partner + product + lot). When the underlying +sale order is delivered and the product has x_fc_maintenance_interval_months>0, +a contract is auto-created. A daily cron walks active contracts and sends +the client a reminder email at 30, 7, and 1 days before next_due_date with +a tokenized booking link. +""" + +import secrets +from datetime import timedelta + +from odoo import _, api, fields, models + + +CONTRACT_STATES = [ + ('draft', 'Draft'), + ('active', 'Active'), + ('paused', 'Paused'), + ('cancelled', 'Cancelled'), +] + + +class FusionRepairMaintenanceContract(models.Model): + _name = 'fusion.repair.maintenance.contract' + _description = 'Repair Maintenance Contract' + _order = 'next_due_date, id' + + name = fields.Char(string='Reference', required=True, default='New', + copy=False, readonly=True) + partner_id = fields.Many2one( + 'res.partner', + string='Client', + required=True, + index=True, + ondelete='restrict', + ) + product_id = fields.Many2one( + 'product.product', + string='Equipment', + required=True, + index=True, + ) + lot_id = fields.Many2one('stock.lot', string='Serial Number') + original_sale_order_id = fields.Many2one( + 'sale.order', + string='Original Sale Order', + index=True, + ) + + interval_months = fields.Integer(string='Interval (months)', default=12, required=True) + last_service_date = fields.Date(string='Last Service') + next_due_date = fields.Date(string='Next Due', required=True, index=True) + state = fields.Selection(CONTRACT_STATES, default='active', required=True, + tracking=True, index=True) + + booking_token = fields.Char(string='Booking Token', copy=False, index=True) + last_reminder_band = fields.Selection( + [('30', '30 days'), ('7', '7 days'), ('1', '1 day')], + string='Last Reminder Sent', + copy=False, + help='The most recent reminder band sent for the current cycle.', + ) + booking_repair_id = fields.Many2one( + 'repair.order', + string='Booked Repair', + copy=False, + help='The repair.order created when the client used the booking link for this cycle.', + ) + + company_id = fields.Many2one( + 'res.company', default=lambda self: self.env.company, + ) + + _inherit = ['mail.thread'] + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', 'New') == 'New': + vals['name'] = self.env['ir.sequence'].next_by_code( + 'fusion.repair.maintenance.contract' + ) or 'MC/NEW' + if not vals.get('booking_token'): + vals['booking_token'] = secrets.token_urlsafe(20) + return super().create(vals_list) + + # ------------------------------------------------------------------ + # ROLL FORWARD + # ------------------------------------------------------------------ + def roll_next_due_date(self): + """Advance next_due_date by interval_months and reset cycle state.""" + for c in self: + base = c.last_service_date or fields.Date.context_today(c) + c.next_due_date = base + timedelta(days=(c.interval_months or 12) * 30) + c.last_reminder_band = False + c.booking_repair_id = False + + # ------------------------------------------------------------------ + # REMINDER CRON + # ------------------------------------------------------------------ + @api.model + def cron_send_due_reminders(self): + """Daily cron - send reminder emails at 30/7/1 days before next_due_date.""" + ICP = self.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_repairs.enable_email_notifications', 'True') != 'True': + return + today = fields.Date.context_today(self) + bands = [ + ('30', 30), + ('7', 7), + ('1', 1), + ] + tpl = self.env.ref( + 'fusion_repairs.email_template_maintenance_due_reminder', + raise_if_not_found=False, + ) + if not tpl: + return + for band_label, days in bands: + target = today + timedelta(days=days) + domain = [ + ('state', '=', 'active'), + ('next_due_date', '=', target), + ('partner_id.email', '!=', False), + ] + # Don't re-send a smaller band if we already sent a larger one + # for the same cycle - the band order is 30 -> 7 -> 1. + contracts = self.search(domain) + for c in contracts: + if c.last_reminder_band == band_label: + continue + try: + tpl.send_mail(c.id, force_send=False) + c.last_reminder_band = band_label + c.message_post( + body=_('Sent %(band)s-day maintenance reminder to %(email)s.', + band=band_label, + email=c.partner_id.email), + ) + except Exception: + continue + + # ------------------------------------------------------------------ + # PORTAL BOOKING + # ------------------------------------------------------------------ + def create_repair_from_booking(self, scheduled_date=None): + """Spawn a repair.order from the booking link (or any manual booking).""" + self.ensure_one() + if self.booking_repair_id and self.booking_repair_id.state != 'cancel': + return self.booking_repair_id + Repair = self.env['repair.order'].sudo() + repair = Repair.create({ + 'partner_id': self.partner_id.id, + 'product_id': self.product_id.id, + 'lot_id': self.lot_id.id if self.lot_id else False, + 'schedule_date': scheduled_date or fields.Datetime.now(), + 'x_fc_intake_source': 'client_portal', + 'x_fc_urgency': 'normal', + 'x_fc_repair_category_id': self.product_id.product_tmpl_id.x_fc_repair_category_id.id + if self.product_id.product_tmpl_id.x_fc_repair_category_id else False, + 'internal_notes': + f'

Maintenance visit booked from reminder for contract {self.name}.

', + }) + self.booking_repair_id = repair + self.message_post( + body=_('Maintenance visit %(ref)s booked from reminder link.', + ref=repair.name), + ) + return repair + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def _spawn_maintenance_contracts(self): + """Create maintenance contracts for any delivered SO line whose + product has x_fc_maintenance_interval_months > 0.""" + Contract = self.env['fusion.repair.maintenance.contract'].sudo() + today = fields.Date.context_today(self) + for so in self: + if so.state not in ('sale', 'done'): + continue + for line in so.order_line: + product = line.product_id + if not product: + continue + interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0 + if interval <= 0: + continue + existing = Contract.search([ + ('partner_id', '=', so.partner_id.id), + ('product_id', '=', product.id), + ('original_sale_order_id', '=', so.id), + ], limit=1) + if existing: + continue + Contract.create({ + 'partner_id': so.partner_id.id, + 'product_id': product.id, + 'original_sale_order_id': so.id, + 'interval_months': interval, + 'next_due_date': today + timedelta(days=interval * 30), + 'state': 'active', + }) diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv index 4d58809f..4470566b 100644 --- a/fusion_repairs/security/ir.model.access.csv +++ b/fusion_repairs/security/ir.model.access.csv @@ -16,3 +16,6 @@ access_repair_warranty_user,Warranty User Read,model_fusion_repair_warranty_cove access_repair_warranty_manager,Warranty Manager Full,model_fusion_repair_warranty_coverage,group_fusion_repairs_manager,1,1,1,1 access_repair_visit_report_wizard_user,Visit Report Wizard User,model_fusion_repair_visit_report_wizard,group_fusion_repairs_user,1,1,1,1 access_repair_visit_report_wizard_line_user,Visit Report Line User,model_fusion_repair_visit_report_wizard_line,group_fusion_repairs_user,1,1,1,1 +access_repair_maintenance_user,Maintenance Contract User Read,model_fusion_repair_maintenance_contract,group_fusion_repairs_user,1,0,0,0 +access_repair_maintenance_dispatcher,Maintenance Contract Dispatcher,model_fusion_repair_maintenance_contract,group_fusion_repairs_dispatcher,1,1,1,0 +access_repair_maintenance_manager,Maintenance Contract Manager Full,model_fusion_repair_maintenance_contract,group_fusion_repairs_manager,1,1,1,1 diff --git a/fusion_repairs/views/maintenance_contract_views.xml b/fusion_repairs/views/maintenance_contract_views.xml new file mode 100644 index 00000000..b25cb0d0 --- /dev/null +++ b/fusion_repairs/views/maintenance_contract_views.xml @@ -0,0 +1,67 @@ + + + + + fusion.repair.maintenance.contract.list + fusion.repair.maintenance.contract + + + + + + + + + + + + + + + + fusion.repair.maintenance.contract.form + fusion.repair.maintenance.contract + +
+
+ +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + +
+ + +
+
+ + + Maintenance Contracts + fusion.repair.maintenance.contract + list,form + + +
diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml index 4a30c436..b1176963 100644 --- a/fusion_repairs/views/menus.xml +++ b/fusion_repairs/views/menus.xml @@ -26,6 +26,12 @@ action="repair.action_repair_order_tree" sequence="20"/> + + + + + + + + + + + + + + + + + + + +