# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """Compliance inspection certificates (M1). Per the design spec section "Phase 4 - Compliance, claims, analytics": Stairlifts / porch lifts need an annual safety inspection certificate (jurisdictional requirement in many places). This model tracks issued certificates, their expiry dates, and a daily cron warns the office + client when one is approaching the 30-day expiry mark. A certificate is issued AFTER a successful inspection technician visit - the visit-report wizard's "Issue Compliance Certificate" button creates the record and renders a PDF. Phase 1 jurisdiction support: a single 'Ontario' jurisdiction text field on the certificate; future phases add per-jurisdiction PDF templates. """ from datetime import timedelta from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models class FusionRepairInspectionCertificate(models.Model): _name = 'fusion.repair.inspection.certificate' _inherit = ['mail.thread'] _description = 'Repair Inspection Certificate' _order = 'issued_date desc, id desc' name = fields.Char( string='Certificate Number', required=True, default='New', copy=False, readonly=True, tracking=True, ) partner_id = fields.Many2one( 'res.partner', string='Client', required=True, tracking=True, index=True, ) product_id = fields.Many2one( 'product.product', string='Equipment', required=True, domain="[('x_fc_repair_category_id.safety_critical', '=', True)]", tracking=True, ) lot_id = fields.Many2one( 'stock.lot', string='Serial Number', tracking=True, ) repair_order_id = fields.Many2one( 'repair.order', string='Inspection Repair', help='The repair / technician task during which this inspection was done.', ondelete='set null', ) inspector_user_id = fields.Many2one( 'res.users', string='Inspector', required=True, default=lambda self: self.env.user, tracking=True, domain="[('x_fc_is_field_staff', '=', True)]", ) jurisdiction = fields.Selection( [ ('on', 'Ontario'), ('bc', 'British Columbia'), ('ab', 'Alberta'), ('qc', 'Quebec'), ('other', 'Other'), ], string='Jurisdiction', default='on', tracking=True, ) issued_date = fields.Date( string='Issued', required=True, default=fields.Date.context_today, tracking=True, ) valid_for_months = fields.Integer( string='Valid For (Months)', default=12, required=True, ) expiry_date = fields.Date( string='Expires', compute='_compute_expiry_date', store=True, tracking=True, ) # Status compute (non-stored - time-dependent, per Bundle 1 C4 fix pattern). status = fields.Selection( [ ('valid', 'Valid'), ('expiring', 'Expiring Soon'), ('expired', 'Expired'), ('revoked', 'Revoked'), ], string='Status', compute='_compute_status', ) revoked = fields.Boolean( string='Revoked', copy=False, tracking=True, ) notes = fields.Html(string='Inspector Notes') company_id = fields.Many2one( 'res.company', string='Company', default=lambda self: self.env.company, ) # Reminder tracking (X2-style band markers so the cron doesn't spam). last_reminder_band = fields.Selection( [('30', '30 days'), ('7', '7 days')], string='Last Reminder', copy=False, ) _certificate_number_unique = models.Constraint( 'unique(name)', 'Inspection certificate numbers must be unique.', ) # ------------------------------------------------------------------ # CREATE # ------------------------------------------------------------------ @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.inspection.certificate' ) or 'CERT/NEW' return super().create(vals_list) # ------------------------------------------------------------------ # COMPUTES # ------------------------------------------------------------------ @api.depends('issued_date', 'valid_for_months') def _compute_expiry_date(self): for c in self: if c.issued_date and c.valid_for_months: c.expiry_date = c.issued_date + relativedelta(months=c.valid_for_months) else: c.expiry_date = False def _compute_status(self): today = fields.Date.context_today(self) for c in self: if c.revoked: c.status = 'revoked' elif not c.expiry_date: c.status = 'valid' elif c.expiry_date < today: c.status = 'expired' elif c.expiry_date <= today + timedelta(days=30): c.status = 'expiring' else: c.status = 'valid' # ------------------------------------------------------------------ # ACTIONS # ------------------------------------------------------------------ def action_revoke(self): for c in self: c.revoked = True c.message_post(body=_('Certificate revoked.')) def action_print(self): self.ensure_one() return self.env.ref( 'fusion_repairs.action_report_inspection_certificate' ).report_action(self) # ------------------------------------------------------------------ # CRON: warn the client 30 + 7 days before expiry # ------------------------------------------------------------------ @api.model def cron_send_expiry_reminders(self): """Daily cron. Sends a reminder at the 30-day band, then again at the 7-day band, so the client books their re-inspection visit before the certificate lapses.""" Service = self.env.get('fusion.repair.intake.service') if Service and not Service._notifications_enabled(): return today = fields.Date.context_today(self) tpl = self.env.ref( 'fusion_repairs.email_template_inspection_expiry_reminder', raise_if_not_found=False, ) if not tpl: return for band_label, days in (('30', 30), ('7', 7)): target = today + timedelta(days=days) certs = self.search([ ('revoked', '=', False), ('expiry_date', '=', target), ('partner_id.email', '!=', False), '|', ('last_reminder_band', '=', False), ('last_reminder_band', '!=', band_label), ]) for c in certs: # Skip if a smaller band already sent (30 -> 7 progression). if c.last_reminder_band and int(c.last_reminder_band) <= days: continue try: tpl.send_mail(c.id, force_send=False) c.last_reminder_band = band_label except Exception: continue