# -*- 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 dateutil.relativedelta import relativedelta from markupsafe import Markup 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' _inherit = ['mail.thread'] _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, ) _booking_token_unique = models.Constraint( 'unique(booking_token)', 'Booking token must be unique.', ) @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. Called from technician_task.write() when a maintenance task moves to 'completed' (see technician_task.py). """ for c in self: base = c.last_service_date or fields.Date.context_today(c) # relativedelta handles month boundaries correctly (28/29/30/31). c.next_due_date = base + relativedelta(months=c.interval_months or 12) 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=Markup(_( 'Sent %(band)s-day maintenance reminder to %(email)s.' )) % { 'band': band_label, 'email': c.partner_id.email or '', }, ) 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, 'x_fc_maintenance_contract_id': self.id, 'internal_notes': f'

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

', }) self.booking_repair_id = repair self.message_post( body=Markup(_( 'Maintenance visit %(ref)s booked from reminder link.' )) % {'ref': repair.name or ''}, ) 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 + relativedelta(months=interval), 'state': 'active', })