New fusion.repair.inspection.certificate model for the annual safety inspections required on stairlifts, porch lifts, and power wheelchairs in many jurisdictions. Model - mail.thread chatter-tracked; fields: name (CERT-YYYY-NNNN auto-seq), partner_id, product_id (filtered to safety-critical categories), lot_id, repair_order_id back-link, inspector_user_id (must be field staff), jurisdiction (selection: Ontario / BC / Alberta / Quebec / Other), issued_date, valid_for_months (default 12), expiry_date (computed, stored, uses relativedelta - correct month boundaries), status (non-stored compute: valid / expiring / expired / revoked), revoked, notes, last_reminder_band. - Unique constraint on certificate number (models.Constraint, not _sql_constraints, per project rule). - Sequence 'fusion.repair.inspection.certificate' with use_date_range=True so the counter resets each year (CERT-2026-0001 ... CERT-2027-0001). Visit report integration - New issue_inspection_cert checkbox on fusion.repair.visit.report.wizard. - When ticked AND the repair's category is safety_critical, action_confirm() creates the certificate via _create_inspection_certificate() and redirects to the cert form so the tech can print immediately. - Non-safety-critical equipment quietly skips with a chatter note explaining why. PDF report - web.html_container + web.external_layout, model bound so it appears as a Print action on the certificate form. - 'Certificate of Inspection' / 'Safety Inspected' gold-banner layout with client name, equipment, serial, jurisdiction, issued + expiry dates, inspector signature line, and the certificate number. - Print Certificate button in form header. Daily cron - cron_send_expiry_reminders runs at 09:00, sends two band-tracked reminders (30 days + 7 days before expiry) to the client. - New mail.template email_template_inspection_expiry_reminder with 4px amber accent, certificate ref, equipment, expiry date, and a CTA to call to book the re-inspection visit. - last_reminder_band on the cert prevents re-sending the same band. Backend wiring - New menu entry 'Fusion Repairs > Inspection Certificates'. - ACL: User read, Dispatcher write, Manager unlink. Field technicians can create (they need to issue from the field). - List view with red/amber/green status decoration. - Form with statusbar, header buttons (Print, Revoke with confirm), chatter. Verified end-to-end on local westin-v19: Stairlift repair RO-202605-15 -> visit-report with issue_inspection_cert=True -> CERT-2026-0001 issued (status=valid, expires 2027-05-21) Cert CERT-2026-0002 expiring in 30 days -> cron flagged last_reminder_band='30' (would email client). Bumped to 19.0.1.4.0 (minor bump for the new public-facing capability). Co-authored-by: Cursor <cursoragent@cursor.com>
230 lines
7.4 KiB
Python
230 lines
7.4 KiB
Python
# -*- 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
|