feat(fusion_repairs): Bundle 4 - M1 compliance inspection certificates
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>
This commit is contained in:
@@ -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',
|
||||
],
|
||||
|
||||
@@ -56,6 +56,19 @@
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- M1: Inspection certificate expiry reminders (30 + 7 days). -->
|
||||
<record id="cron_inspection_expiry_reminders" model="ir.cron">
|
||||
<field name="name">Fusion Repairs: Inspection certificate expiry reminders</field>
|
||||
<field name="model_id" ref="model_fusion_repair_inspection_certificate"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_send_expiry_reminders()</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="nextcall" eval="(DateTime.now().replace(hour=9, minute=0, second=0, microsecond=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- M3: Offer loaner activity for long-running repairs. Runs daily. -->
|
||||
<record id="cron_offer_loaner" model="ir.cron">
|
||||
<field name="name">Fusion Repairs: Offer loaner for long-running repairs</field>
|
||||
|
||||
@@ -24,6 +24,18 @@
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Inspection certificate reference: CERT-YYYY-NNNN, yearly reset. -->
|
||||
<record id="seq_repair_inspection_certificate" model="ir.sequence">
|
||||
<field name="name">Inspection Certificate</field>
|
||||
<field name="code">fusion.repair.inspection.certificate</field>
|
||||
<field name="prefix">CERT-%(year)s-</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
<field name="use_date_range" eval="True"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Date-based repair.order reference: RO-YYYYMM-NN, counter resets monthly.
|
||||
use_date_range=True so Odoo creates an ir.sequence.date_range record
|
||||
per month with its own number_next, giving each month a fresh -01.
|
||||
|
||||
@@ -143,6 +143,52 @@
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- M1: Inspection certificate expiry reminder -->
|
||||
<!-- ============================================================== -->
|
||||
<record id="email_template_inspection_expiry_reminder" model="mail.template">
|
||||
<field name="name">Repair: Inspection Certificate Expiry Reminder</field>
|
||||
<field name="model_id" ref="model_fusion_repair_inspection_certificate"/>
|
||||
<field name="subject">Your {{ object.product_id.display_name }} inspection certificate expires {{ object.expiry_date }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="partner_to">{{ object.partner_id.id }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="height:4px;background-color:#d97706;"></div>
|
||||
<div style="padding:32px 28px;">
|
||||
<p style="color:#d97706;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
|
||||
<t t-out="object.company_id.name"/>
|
||||
</p>
|
||||
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Annual safety inspection coming due</h2>
|
||||
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
|
||||
Hello <t t-out="object.partner_id.name or 'there'"/>, the safety inspection
|
||||
certificate on your <strong><t t-out="object.product_id.display_name"/></strong>
|
||||
(certificate <strong><t t-out="object.name"/></strong>) expires
|
||||
<strong><t t-out="object.expiry_date" t-options="{'widget': 'date'}"/></strong>.
|
||||
Annual re-inspection keeps your equipment compliant with local safety regulations.
|
||||
</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:40%;">Certificate</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Equipment</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.product_id.display_name"/></td></tr>
|
||||
<t t-if="object.lot_id">
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Serial</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.lot_id.name"/></td></tr>
|
||||
</t>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;">Expires</td><td style="padding:10px 14px;font-size:14px;color:#d97706;font-weight:600;"><t t-out="object.expiry_date" t-options="{'widget': 'date'}"/></td></tr>
|
||||
</table>
|
||||
<div style="border-left:3px solid #d97706;padding:12px 16px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="lang">{{ object.partner_id.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- On-Call Safety Page -->
|
||||
<!-- ============================================================== -->
|
||||
|
||||
@@ -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
|
||||
|
||||
229
fusion_repairs/models/repair_inspection.py
Normal file
229
fusion_repairs/models/repair_inspection.py
Normal file
@@ -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
|
||||
158
fusion_repairs/report/inspection_certificate_report.xml
Normal file
158
fusion_repairs/report/inspection_certificate_report.xml
Normal file
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_report_inspection_certificate" model="ir.actions.report">
|
||||
<field name="name">Inspection Certificate</field>
|
||||
<field name="model">fusion.repair.inspection.certificate</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_repairs.report_inspection_certificate</field>
|
||||
<field name="report_file">fusion_repairs.report_inspection_certificate</field>
|
||||
<field name="print_report_name">'Inspection Certificate - %s' % (object.name)</field>
|
||||
<field name="binding_model_id" ref="model_fusion_repair_inspection_certificate"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
<template id="report_inspection_certificate">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="cert">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<style>
|
||||
.cert-wrap {
|
||||
font-family: sans-serif;
|
||||
padding: 20mm 18mm;
|
||||
text-align: center;
|
||||
}
|
||||
.cert-banner {
|
||||
font-size: 11pt;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
color: #c0a544;
|
||||
margin-bottom: 8mm;
|
||||
}
|
||||
.cert-title {
|
||||
font-size: 30pt;
|
||||
font-weight: 700;
|
||||
margin: 4mm 0;
|
||||
}
|
||||
.cert-sub {
|
||||
font-size: 13pt;
|
||||
color: #555;
|
||||
margin: 0 0 12mm 0;
|
||||
}
|
||||
.cert-issued-to {
|
||||
font-size: 11pt;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
margin-bottom: 4mm;
|
||||
}
|
||||
.cert-client {
|
||||
font-size: 20pt;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12mm;
|
||||
}
|
||||
.cert-info {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 18mm;
|
||||
margin: 10mm 0;
|
||||
}
|
||||
.cert-info-item {
|
||||
font-size: 10pt;
|
||||
text-align: left;
|
||||
}
|
||||
.cert-info-item .label {
|
||||
text-transform: uppercase;
|
||||
color: #888;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 8pt;
|
||||
margin-bottom: 1mm;
|
||||
}
|
||||
.cert-info-item .value {
|
||||
font-size: 12pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cert-footer {
|
||||
margin-top: 18mm;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.cert-sig {
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
border-top: 1px solid #999;
|
||||
padding-top: 2mm;
|
||||
width: 70mm;
|
||||
text-align: center;
|
||||
}
|
||||
.cert-number {
|
||||
font-size: 9pt;
|
||||
color: #888;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
<div class="cert-wrap">
|
||||
<div class="cert-banner">Certificate of Inspection</div>
|
||||
<div class="cert-title">Safety Inspected</div>
|
||||
<div class="cert-sub">
|
||||
This certifies that the equipment described below has passed
|
||||
its annual safety inspection in accordance with applicable
|
||||
local regulations.
|
||||
</div>
|
||||
|
||||
<div class="cert-issued-to">Issued To</div>
|
||||
<div class="cert-client"><t t-out="cert.partner_id.name"/></div>
|
||||
|
||||
<div class="cert-info">
|
||||
<div class="cert-info-item">
|
||||
<div class="label">Equipment</div>
|
||||
<div class="value"><t t-out="cert.product_id.display_name"/></div>
|
||||
</div>
|
||||
<t t-if="cert.lot_id">
|
||||
<div class="cert-info-item">
|
||||
<div class="label">Serial</div>
|
||||
<div class="value"><t t-out="cert.lot_id.name"/></div>
|
||||
</div>
|
||||
</t>
|
||||
<div class="cert-info-item">
|
||||
<div class="label">Jurisdiction</div>
|
||||
<div class="value">
|
||||
<t t-out="dict(cert._fields['jurisdiction'].selection).get(cert.jurisdiction)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-info">
|
||||
<div class="cert-info-item">
|
||||
<div class="label">Issued</div>
|
||||
<div class="value">
|
||||
<t t-out="cert.issued_date" t-options="{'widget': 'date'}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-info-item">
|
||||
<div class="label">Valid Until</div>
|
||||
<div class="value">
|
||||
<t t-out="cert.expiry_date" t-options="{'widget': 'date'}"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-footer">
|
||||
<div class="cert-number">
|
||||
Certificate #<t t-out="cert.name"/>
|
||||
</div>
|
||||
<div class="cert-sig">
|
||||
<t t-out="cert.inspector_user_id.name"/><br/>
|
||||
Inspector
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -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
|
||||
|
||||
|
@@ -39,6 +39,12 @@
|
||||
action="action_maintenance_contract"
|
||||
sequence="30"/>
|
||||
|
||||
<menuitem id="menu_fusion_repairs_inspections"
|
||||
name="Inspection Certificates"
|
||||
parent="menu_fusion_repairs_root"
|
||||
action="action_repair_inspection"
|
||||
sequence="35"/>
|
||||
|
||||
<!-- Configuration -->
|
||||
<menuitem id="menu_fusion_repairs_configuration"
|
||||
name="Configuration"
|
||||
|
||||
79
fusion_repairs/views/repair_inspection_views.xml
Normal file
79
fusion_repairs/views/repair_inspection_views.xml
Normal file
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_repair_inspection_list" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.inspection.certificate.list</field>
|
||||
<field name="model">fusion.repair.inspection.certificate</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Inspection Certificates"
|
||||
decoration-success="status == 'valid'"
|
||||
decoration-warning="status == 'expiring'"
|
||||
decoration-danger="status == 'expired' or status == 'revoked'">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="lot_id" optional="show"/>
|
||||
<field name="jurisdiction"/>
|
||||
<field name="issued_date"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="status" widget="badge"/>
|
||||
<field name="inspector_user_id" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_repair_inspection_form" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.inspection.certificate.form</field>
|
||||
<field name="model">fusion.repair.inspection.certificate</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Inspection Certificate">
|
||||
<header>
|
||||
<button name="action_print" type="object" string="Print Certificate"
|
||||
class="btn-primary" icon="fa-print"/>
|
||||
<button name="action_revoke" type="object" string="Revoke"
|
||||
class="btn-secondary" icon="fa-ban"
|
||||
invisible="revoked == True"
|
||||
confirm="Revoke this certificate? This cannot be undone."/>
|
||||
<field name="status" widget="badge"
|
||||
decoration-success="status == 'valid'"
|
||||
decoration-warning="status == 'expiring'"
|
||||
decoration-danger="status == 'expired' or status == 'revoked'"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" readonly="1"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id" options="{'no_create': True}"/>
|
||||
<field name="product_id" options="{'no_create': True}"/>
|
||||
<field name="lot_id"/>
|
||||
<field name="repair_order_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="inspector_user_id"/>
|
||||
<field name="jurisdiction"/>
|
||||
<field name="issued_date"/>
|
||||
<field name="valid_for_months"/>
|
||||
<field name="expiry_date" readonly="1"/>
|
||||
<field name="last_reminder_band" readonly="1"/>
|
||||
<field name="revoked" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="notes" placeholder="Inspection notes, measurements, photos taken..."/>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_repair_inspection" model="ir.actions.act_window">
|
||||
<field name="name">Inspection Certificates</field>
|
||||
<field name="res_model">fusion.repair.inspection.certificate</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -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').
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<separator string="Outcome"/>
|
||||
<field name="notes"/>
|
||||
<field name="found_another_issue"/>
|
||||
<field name="issue_inspection_cert"/>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button string="Save Visit Report"
|
||||
|
||||
Reference in New Issue
Block a user