# -*- 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, ) currency_id = fields.Many2one( 'res.currency', default=lambda self: self.env.company.currency_id, ) x_fc_maintenance_fee = fields.Monetary( string='Maintenance Fee', currency_field='currency_id', help='Flat fee shown to the client for this maintenance visit.', ) x_fc_source = fields.Selection( [('sale', 'New Sale'), ('backfill', 'Backfill'), ('claims', 'Claims Bridge'), ('manual', 'Manual')], string='Source', default='manual', index=True, ) x_fc_source_sale_line_id = fields.Many2one( 'sale.order.line', string='Source Sale Line', index=True, copy=False, ) x_fc_device_serial = fields.Char(string='Serial (text)', index=True, copy=False) x_fc_policy_category_id = fields.Many2one( 'fusion.repair.product.category', string='Maintenance Policy', ) _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 _fc_maintenance_anchor_date(self, line): """Best-available delivery anchor: commitment_date -> date_order -> today. (Non-ADP / lift units lack a delivery date; this fallback chain handles them.)""" so = line.order_id anchor = so.commitment_date or so.date_order return fields.Date.to_date(anchor) if anchor else fields.Date.context_today(self) def _spawn_maintenance_contracts(self): """Create a priced maintenance contract per maintainable unit on a confirmed SO. Policy = product interval override, else the product's category policy. Idempotent: by serial when captured, else by source sale line.""" Contract = self.env['fusion.repair.maintenance.contract'].sudo() 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 tmpl = product.product_tmpl_id category = tmpl.x_fc_repair_category_id product_interval = tmpl.x_fc_maintenance_interval_months or 0 cat_enabled = bool(category) and category.x_fc_maintenance_enabled interval = product_interval or ( category.x_fc_maintenance_interval_months if cat_enabled else 0) if interval <= 0 or not (product_interval > 0 or cat_enabled): continue fee = tmpl.x_fc_maintenance_fee or ( category.x_fc_maintenance_fee if category else 0.0) # Capture serial only if fusion_claims' line field is present. serial = '' if 'x_fc_serial_number' in line._fields: serial = (line.x_fc_serial_number or '').strip() # Idempotency: serial regime vs source-line regime (spec 6.2). if serial: dedup = [('state', '=', 'active'), ('x_fc_device_serial', '=', serial)] else: dedup = [('state', '=', 'active'), ('x_fc_source_sale_line_id', '=', line.id)] if Contract.search_count(dedup): continue anchor = so._fc_maintenance_anchor_date(line) # One contract per serialized unit; without a serial, per quantity. count = 1 if serial else max(int(line.product_uom_qty or 1), 1) for _i in range(count): Contract.create({ 'partner_id': so.partner_id.id, 'product_id': product.id, 'original_sale_order_id': so.id, 'x_fc_source_sale_line_id': line.id, 'x_fc_source': 'sale', 'x_fc_device_serial': serial, 'x_fc_policy_category_id': category.id if category else False, 'interval_months': interval, 'x_fc_maintenance_fee': fee, 'next_due_date': anchor + relativedelta(months=interval), 'state': 'active', })