diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index ed2483bb..160fbd7e 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.3.1', + 'version': '19.0.1.4.0', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ @@ -82,6 +82,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'views/repair_warranty_views.xml', 'views/maintenance_contract_views.xml', 'views/repair_dashboard_views.xml', + 'views/repair_inspection_views.xml', 'views/repair_order_views.xml', 'views/sale_order_views.xml', 'views/technician_task_views.xml', @@ -98,6 +99,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'wizard/qr_sticker_wizard_views.xml', # Reports 'report/qr_sticker_report.xml', + 'report/inspection_certificate_report.xml', # Menus (last, after all referenced actions exist) 'views/menus.xml', ], diff --git a/fusion_repairs/data/ir_cron_data.xml b/fusion_repairs/data/ir_cron_data.xml index df6fcb5a..3b256902 100644 --- a/fusion_repairs/data/ir_cron_data.xml +++ b/fusion_repairs/data/ir_cron_data.xml @@ -56,6 +56,19 @@ + + + Fusion Repairs: Inspection certificate expiry reminders + + code + model.cron_send_expiry_reminders() + + 1 + days + + + + Fusion Repairs: Offer loaner for long-running repairs diff --git a/fusion_repairs/data/ir_sequence_data.xml b/fusion_repairs/data/ir_sequence_data.xml index 6123a864..1d151cd6 100644 --- a/fusion_repairs/data/ir_sequence_data.xml +++ b/fusion_repairs/data/ir_sequence_data.xml @@ -24,6 +24,18 @@ + + + Inspection Certificate + fusion.repair.inspection.certificate + CERT-%(year)s- + 4 + 1 + 1 + + + + + + + + Repair: Inspection Certificate Expiry Reminder + + Your {{ object.product_id.display_name }} inspection certificate expires {{ object.expiry_date }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

+ +

+

Annual safety inspection coming due

+

+ Hello , the safety inspection + certificate on your + (certificate ) expires + . + Annual re-inspection keeps your equipment compliant with local safety regulations. +

+ + + + + + + +
Certificate
Equipment
Serial
Expires
+
+

+ Reply to this email or call our office to book your re-inspection. We will + send our certified technician to confirm everything is safe and renew your + certificate. +

+
+
+
+
+ {{ object.partner_id.lang }} + +
+ diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py index 9733b7dd..90e32bdc 100644 --- a/fusion_repairs/models/__init__.py +++ b/fusion_repairs/models/__init__.py @@ -12,6 +12,7 @@ from . import maintenance_contract from . import repair_self_check_rule from . import repair_ai_service from . import repair_on_call_service +from . import repair_inspection from . import product_template from . import res_partner from . import res_users diff --git a/fusion_repairs/models/repair_inspection.py b/fusion_repairs/models/repair_inspection.py new file mode 100644 index 00000000..12e6e58e --- /dev/null +++ b/fusion_repairs/models/repair_inspection.py @@ -0,0 +1,229 @@ +# -*- 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 diff --git a/fusion_repairs/report/inspection_certificate_report.xml b/fusion_repairs/report/inspection_certificate_report.xml new file mode 100644 index 00000000..d7849b04 --- /dev/null +++ b/fusion_repairs/report/inspection_certificate_report.xml @@ -0,0 +1,158 @@ + + + + + Inspection Certificate + fusion.repair.inspection.certificate + qweb-pdf + fusion_repairs.report_inspection_certificate + fusion_repairs.report_inspection_certificate + 'Inspection Certificate - %s' % (object.name) + + report + + + + + diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv index 15c9727b..f4d1859a 100644 --- a/fusion_repairs/security/ir.model.access.csv +++ b/fusion_repairs/security/ir.model.access.csv @@ -26,3 +26,7 @@ access_technician_task_repairs_manager,Technician Task Repairs Manager Full,fusi access_repair_self_check_rule_user,Self-Check Rule User Read,model_fusion_repair_self_check_rule,group_fusion_repairs_user,1,0,0,0 access_repair_self_check_rule_manager,Self-Check Rule Manager Full,model_fusion_repair_self_check_rule,group_fusion_repairs_manager,1,1,1,1 access_qr_sticker_wizard_user,QR Sticker Wizard User Full,model_fusion_repair_qr_sticker_wizard,group_fusion_repairs_user,1,1,1,1 +access_repair_inspection_user,Inspection Cert User Read,model_fusion_repair_inspection_certificate,group_fusion_repairs_user,1,0,0,0 +access_repair_inspection_dispatcher,Inspection Cert Dispatcher,model_fusion_repair_inspection_certificate,group_fusion_repairs_dispatcher,1,1,1,0 +access_repair_inspection_manager,Inspection Cert Manager Full,model_fusion_repair_inspection_certificate,group_fusion_repairs_manager,1,1,1,1 +access_repair_inspection_technician,Inspection Cert Field Tech Create,model_fusion_repair_inspection_certificate,fusion_tasks.group_field_technician,1,1,1,0 diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml index 4f671813..47456ebb 100644 --- a/fusion_repairs/views/menus.xml +++ b/fusion_repairs/views/menus.xml @@ -39,6 +39,12 @@ action="action_maintenance_contract" sequence="30"/> + + + + + + fusion.repair.inspection.certificate.list + fusion.repair.inspection.certificate + + + + + + + + + + + + + + + + + fusion.repair.inspection.certificate.form + fusion.repair.inspection.certificate + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Inspection Certificates + fusion.repair.inspection.certificate + list,form + + +
diff --git a/fusion_repairs/wizard/repair_visit_report_wizard.py b/fusion_repairs/wizard/repair_visit_report_wizard.py index 559cf72c..0301f866 100644 --- a/fusion_repairs/wizard/repair_visit_report_wizard.py +++ b/fusion_repairs/wizard/repair_visit_report_wizard.py @@ -66,6 +66,19 @@ class RepairVisitReportWizard(models.TransientModel): help='Tick to spawn a follow-up repair after saving this visit.', ) + # M1: tick when the visit was a safety inspection. On save the wizard + # creates a fusion.repair.inspection.certificate. + issue_inspection_cert = fields.Boolean( + string='Issue Compliance Certificate', + help='Tick when the visit was an annual safety inspection. Creates an ' + 'inspection certificate record and prints the PDF on save.', + ) + inspection_cert_id = fields.Many2one( + 'fusion.repair.inspection.certificate', + string='Issued Certificate', + readonly=True, + ) + # Variance display estimated_cost = fields.Monetary( related='repair_id.x_fc_estimated_cost', @@ -169,17 +182,66 @@ class RepairVisitReportWizard(models.TransientModel): )) % {'name': stub.name or ''}, ) + # M1: issue an inspection certificate when the box is ticked + # AND the equipment is safety-critical (stairlift / porch lift / power chair). + if self.issue_inspection_cert: + self._create_inspection_certificate(repair) + # If a stub was spawned, open it directly so the tech can fill in details. - target_id = stub.id if stub else repair.id - target_name = stub.name if stub else repair.name + # Otherwise, if a certificate was issued, jump to it so the tech can print. + if stub: + return { + 'type': 'ir.actions.act_window', + 'name': stub.name, + 'res_model': 'repair.order', + 'view_mode': 'form', + 'res_id': stub.id, + } + if self.inspection_cert_id: + return { + 'type': 'ir.actions.act_window', + 'name': self.inspection_cert_id.name, + 'res_model': 'fusion.repair.inspection.certificate', + 'view_mode': 'form', + 'res_id': self.inspection_cert_id.id, + } return { 'type': 'ir.actions.act_window', - 'name': target_name, + 'name': repair.name, 'res_model': 'repair.order', 'view_mode': 'form', - 'res_id': target_id, + 'res_id': repair.id, } + def _create_inspection_certificate(self, repair): + """M1: create the inspection certificate. Requires a safety-critical + equipment category - otherwise just logs to chatter and skips.""" + category = repair.x_fc_repair_category_id + if not category or not category.safety_critical: + repair.message_post(body=_( + 'Inspection certificate skipped - equipment category is not ' + 'flagged as safety_critical. Only stairlifts, porch lifts, ' + 'and power wheelchairs receive annual certificates.' + )) + return + if not repair.product_id: + repair.message_post(body=_( + 'Inspection certificate skipped - the repair has no product set.' + )) + return + Cert = self.env['fusion.repair.inspection.certificate'].sudo() + cert = Cert.create({ + 'partner_id': repair.partner_id.id, + 'product_id': repair.product_id.id, + 'lot_id': repair.lot_id.id if repair.lot_id else False, + 'repair_order_id': repair.id, + 'inspector_user_id': self.technician_id.id or self.env.uid, + }) + self.inspection_cert_id = cert + repair.message_post(body=_( + 'Issued inspection certificate %s (expires %s).' + ) % (cert.name, cert.expiry_date)) + def _create_repair_part_moves(self, repair): """Create stock.move records for each part used (repair_line_type='add'). diff --git a/fusion_repairs/wizard/repair_visit_report_wizard_views.xml b/fusion_repairs/wizard/repair_visit_report_wizard_views.xml index 7bf1139d..a78041e5 100644 --- a/fusion_repairs/wizard/repair_visit_report_wizard_views.xml +++ b/fusion_repairs/wizard/repair_visit_report_wizard_views.xml @@ -49,6 +49,7 @@ +