This commit is contained in:
gsinghpal
2026-05-21 05:18:40 -04:00
91 changed files with 15004 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models
from . import wizard
from . import controllers

View File

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Repairs',
'version': '19.0.2.1.0',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """
Fusion Repairs
==============
Comprehensive repairs and maintenance management for medical equipment retailers
and service providers (hospital beds, wheelchairs, stairlifts, porch lifts,
walkers, mattresses, rollators).
Phase 1 - MVP
-------------
- Three intake surfaces sharing one service layer:
* Backend wizard for CS reps on the phone
* Sales rep portal (/my/repair/new) for reps on the road
* Public client self-service portal (/repair) - voicemail ready
- Guided question templates per medical equipment category
- Phone-first partner lookup with duplicate-call detection
- Multi-equipment per call (one repair.order per unit)
- Photo / video capture during intake
- Third-party equipment support (equipment we didn't sell)
- Auto warranty detection from original sale order
- Office notification recipients + 4 follow-up activities
- repair.order extensions linked to fusion.technician.task
Phase 2-4 (roadmap)
-------------------
- AI self-check engine with strict medical safety guardrails
- Upsell engine and direct-buy parts/plans
- Repair warranty tracking (free re-do window)
- Visit report wizard with Poynt terminal payment
- Maintenance contracts with client self-booking
- Weekend safety on-call paging
- SMS notifications, compliance certificates, analytics
Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'base',
'mail',
'portal',
'website',
'sale_management',
'stock',
'repair',
'maintenance',
'fusion_tasks',
'fusion_poynt',
'fusion_authorizer_portal',
],
'data': [
# Security
'security/security.xml',
'security/ir.model.access.csv',
# Data (must load before views that reference records)
'data/ir_sequence_data.xml',
'data/ir_config_parameter_data.xml',
'data/ir_cron_data.xml',
'data/mail_activity_type_data.xml',
'data/mail_template_data.xml',
'data/repair_product_category_data.xml',
'data/intake_template_data.xml',
'data/self_check_data.xml',
'data/emergency_charge_data.xml',
'data/callout_rate_data.xml',
'data/delivery_charge_data.xml',
# Views
'views/repair_product_category_views.xml',
'views/intake_template_views.xml',
'views/service_catalog_views.xml',
'views/repair_warranty_views.xml',
'views/maintenance_contract_views.xml',
'views/repair_dashboard_views.xml',
'views/repair_emergency_charge_views.xml',
'views/repair_inspection_views.xml',
'views/repair_callout_rate_views.xml',
'views/repair_delivery_charge_views.xml',
'views/repair_labor_warranty_views.xml',
'views/repair_order_views.xml',
'views/repair_part_order_views.xml',
'views/repair_service_plan_views.xml',
'views/sale_order_views.xml',
'views/technician_task_views.xml',
'views/res_partner_views.xml',
'views/res_users_views.xml',
'views/res_config_settings_views.xml',
# Portal templates
'views/portal_sales_rep_templates.xml',
'views/portal_client_repair_templates.xml',
'views/portal_maintenance_templates.xml',
# Wizards
'wizard/repair_intake_wizard_views.xml',
'wizard/repair_visit_report_wizard_views.xml',
'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',
],
'assets': {
'web.assets_backend': [
# Tokens MUST load first - dashboard.scss references its variables.
'fusion_repairs/static/src/scss/_fr_tokens.scss',
'fusion_repairs/static/src/scss/dashboard.scss',
'fusion_repairs/static/src/components/dashboard/dashboard.js',
'fusion_repairs/static/src/components/dashboard/dashboard.xml',
],
'web.assets_frontend': [
'fusion_repairs/static/src/scss/portal_repair_mobile.scss',
'fusion_repairs/static/src/scss/portal_client_repair.scss',
'fusion_repairs/static/src/js/portal_repair_intake.js',
'fusion_repairs/static/src/js/portal_client_repair.js',
],
},
'images': ['static/description/icon.png'],
'installable': True,
'application': True,
'auto_install': False,
}

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import portal_sales_rep_repair
from . import portal_client_repair
from . import portal_maintenance_booking

View File

@@ -0,0 +1,372 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Public client self-service portal at /repair.
Phase 1 scope (no AI yet):
- /repair Landing page with "Start" CTA
- /repair/new Multi-step form
- /repair/submit POST -> creates repair.order via shared intake service
- /repair/thanks Confirmation with reference
- /repair/lookup_phone jsonrpc safe partner match (masked PII)
Security:
- Public auth (no login) - the voicemail prompts mention this URL
- Per-IP rate limit on submit (configurable)
- Honeypot + CSRF
- Phone lookup returns ONLY masked name + address slice (never other PII)
- Records created via sudo in the controller; record rules don't apply
because anonymous users don't have a session
Phase 2+ will add: AI self-check, upsell engine, smart SMS verify,
safety on-call paging, reCAPTCHA v3.
"""
import base64
import hashlib
import logging
import re
import time
from odoo import SUPERUSER_ID, http, fields
from odoo.http import request
from odoo.tools import email_normalize
_logger = logging.getLogger(__name__)
# In-memory rate-limit window per worker. Good enough for Phase 1
# and matches the project's "no extra infra" goal. Resets on restart.
_RATE_LIMIT_BUCKET = {}
def _now_hour_bucket():
return int(time.time() // 3600)
def _mask_partner_for_lookup(partner):
"""Return ONLY safe summary fields - never the full partner record."""
name = partner.name or ""
# First name + last initial; never reveal full surname.
if " " in name:
first, last = name.split(" ", 1)
safe_name = f"{first} {(last or ' ')[:1]}."
else:
safe_name = name
return {
"matched": True,
"name": safe_name,
"city": partner.city or "",
}
def _e164_clean(phone):
if not phone:
return ""
return re.sub(r"[^\d+]", "", phone)[-12:]
class ClientRepairPortal(http.Controller):
# ------------------------------------------------------------------
# RATE LIMIT (scoped per endpoint so /repair/self_check and
# /repair/submit and /repair/lookup_phone don't share one bucket).
# ------------------------------------------------------------------
def _check_rate_limit(self, scope="submit"):
ICP = request.env["ir.config_parameter"].sudo()
# Scope-specific cap if configured, falls back to the global.
try:
limit = int(ICP.get_param(
f"fusion_repairs.client_portal_rate_limit_per_hour_{scope}",
ICP.get_param("fusion_repairs.client_portal_rate_limit_per_hour", "10"),
))
except (ValueError, TypeError):
limit = 10
ip = (
request.httprequest.headers.get("X-Forwarded-For")
or request.httprequest.remote_addr
or "unknown"
)
ip = ip.split(",")[0].strip()
bucket = _now_hour_bucket()
key = f"{scope}:{ip}:{bucket}"
# Prune old buckets across all scopes (cheap - dict is small).
suffix = f":{bucket}"
for k in list(_RATE_LIMIT_BUCKET.keys()):
if not k.endswith(suffix):
_RATE_LIMIT_BUCKET.pop(k, None)
if _RATE_LIMIT_BUCKET.get(key, 0) >= limit:
return True # blocked
_RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1
return False
# ------------------------------------------------------------------
# LANDING
# ------------------------------------------------------------------
@http.route("/repair", type="http", auth="public", website=True, sitemap=True)
def repair_landing(self, sn=None, **kw):
serial_info = self._resolve_serial_info((sn or "").strip())
# Preserve the ?sn= in the CTA so the form gets it too.
form_url = "/repair/new" + (f"?sn={sn}" if sn else "")
return request.render("fusion_repairs.portal_client_repair_landing", {
"page_name": "client_repair_landing",
"serial_info": serial_info,
"form_url": form_url,
})
@http.route("/repair/new", type="http", auth="public", website=True,
sitemap=False)
def repair_new(self, sn=None, **kw):
categories = request.env["fusion.repair.product.category"].sudo().search([
("active", "=", True),
], order="sequence, name")
serial_info = self._resolve_serial_info((sn or "").strip())
return request.render("fusion_repairs.portal_client_repair_form", {
"page_name": "client_repair_new",
"categories": categories,
"serial_info": serial_info,
"error": kw.get("error"),
})
# ------------------------------------------------------------------
# B4: resolve ?sn=<serial> from a QR sticker scan
# ------------------------------------------------------------------
def _resolve_serial_info(self, serial):
if not serial:
return None
Lot = request.env["stock.lot"].sudo()
lot = Lot.search([("name", "=", serial)], limit=1)
if not lot:
return None
product = lot.product_id
category = product.product_tmpl_id.x_fc_repair_category_id
return {
"serial": lot.name,
"lot_id": lot.id,
"product_id": product.id,
"product_name": product.display_name,
"category_id": category.id if category else False,
}
# ------------------------------------------------------------------
# PARTNER LOOKUP (rate-limited, audited)
# The client is identifying themselves with a phone they own. We return
# enough info to pre-fill the form (name, email, street, city) plus the
# partner_id so submit can re-use the existing record instead of creating
# a duplicate. Privacy guard: rate-limited to 10/hr per IP; every match
# is logged at INFO level so abuse leaves a trail.
# ------------------------------------------------------------------
@http.route("/repair/lookup_phone", type="jsonrpc", auth="public",
website=True)
def repair_lookup_phone(self, phone=None, **kw):
if self._check_rate_limit(scope="lookup"):
return {"error": "rate_limited"}
cleaned = _e164_clean(phone)
if len(cleaned) < 7:
return {"matched": False, "partners": []}
matches = request.env["res.partner"].sudo().search([
"|",
("phone", "ilike", cleaned[-7:]),
("phone_sanitized", "ilike", cleaned[-7:]),
], limit=3) # cap at 3 - real households rarely have more
if not matches:
return {"matched": False, "partners": []}
_logger.info(
"Portal phone lookup matched %d partner(s) for last7=%s from IP=%s",
len(matches), cleaned[-7:], request.httprequest.remote_addr,
)
return {
"matched": True,
"partners": [{
"id": p.id,
"name": p.name or "",
"email": p.email or "",
"street": p.street or "",
"city": p.city or "",
} for p in matches],
}
# ------------------------------------------------------------------
# SUBMIT
# ------------------------------------------------------------------
@http.route("/repair/submit", type="http", auth="public", methods=["POST"],
csrf=True, website=True)
def repair_submit(self, **post):
# Honeypot - bots tend to fill every visible field.
if (post.get("hp_company") or "").strip():
_logger.info("Client portal submit blocked by honeypot from IP=%s",
request.httprequest.remote_addr)
return request.redirect("/repair/new?error=spam")
if self._check_rate_limit(scope="submit"):
return request.redirect("/repair/new?error=rate_limited")
# Required fields.
partner_name = (post.get("client_name") or "").strip()
phone = (post.get("client_phone") or "").strip()
issue_summary = (post.get("issue_summary") or "").strip()
category_id = int(post.get("category_id") or 0)
if not (partner_name and phone and issue_summary and category_id):
return request.redirect("/repair/new?error=missing")
# Validate email if provided. Empty is allowed; malformed redirects back.
raw_email = (post.get("client_email") or "").strip()
clean_email = email_normalize(raw_email) if raw_email else False
if raw_email and not clean_email:
return request.redirect("/repair/new?error=email")
# B3: trust the explicit known_partner_id from the lookup widget when
# present (client identified themselves via the lookup widget on this
# very page). Otherwise re-match by phone, otherwise create.
partner = False
try:
known_id = int(post.get("known_partner_id") or 0)
except (ValueError, TypeError):
known_id = 0
if known_id:
partner = request.env["res.partner"].sudo().browse(known_id).exists()
cleaned_phone = _e164_clean(phone)
if not partner and len(cleaned_phone) >= 7:
partner = request.env["res.partner"].sudo().search([
"|",
("phone", "ilike", cleaned_phone[-7:]),
("phone_sanitized", "ilike", cleaned_phone[-7:]),
], limit=1)
partner_vals = None
if not partner:
partner_vals = {
"name": partner_name,
"phone": phone,
"email": clean_email or False,
"street": (post.get("client_street") or "").strip(),
"city": (post.get("client_city") or "").strip(),
}
# Stage uploaded photos.
files = request.httprequest.files.getlist("photos")
attachment_ids = []
for f in files or []:
if not getattr(f, "filename", None):
continue
data = f.read()
if not data:
continue
attachment_ids.append(request.env["ir.attachment"].sudo().create({
"name": f.filename,
"datas": base64.b64encode(data),
"res_model": "fusion.repair.intake.session",
"res_id": 0,
}).id)
# B4: resolve ?sn= QR scan -> attach the lot to the repair
serial_info = self._resolve_serial_info((post.get("serial_number") or "").strip())
equipment = {
"repair_category_id": category_id,
"third_party": post.get("third_party") in ("on", "true", "1"),
"urgency": post.get("urgency") or "normal",
"issue_summary": issue_summary,
"internal_notes": (post.get("internal_notes") or "").strip(),
"photo_attachment_ids": attachment_ids,
}
if serial_info:
equipment["lot_id"] = serial_info["lot_id"]
# If client didn't override category, use what the QR identified.
if not category_id and serial_info.get("category_id"):
equipment["repair_category_id"] = serial_info["category_id"]
# Pick a real human owner for the repair so emails go from a person:
# admin if present, else the lowest-id non-share user, else SUPERUSER_ID.
admin = request.env.ref("base.user_admin", raise_if_not_found=False)
if admin:
intake_uid = admin.id
else:
internal = request.env["res.users"].sudo().search(
[("share", "=", False)], order="id asc", limit=1,
)
intake_uid = internal.id if internal else SUPERUSER_ID
payload = {
"partner_id": partner.id if partner else None,
"partner_vals": partner_vals,
"intake_user_id": intake_uid,
"equipment_items": [equipment],
}
try:
repairs = request.env["fusion.repair.intake.service"].sudo() \
.create_repair_orders(payload, source="client_portal")
except Exception:
_logger.exception("Client portal repair submit failed")
return request.redirect("/repair/new?error=server")
token = hashlib.sha256(
f"{repairs[0].id}:{repairs[0].create_date}".encode()
).hexdigest()[:16]
return request.redirect(f"/repair/thanks?ref={repairs[0].name}&t={token}")
@http.route("/repair/thanks", type="http", auth="public", website=True,
sitemap=False)
def repair_thanks(self, ref=None, t=None, **kw):
return request.render("fusion_repairs.portal_client_repair_thanks", {
"page_name": "client_repair_thanks",
"ref": ref or "",
})
# ------------------------------------------------------------------
# CL6 / CL7: AI self-check JSONRPC endpoint
# ------------------------------------------------------------------
@http.route("/repair/self_check", type="jsonrpc", auth="public",
website=True)
def repair_self_check(self, category_id=None, symptoms=None,
urgency=None, **kw):
if self._check_rate_limit(scope="self_check"):
return {"error": "rate_limited"}
if not symptoms:
symptoms = []
if isinstance(symptoms, str):
symptoms = [symptoms]
# Defensive: cap input size to defend against prompt-injection bloat
symptoms = [str(s)[:500] for s in symptoms[:5]]
Service = request.env["fusion.repair.ai.service"].sudo()
return Service.suggest_self_check(
product_category_id=int(category_id or 0) or None,
symptoms=symptoms,
urgency=urgency or None,
)
# ------------------------------------------------------------------
# CL15: on-call acknowledgement endpoint
# Only the paged user OR a Repairs Manager can ack - prevents arbitrary
# internal users (or someone with a forwarded mail) from acknowledging
# a page they were never paged for.
# ------------------------------------------------------------------
@http.route("/repair/on-call/ack/<string:token>", type="http",
auth="user", website=True, sitemap=False)
def repair_on_call_ack(self, token, **kw):
Repair = request.env["repair.order"].sudo()
repair = Repair.search([("x_fc_on_call_token", "=", token)], limit=1)
if not repair:
return request.render(
"fusion_repairs.portal_on_call_ack_invalid", {},
)
user = request.env.user
is_paged_user = user == repair.x_fc_on_call_paged_user_id
is_manager = user.has_group("fusion_repairs.group_fusion_repairs_manager")
if not (is_paged_user or is_manager):
_logger.warning(
"On-call ack denied for repair %s - user %s is not the paged "
"user (%s) and not a Repairs Manager.",
repair.name, user.login,
repair.x_fc_on_call_paged_user_id.login or "(none)",
)
return request.render(
"fusion_repairs.portal_on_call_ack_invalid", {},
)
Service = request.env["fusion.repair.on.call.service"].sudo()
Service.acknowledge(repair, user)
return request.render("fusion_repairs.portal_on_call_ack_ok", {
"repair_name": repair.name,
})

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Client maintenance booking portal.
The maintenance reminder email contains a tokenized URL:
/repairs/maintenance/book/<token>
Clicking it lands the client on a single-page form where they can confirm
a preferred date. On submit, a repair.order is spawned via the same
intake service (source='client_portal') and the contract's next reminder
band is locked so we don't keep nagging them.
"""
import logging
from odoo import _, fields, http
from odoo.http import request
_logger = logging.getLogger(__name__)
class MaintenanceBookingPortal(http.Controller):
def _resolve_contract(self, token):
if not token:
return None
Contract = request.env['fusion.repair.maintenance.contract'].sudo()
contract = Contract.search([('booking_token', '=', token)], limit=1)
if not contract or contract.state != 'active':
return None
return contract
@http.route('/repairs/maintenance/book/<string:token>', type='http',
auth='public', website=True, sitemap=False)
def maintenance_book_get(self, token, **kw):
contract = self._resolve_contract(token)
if not contract:
return request.render('fusion_repairs.portal_maintenance_invalid_token', {})
already = bool(contract.booking_repair_id)
return request.render('fusion_repairs.portal_maintenance_book', {
'contract': contract,
'already_booked': already,
'default_date': fields.Date.context_today(request.env.user).isoformat(),
})
@http.route('/repairs/maintenance/book/<string:token>/confirm', type='http',
auth='public', methods=['POST'], csrf=True, website=True)
def maintenance_book_post(self, token, **post):
contract = self._resolve_contract(token)
if not contract:
return request.render('fusion_repairs.portal_maintenance_invalid_token', {})
if contract.booking_repair_id:
return request.redirect(f'/repairs/maintenance/book/{token}?ok=already')
preferred_date = (post.get('preferred_date') or '').strip()
scheduled = False
if preferred_date:
try:
scheduled = fields.Date.from_string(preferred_date)
except ValueError:
scheduled = False
repair = contract.create_repair_from_booking(scheduled_date=scheduled)
return request.render('fusion_repairs.portal_maintenance_thanks', {
'contract': contract,
'repair': repair,
})

View File

@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Sales rep portal for repair intake.
Sales reps marked `is_sales_rep_portal` on their partner can:
- /my/repair/new - submit a new service call from their phone
- /my/repairs - list of repairs they have submitted
- /my/repair/<id> - read-only detail with status timeline
All routes are gated by the is_sales_rep_portal flag and use the SAME
shared intake service (`fusion.repair.intake.service`) as the backend
wizard - so behaviour stays consistent across surfaces.
"""
import base64
import logging
from odoo import http, fields
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
_logger = logging.getLogger(__name__)
class SalesRepRepairPortal(CustomerPortal):
# ------------------------------------------------------------------
# ACCESS GATE
# ------------------------------------------------------------------
def _check_sales_rep_access(self):
partner = request.env.user.partner_id
if not getattr(partner, 'is_sales_rep_portal', False):
return request.redirect('/my')
return None
def _staged_attachment_ids_from_files(self, files):
"""Stage uploaded files as ir.attachment records and return their IDs."""
ids = []
for f in files or []:
if not getattr(f, 'filename', None):
continue
data = f.read()
if not data:
continue
attachment = request.env['ir.attachment'].sudo().create({
'name': f.filename,
'datas': base64.b64encode(data),
'res_model': 'fusion.repair.intake.session',
'res_id': 0,
})
ids.append(attachment.id)
return ids
# ------------------------------------------------------------------
# NEW SERVICE CALL FORM
# ------------------------------------------------------------------
@http.route('/my/repair/new', type='http', auth='user', website=True, sitemap=False)
def portal_repair_new(self, **kw):
gate = self._check_sales_rep_access()
if gate:
return gate
categories = request.env['fusion.repair.product.category'].sudo().search([
('active', '=', True),
], order='sequence, name')
return request.render('fusion_repairs.portal_sales_rep_repair_form', {
'page_name': 'repair_new',
'categories': categories,
'default_partner': False,
'submitted': False,
})
@http.route('/my/repair/lookup_partner', type='jsonrpc', auth='user', website=True)
def portal_repair_lookup_partner(self, query=None, **kw):
gate = self._check_sales_rep_access()
if gate:
return {'error': 'access'}
if not query or len(query) < 3:
return {'matches': []}
Partner = request.env['res.partner'].sudo()
matches = Partner.search([
'|', '|',
('name', 'ilike', query),
('phone', 'ilike', query),
('email', 'ilike', query),
], limit=8)
return {
'matches': [{
'id': p.id,
'name': p.name or '',
'phone': p.phone or '',
'email': p.email or '',
'street': p.street or '',
'city': p.city or '',
'repair_count': p.x_fc_repair_count,
} for p in matches],
}
@http.route('/my/repair/submit', type='http', auth='user', methods=['POST'],
csrf=True, website=True)
def portal_repair_submit(self, **post):
gate = self._check_sales_rep_access()
if gate:
return gate
partner_id = int(post.get('partner_id') or 0)
if not partner_id:
return request.redirect('/my/repair/new?error=partner')
# Build single-equipment payload from the form. Multi-equipment loop
# is supported by adding more equipment_* groups in Phase 2.
files = request.httprequest.files.getlist('photos')
attachment_ids = self._staged_attachment_ids_from_files(files)
equipment = {
'repair_category_id': int(post.get('category_id') or 0) or False,
'product_id': int(post.get('product_id') or 0) or False,
'third_party': post.get('third_party') in ('on', 'true', '1'),
'urgency': post.get('urgency') or 'normal',
'issue_summary': (post.get('issue_summary') or '').strip(),
'issue_category': (post.get('issue_category') or '').strip(),
'internal_notes': (post.get('internal_notes') or '').strip(),
'photo_attachment_ids': attachment_ids,
}
payload = {
'partner_id': partner_id,
'intake_user_id': request.env.uid,
'equipment_items': [equipment],
}
try:
repairs = request.env['fusion.repair.intake.service'].sudo() \
.create_repair_orders(payload, source='sales_rep_portal')
except Exception:
_logger.exception('Sales rep portal repair submit failed')
return request.redirect('/my/repair/new?error=server')
return request.redirect('/my/repair/%d?thanks=1' % repairs[0].id)
# ------------------------------------------------------------------
# MY REPAIRS LIST + DETAIL
# ------------------------------------------------------------------
@http.route(['/my/repairs', '/my/repairs/page/<int:page>'], type='http',
auth='user', website=True)
def portal_repairs_list(self, page=1, **kw):
gate = self._check_sales_rep_access()
if gate:
return gate
Repair = request.env['repair.order'].sudo()
domain = [('x_fc_intake_user_id', '=', request.env.uid)]
total = Repair.search_count(domain)
page_size = 20
offset = (page - 1) * page_size
repairs = Repair.search(domain, order='create_date desc',
limit=page_size, offset=offset)
return request.render('fusion_repairs.portal_sales_rep_repair_list', {
'page_name': 'repairs_list',
'repairs': repairs,
'total': total,
'page': page,
'page_size': page_size,
})
@http.route('/my/repair/<int:repair_id>', type='http', auth='user',
website=True)
def portal_repair_detail(self, repair_id, thanks=None, **kw):
gate = self._check_sales_rep_access()
if gate:
return gate
repair = request.env['repair.order'].sudo().browse(repair_id).exists()
if not repair or repair.x_fc_intake_user_id.id != request.env.uid:
return request.redirect('/my/repairs')
return request.render('fusion_repairs.portal_sales_rep_repair_detail', {
'page_name': 'repair_detail',
'repair': repair,
'thanks': bool(thanks),
})

View File

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Westin Healthcare published rate card (from the official client-facing
service-rates flyer / QR card). noupdate=1 so site admin tweaks survive
module upgrade.
STANDARD SERVICE:
Service Calls (includes 30 min labour) ........ $95
Hourly Labour Rate (on-site, per tech) ........ $85
In-Shop Labour Rate (per tech) ................ $75
Rush Service Call ............................. $120 + $0.70/km x 2-way
After Hours Service Call ...................... $140 + $0.70/km x 2-way
LIFT & ELEVATING SERVICE (stairlifts, porch lifts, lift chairs):
Service Calls (includes 30 min labour) ........ $160
Hourly Labour Rate (on-site, per tech) ........ $110
In-Shop Labour Rate (per tech) ................ $110
(Rush / After-hours / Weekend tiers follow the same multipliers as
Standard, applied to the higher base rate.)
Travel: $0.70 per km past 25 km, BOTH WAYS, per technician.
Footnote 2: "If multiple technicians are required, rates will apply per technician."
-->
<odoo>
<data noupdate="1">
<!-- ============== STANDARD SERVICE TIER ROWS ================== -->
<record id="callout_rate_regular" model="fusion.repair.callout.rate">
<field name="tier">regular</field>
<field name="equipment_class">standard</field>
<field name="base_callout_fee">95.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">85.00</field>
<field name="in_shop_labor_rate">75.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">STANDARD - regular business hours. Service Call ($95) includes the first 30 min of labour. Hourly Rate ($85/h on-site, $75/h in-shop) applies past 30 min, per tech, pro-rated in 30-min increments with a 1-hour minimum.</field>
</record>
<record id="callout_rate_rush" model="fusion.repair.callout.rate">
<field name="tier">rush</field>
<field name="equipment_class">standard</field>
<field name="base_callout_fee">120.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">85.00</field>
<field name="in_shop_labor_rate">75.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">STANDARD - rush. $120 plus $0.70 per km (2-way, past 25 km).</field>
</record>
<record id="callout_rate_after_hours" model="fusion.repair.callout.rate">
<field name="tier">after_hours</field>
<field name="equipment_class">standard</field>
<field name="base_callout_fee">140.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">85.00</field>
<field name="in_shop_labor_rate">75.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">STANDARD - after-hours (weekday evenings). $140 plus $0.70 per km (2-way, past 25 km).</field>
</record>
<record id="callout_rate_weekend" model="fusion.repair.callout.rate">
<field name="tier">weekend</field>
<field name="equipment_class">standard</field>
<field name="base_callout_fee">180.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">85.00</field>
<field name="in_shop_labor_rate">75.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">STANDARD - weekend (extension of published card). $180 callout.</field>
</record>
<record id="callout_rate_holiday" model="fusion.repair.callout.rate">
<field name="tier">holiday</field>
<field name="equipment_class">standard</field>
<field name="base_callout_fee">220.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">85.00</field>
<field name="in_shop_labor_rate">75.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">STANDARD - statutory holiday (extension of published card). $220 callout.</field>
</record>
<!-- ============== LIFT & ELEVATING SERVICE TIER ROWS ========== -->
<record id="callout_rate_regular_lift" model="fusion.repair.callout.rate">
<field name="tier">regular</field>
<field name="equipment_class">lift_elevating</field>
<field name="base_callout_fee">160.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">110.00</field>
<field name="in_shop_labor_rate">110.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">LIFT &amp; ELEVATING SERVICE - regular business hours. $160 callout includes 30 min. $110/h labour past 30 min, per tech.</field>
</record>
<record id="callout_rate_rush_lift" model="fusion.repair.callout.rate">
<field name="tier">rush</field>
<field name="equipment_class">lift_elevating</field>
<field name="base_callout_fee">200.00</field>
<field name="hourly_labor_rate">110.00</field>
<field name="in_shop_labor_rate">110.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">LIFT &amp; ELEVATING - rush. $200 callout plus $0.70/km (2-way, past 25 km).</field>
</record>
<record id="callout_rate_after_hours_lift" model="fusion.repair.callout.rate">
<field name="tier">after_hours</field>
<field name="equipment_class">lift_elevating</field>
<field name="base_callout_fee">240.00</field>
<field name="hourly_labor_rate">110.00</field>
<field name="in_shop_labor_rate">110.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">LIFT &amp; ELEVATING - after-hours. $240 callout plus $0.70/km (2-way, past 25 km).</field>
</record>
<record id="callout_rate_weekend_lift" model="fusion.repair.callout.rate">
<field name="tier">weekend</field>
<field name="equipment_class">lift_elevating</field>
<field name="base_callout_fee">300.00</field>
<field name="hourly_labor_rate">110.00</field>
<field name="in_shop_labor_rate">110.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">LIFT &amp; ELEVATING - weekend. $300 callout.</field>
</record>
<record id="callout_rate_holiday_lift" model="fusion.repair.callout.rate">
<field name="tier">holiday</field>
<field name="equipment_class">lift_elevating</field>
<field name="base_callout_fee">360.00</field>
<field name="hourly_labor_rate">110.00</field>
<field name="in_shop_labor_rate">110.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">LIFT &amp; ELEVATING - statutory holiday. $360 callout.</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Westin Healthcare DELIVERY / PICKUP CHARGES from the published rate card.
Local Service Area (within Brampton) .................. $35
Outside Local Area .................................... $60
Rush Pickups / Delivery ............................... $60 + $0.70/km x 2-way
Lift Chair Delivery and Set-Up ........................ $120
Hospital Bed Delivery and Set-Up ...................... $120
Stairlift Delivery and Set-Up ......................... $300
Stairlift Removal ..................................... $300
Footnote 3: "Westin Healthcare Delivery includes the drop-off of any
product or material to a client's home office, facility or
predetermined location by one of the staff members. This includes
the return of equipment post-repair."
-->
<odoo>
<data noupdate="1">
<record id="delivery_local" model="fusion.repair.delivery.charge">
<field name="charge_type">local</field>
<field name="amount">35.00</field>
<field name="sequence">10</field>
<field name="description">Within the Brampton service area.</field>
</record>
<record id="delivery_outside" model="fusion.repair.delivery.charge">
<field name="charge_type">outside</field>
<field name="amount">60.00</field>
<field name="sequence">20</field>
<field name="description">Outside the local service area (per the published card).</field>
</record>
<record id="delivery_rush" model="fusion.repair.delivery.charge">
<field name="charge_type">rush</field>
<field name="amount">60.00</field>
<field name="travel_per_km_fee">0.70</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="sequence">30</field>
<field name="description">Rush pickup or delivery. $60 plus $0.70 per km, both ways, past 25 km.</field>
</record>
<record id="delivery_lift_chair" model="fusion.repair.delivery.charge">
<field name="charge_type">lift_chair_install</field>
<field name="amount">120.00</field>
<field name="sequence">40</field>
<field name="description">Lift Chair delivery and on-site set-up.</field>
</record>
<record id="delivery_hospital_bed" model="fusion.repair.delivery.charge">
<field name="charge_type">hospital_bed_install</field>
<field name="amount">120.00</field>
<field name="sequence">50</field>
<field name="description">Hospital Bed delivery and on-site assembly / set-up.</field>
</record>
<record id="delivery_stairlift_install" model="fusion.repair.delivery.charge">
<field name="charge_type">stairlift_install</field>
<field name="amount">300.00</field>
<field name="sequence">60</field>
<field name="description">Stairlift delivery and full set-up at client home.</field>
</record>
<record id="delivery_stairlift_removal" model="fusion.repair.delivery.charge">
<field name="charge_type">stairlift_removal</field>
<field name="amount">300.00</field>
<field name="sequence">70</field>
<field name="description">Removal of an old stairlift from client home.</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Default rush / emergency rate card. Tuned for medical equipment in Ontario.
Office can edit these in Configuration -> Emergency Surcharges. noupdate=1
so admin tweaks survive module upgrades.
-->
<odoo>
<data noupdate="1">
<!-- Stairlift: high-risk-to-be-stranded equipment, top rates. -->
<record id="emerg_stairlift_same_day" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_stairlift"/>
<field name="tier">same_day</field>
<field name="base_amount">250.00</field>
<field name="per_tech_multiplier">0.5</field>
<field name="description">Same-day stairlift dispatch. Squeezed into today's route.</field>
</record>
<record id="emerg_stairlift_after_hours" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_stairlift"/>
<field name="tier">after_hours</field>
<field name="base_amount">350.00</field>
<field name="per_tech_multiplier">0.5</field>
</record>
<record id="emerg_stairlift_weekend" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_stairlift"/>
<field name="tier">weekend</field>
<field name="base_amount">450.00</field>
<field name="per_tech_multiplier">0.5</field>
</record>
<!-- Porch lift -->
<record id="emerg_porch_same_day" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_porch_lift"/>
<field name="tier">same_day</field>
<field name="base_amount">300.00</field>
<field name="per_tech_multiplier">0.5</field>
</record>
<record id="emerg_porch_weekend" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_porch_lift"/>
<field name="tier">weekend</field>
<field name="base_amount">500.00</field>
<field name="per_tech_multiplier">0.5</field>
</record>
<!-- Hospital bed -->
<record id="emerg_bed_same_day" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_hospital_bed"/>
<field name="tier">same_day</field>
<field name="base_amount">175.00</field>
<field name="per_tech_multiplier">0.6</field>
<field name="description">Bed lifts often need 2 techs (one to hold, one to wrench).</field>
</record>
<record id="emerg_bed_after_hours" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_hospital_bed"/>
<field name="tier">after_hours</field>
<field name="base_amount">275.00</field>
<field name="per_tech_multiplier">0.6</field>
</record>
<!-- Power wheelchair -->
<record id="emerg_powerchair_same_day" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_wheelchair_power"/>
<field name="tier">same_day</field>
<field name="base_amount">200.00</field>
<field name="per_tech_multiplier">0.5</field>
</record>
<!-- Manual wheelchair -->
<record id="emerg_wheelchair_same_day" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_wheelchair_manual"/>
<field name="tier">same_day</field>
<field name="base_amount">120.00</field>
<field name="per_tech_multiplier">0.5</field>
</record>
<!-- Mattress (pump usually) -->
<record id="emerg_mattress_same_day" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_mattress"/>
<field name="tier">same_day</field>
<field name="base_amount">150.00</field>
<field name="per_tech_multiplier">0.5</field>
</record>
<record id="emerg_mattress_weekend" model="fusion.repair.emergency.charge">
<field name="category_id" ref="category_mattress"/>
<field name="tier">weekend</field>
<field name="base_amount">275.00</field>
<field name="per_tech_multiplier">0.5</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,378 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Seed intake templates - one per major medical equipment category.
Question banks based on the design spec Section "Configurable intake".
All templates noupdate=1 so customers can customise without losing data on upgrade.
-->
<odoo>
<data noupdate="1">
<!-- ============================================================== -->
<!-- DEFAULT (fallback) - applies to any category without override -->
<!-- ============================================================== -->
<record id="intake_template_default" model="fusion.repair.intake.template">
<field name="name">Default - General Intake</field>
<field name="code">default</field>
<field name="sequence">1</field>
<field name="is_default" eval="True"/>
<field name="description"><![CDATA[<p>Generic question set used when no equipment-specific template is configured.</p>]]></field>
</record>
<record id="q_default_caller_relationship" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">10</field>
<field name="name">Who is calling? (self / family / caregiver / other)</field>
<field name="code">caller_relationship</field>
<field name="question_type">char</field>
<field name="required" eval="True"/>
</record>
<record id="q_default_address_match" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">20</field>
<field name="name">Is the service address the same as the contact address on file?</field>
<field name="code">address_match</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_default_purchased_from_us" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">30</field>
<field name="name">Was this equipment purchased from us?</field>
<field name="code">purchased_from_us</field>
<field name="question_type">boolean</field>
</record>
<record id="q_default_purchase_date" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">40</field>
<field name="name">Approximate purchase date (if known)</field>
<field name="code">purchase_date</field>
<field name="question_type">date</field>
</record>
<record id="q_default_issue_summary" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">50</field>
<field name="name">Describe the issue in your own words</field>
<field name="code">issue_summary</field>
<field name="question_type">text</field>
<field name="required" eval="True"/>
</record>
<record id="q_default_safety_concern" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">60</field>
<field name="name">Does this issue affect anyone's safety right now?</field>
<field name="code">safety_concern</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_default_access_notes" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">70</field>
<field name="name">Anything the technician should know about access? (stairs, parking, gate code, pet)</field>
<field name="code">access_notes</field>
<field name="question_type">text</field>
<field name="help_text">e.g. "dog in front yard, use side gate"</field>
</record>
<!-- ============================================================== -->
<!-- HOSPITAL BED -->
<!-- ============================================================== -->
<record id="intake_template_hospital_bed" model="fusion.repair.intake.template">
<field name="name">Hospital Bed - Intake</field>
<field name="code">hospital_bed</field>
<field name="sequence">10</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_hospital_bed')])]"/>
</record>
<record id="q_bed_powered" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_hospital_bed"/>
<field name="sequence">10</field>
<field name="name">Is the bed plugged in and does it power on?</field>
<field name="code">powered</field>
<field name="question_type">selection</field>
<field name="selection_options">Yes - powers on normally
No - no lights/sound at all
Powers on but won't move</field>
<field name="required" eval="True"/>
</record>
<record id="q_bed_remote_works" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_hospital_bed"/>
<field name="sequence">20</field>
<field name="name">Does the remote control respond when buttons are pressed?</field>
<field name="code">remote_works</field>
<field name="question_type">boolean</field>
</record>
<record id="q_bed_motor_side" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_hospital_bed"/>
<field name="sequence">30</field>
<field name="name">Which motor seems affected? (head, foot, height, all)</field>
<field name="code">motor_side</field>
<field name="question_type">char</field>
<field name="symptom_keywords">motor</field>
</record>
<record id="q_bed_rails" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_hospital_bed"/>
<field name="sequence">40</field>
<field name="name">Are the side rails functioning normally?</field>
<field name="code">rails_ok</field>
<field name="question_type">boolean</field>
<field name="symptom_keywords">rail,side</field>
</record>
<record id="q_bed_mattress" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_hospital_bed"/>
<field name="sequence">50</field>
<field name="name">Is the mattress included in this issue?</field>
<field name="code">mattress_involved</field>
<field name="question_type">boolean</field>
</record>
<!-- ============================================================== -->
<!-- STAIRLIFT -->
<!-- ============================================================== -->
<record id="intake_template_stairlift" model="fusion.repair.intake.template">
<field name="name">Stairlift - Intake</field>
<field name="code">stairlift</field>
<field name="sequence">20</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_stairlift')])]"/>
</record>
<record id="q_stairlift_powered" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_stairlift"/>
<field name="sequence">10</field>
<field name="name">Does the stairlift power on? (any lights, beeps)</field>
<field name="code">powered</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_stairlift_error_code" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_stairlift"/>
<field name="sequence">20</field>
<field name="name">Is there an error code displayed? (note the number/letter shown)</field>
<field name="code">error_code</field>
<field name="question_type">char</field>
<field name="symptom_keywords">error code</field>
</record>
<record id="q_stairlift_stuck_position" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_stairlift"/>
<field name="sequence">30</field>
<field name="name">Is anyone currently stuck on the stairlift?</field>
<field name="code">person_stuck</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
<field name="help_text">If yes, this is a safety issue - escalate immediately.</field>
</record>
<record id="q_stairlift_stops_midway" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_stairlift"/>
<field name="sequence">40</field>
<field name="name">Does it stop partway up or down the track?</field>
<field name="code">stops_midway</field>
<field name="question_type">boolean</field>
<field name="symptom_keywords">stops midway</field>
</record>
<record id="q_stairlift_burning" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_stairlift"/>
<field name="sequence">50</field>
<field name="name">Any burning smell, smoke, or unusual noise?</field>
<field name="code">burning_smell</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
<field name="symptom_keywords">burning smell,smoke</field>
</record>
<!-- ============================================================== -->
<!-- PORCH LIFT -->
<!-- ============================================================== -->
<record id="intake_template_porch_lift" model="fusion.repair.intake.template">
<field name="name">Porch Lift - Intake</field>
<field name="code">porch_lift</field>
<field name="sequence">30</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_porch_lift')])]"/>
</record>
<record id="q_porch_powered" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_porch_lift"/>
<field name="sequence">10</field>
<field name="name">Does the lift respond when you press the call/send button?</field>
<field name="code">powered</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_porch_gate_switches" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_porch_lift"/>
<field name="sequence">20</field>
<field name="name">Are all gate and door safety switches fully closed?</field>
<field name="code">gate_switches</field>
<field name="question_type">boolean</field>
</record>
<record id="q_porch_stuck" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_porch_lift"/>
<field name="sequence">30</field>
<field name="name">Is anyone currently stuck on the lift?</field>
<field name="code">person_stuck</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_porch_outdoor" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_porch_lift"/>
<field name="sequence">40</field>
<field name="name">Is the lift outdoors exposed to weather?</field>
<field name="code">outdoor</field>
<field name="question_type">boolean</field>
</record>
<!-- ============================================================== -->
<!-- WHEELCHAIR (MANUAL + POWER - shared template) -->
<!-- ============================================================== -->
<record id="intake_template_wheelchair" model="fusion.repair.intake.template">
<field name="name">Wheelchair - Intake</field>
<field name="code">wheelchair</field>
<field name="sequence">40</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_wheelchair_manual'), ref('category_wheelchair_power')])]"/>
</record>
<record id="q_wc_brakes" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_wheelchair"/>
<field name="sequence">10</field>
<field name="name">Do the brakes engage and hold the wheelchair?</field>
<field name="code">brakes_ok</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
<field name="symptom_keywords">brake</field>
</record>
<record id="q_wc_tires" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_wheelchair"/>
<field name="sequence">20</field>
<field name="name">Are both tires inflated and undamaged?</field>
<field name="code">tires_ok</field>
<field name="question_type">boolean</field>
</record>
<record id="q_wc_frame" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_wheelchair"/>
<field name="sequence">30</field>
<field name="name">Is there any visible damage to the frame or footrests?</field>
<field name="code">frame_damage</field>
<field name="question_type">boolean</field>
</record>
<record id="q_wc_battery" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_wheelchair"/>
<field name="sequence">40</field>
<field name="name">For power chairs: does the battery hold a charge?</field>
<field name="code">battery_holds_charge</field>
<field name="question_type">boolean</field>
<field name="symptom_keywords">battery,charge</field>
</record>
<record id="q_wc_joystick" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_wheelchair"/>
<field name="sequence">50</field>
<field name="name">For power chairs: any error code shown on the joystick display?</field>
<field name="code">joystick_error</field>
<field name="question_type">char</field>
</record>
<!-- ============================================================== -->
<!-- WALKER / ROLLATOR (shared) -->
<!-- ============================================================== -->
<record id="intake_template_walker_rollator" model="fusion.repair.intake.template">
<field name="name">Walker / Rollator - Intake</field>
<field name="code">walker_rollator</field>
<field name="sequence">50</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_walker'), ref('category_rollator')])]"/>
</record>
<record id="q_walker_wheels" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_walker_rollator"/>
<field name="sequence">10</field>
<field name="name">Do all wheels roll freely?</field>
<field name="code">wheels_roll</field>
<field name="question_type">boolean</field>
</record>
<record id="q_walker_brakes" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_walker_rollator"/>
<field name="sequence">20</field>
<field name="name">Do the brakes lock when engaged? (rollator only)</field>
<field name="code">brakes_lock</field>
<field name="question_type">boolean</field>
</record>
<record id="q_walker_frame" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_walker_rollator"/>
<field name="sequence">30</field>
<field name="name">Is the frame stable, with no wobble or loose parts?</field>
<field name="code">frame_stable</field>
<field name="question_type">boolean</field>
</record>
<!-- ============================================================== -->
<!-- MEDICAL MATTRESS -->
<!-- ============================================================== -->
<record id="intake_template_mattress" model="fusion.repair.intake.template">
<field name="name">Medical Mattress - Intake</field>
<field name="code">mattress</field>
<field name="sequence">60</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_mattress')])]"/>
</record>
<record id="q_mattress_pump_powered" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_mattress"/>
<field name="sequence">10</field>
<field name="name">Is the pump plugged in and showing any indicator lights?</field>
<field name="code">pump_powered</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_mattress_leak" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_mattress"/>
<field name="sequence">20</field>
<field name="name">Is the mattress leaking or losing air?</field>
<field name="code">leak</field>
<field name="question_type">boolean</field>
<field name="symptom_keywords">leak,deflate</field>
</record>
<record id="q_mattress_alarm" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_mattress"/>
<field name="sequence">30</field>
<field name="name">Is the pump showing an error code or alarm?</field>
<field name="code">alarm</field>
<field name="question_type">char</field>
</record>
<!-- ============================================================== -->
<!-- Backfill category defaults -->
<!-- ============================================================== -->
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_hospital_bed')]"/>
<value eval="{'intake_template_id': ref('intake_template_hospital_bed')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_stairlift')]"/>
<value eval="{'intake_template_id': ref('intake_template_stairlift')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_porch_lift')]"/>
<value eval="{'intake_template_id': ref('intake_template_porch_lift')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_wheelchair_manual')]"/>
<value eval="{'intake_template_id': ref('intake_template_wheelchair')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_wheelchair_power')]"/>
<value eval="{'intake_template_id': ref('intake_template_wheelchair')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_walker')]"/>
<value eval="{'intake_template_id': ref('intake_template_walker_rollator')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_rollator')]"/>
<value eval="{'intake_template_id': ref('intake_template_walker_rollator')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_mattress')]"/>
<value eval="{'intake_template_id': ref('intake_template_default')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_other')]"/>
<value eval="{'intake_template_id': ref('intake_template_default')}"/>
</function>
</data>
</odoo>

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Feature toggles -->
<record id="param_enable_email_notifications" model="ir.config_parameter">
<field name="key">fusion_repairs.enable_email_notifications</field>
<field name="value">True</field>
</record>
<!-- Outstanding balance warning threshold (CAD) - C5 -->
<record id="param_outstanding_balance_threshold" model="ir.config_parameter">
<field name="key">fusion_repairs.outstanding_balance_threshold</field>
<field name="value">100.00</field>
</record>
<!-- Duplicate-call detection window (days) - C1 -->
<record id="param_duplicate_call_window_days" model="ir.config_parameter">
<field name="key">fusion_repairs.duplicate_call_window_days</field>
<field name="value">14</field>
</record>
<!-- Pricing variance reconciliation - Phase 2 -->
<record id="param_variance_threshold_pct" model="ir.config_parameter">
<field name="key">fusion_repairs.variance_threshold_pct</field>
<field name="value">20</field>
</record>
<record id="param_variance_threshold_amount" model="ir.config_parameter">
<field name="key">fusion_repairs.variance_threshold_amount</field>
<field name="value">100.00</field>
</record>
<!-- Office follow-up cron toggles - Phase 3 -->
<record id="param_followup_maintenance_enabled" model="ir.config_parameter">
<field name="key">fusion_repairs.followup_maintenance_enabled</field>
<field name="value">True</field>
</record>
<record id="param_followup_repair_no_tech_enabled" model="ir.config_parameter">
<field name="key">fusion_repairs.followup_repair_no_tech_enabled</field>
<field name="value">True</field>
</record>
<record id="param_followup_overdue_visit_enabled" model="ir.config_parameter">
<field name="key">fusion_repairs.followup_overdue_visit_enabled</field>
<field name="value">True</field>
</record>
<record id="param_followup_unpaid_invoice_enabled" model="ir.config_parameter">
<field name="key">fusion_repairs.followup_unpaid_invoice_enabled</field>
<field name="value">True</field>
</record>
<!-- Public client portal - Phase 1+ -->
<record id="param_client_portal_url" model="ir.config_parameter">
<field name="key">fusion_repairs.client_portal_url</field>
<field name="value">/repair</field>
</record>
<record id="param_client_portal_rate_limit_per_hour" model="ir.config_parameter">
<field name="key">fusion_repairs.client_portal_rate_limit_per_hour</field>
<field name="value">10</field>
</record>
<!-- M3: post a loaner-offer activity if a repair has been open this long. -->
<record id="param_loaner_offer_threshold_days" model="ir.config_parameter">
<field name="key">fusion_repairs.loaner_offer_threshold_days</field>
<field name="value">3</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Daily maintenance reminders at 30/7/1 days before due date. -->
<record id="cron_maintenance_due_reminders" model="ir.cron">
<field name="name">Fusion Repairs: Send maintenance due reminders</field>
<field name="model_id" ref="model_fusion_repair_maintenance_contract"/>
<field name="state">code</field>
<field name="code">model.cron_send_due_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=7, minute=0, second=0, microsecond=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="active" eval="True"/>
</record>
<!-- CL15: Escalate unacknowledged on-call pages every 5 minutes.
Pages older than fusion_repairs.on_call_escalate_minutes
(default 15) get re-paged to the next priority. -->
<record id="cron_on_call_escalate" model="ir.cron">
<field name="name">Fusion Repairs: Escalate unacknowledged on-call pages</field>
<field name="model_id" ref="model_fusion_repair_on_call_service"/>
<field name="state">code</field>
<field name="code">model.cron_escalate_unacknowledged()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="nextcall" eval="(DateTime.now() + timedelta(minutes=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="active" eval="True"/>
</record>
<!-- X2: Day-before visit reminders. Runs every morning at 08:00. -->
<record id="cron_day_before_reminders" model="ir.cron">
<field name="name">Fusion Repairs: Day-before visit reminders</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="state">code</field>
<field name="code">model.cron_send_day_before_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=8, minute=0, second=0, microsecond=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="active" eval="True"/>
</record>
<!-- X4: Post-visit NPS. Runs hourly to send 24h after state=done. -->
<record id="cron_post_visit_nps" model="ir.cron">
<field name="name">Fusion Repairs: Send post-visit NPS emails</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="state">code</field>
<field name="code">model.cron_send_post_visit_nps()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="nextcall" eval="(DateTime.now() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<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>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="state">code</field>
<field name="code">model.cron_offer_loaner_for_long_repairs()</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=8, minute=30, second=0, microsecond=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Intake session reference. -->
<!-- Groups multiple repair.order records created from the same call. -->
<record id="seq_repair_intake_session" model="ir.sequence">
<field name="name">Repair Intake Session</field>
<field name="code">fusion.repair.intake.session</field>
<field name="prefix">RIS</field>
<field name="padding">6</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
<!-- Maintenance contract reference -->
<record id="seq_repair_maintenance_contract" model="ir.sequence">
<field name="name">Repair Maintenance Contract</field>
<field name="code">fusion.repair.maintenance.contract</field>
<field name="prefix">MC/</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
<!-- Bundle 9: LW-NNNNN for store labor warranty records. -->
<record id="seq_repair_labor_warranty" model="ir.sequence">
<field name="name">Labor Warranty</field>
<field name="code">fusion.repair.labor.warranty</field>
<field name="prefix">LW-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
<!-- Bundle 8: PART-NNNNN for procurement-facing part orders. -->
<record id="seq_repair_part_order" model="ir.sequence">
<field name="name">Repair Part Order</field>
<field name="code">fusion.repair.part.order</field>
<field name="prefix">PART-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
<!-- Service plan subscription reference: PLAN-NNNNN. -->
<record id="seq_repair_service_plan_subscription" model="ir.sequence">
<field name="name">Service Plan Subscription</field>
<field name="code">fusion.repair.service.plan.subscription</field>
<field name="prefix">PLAN-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<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.
%(year)s -> 4-digit year, %(month)s -> zero-padded month (per strftime %m). -->
<record id="seq_repair_order_monthly" model="ir.sequence">
<field name="name">Repair Order (RO-YYYYMM-NN)</field>
<field name="code">fusion.repair.order.monthly</field>
<field name="prefix">RO-%(year)s%(month)s-</field>
<field name="suffix"/>
<field name="padding">2</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>
</data>
</odoo>

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- CS callback after intake - confirms call back if anything was missing -->
<record id="mail_activity_type_cs_callback" model="mail.activity.type">
<field name="name">Repair: CS Callback</field>
<field name="summary">Call client back if any intake info was missing</field>
<field name="delay_count">1</field>
<field name="delay_unit">days</field>
<field name="delay_from">previous_activity</field>
<field name="res_model">repair.order</field>
<field name="icon">fa-phone</field>
<field name="sequence">10</field>
</record>
<!-- Tech dispatch needed - office must assign a technician -->
<record id="mail_activity_type_tech_dispatch" model="mail.activity.type">
<field name="name">Repair: Assign Technician</field>
<field name="summary">Assign a technician to this repair</field>
<field name="delay_count">2</field>
<field name="delay_unit">days</field>
<field name="delay_from">previous_activity</field>
<field name="res_model">repair.order</field>
<field name="icon">fa-wrench</field>
<field name="sequence">20</field>
</record>
<!-- Visit follow-up - tech must report visit outcome -->
<record id="mail_activity_type_visit_followup" model="mail.activity.type">
<field name="name">Repair: Visit Follow-Up</field>
<field name="summary">Confirm visit outcome and complete repair</field>
<field name="delay_count">1</field>
<field name="delay_unit">days</field>
<field name="delay_from">previous_activity</field>
<field name="res_model">repair.order</field>
<field name="icon">fa-check-square-o</field>
<field name="sequence">30</field>
</record>
<!-- Manager review - third-party equipment -->
<record id="mail_activity_type_manager_review" model="mail.activity.type">
<field name="name">Repair: Manager Review</field>
<field name="summary">Third-party equipment - manager awareness</field>
<field name="delay_count">1</field>
<field name="delay_unit">days</field>
<field name="delay_from">previous_activity</field>
<field name="res_model">repair.order</field>
<field name="icon">fa-flag</field>
<field name="sequence">40</field>
</record>
<!-- M3: Loaner offer for long-running repairs. -->
<record id="mail_activity_type_loaner_offer" model="mail.activity.type">
<field name="name">Repair: Offer Loaner</field>
<field name="summary">Offer the client a loaner unit while repair is in progress</field>
<field name="delay_count">1</field>
<field name="delay_unit">days</field>
<field name="delay_from">previous_activity</field>
<field name="res_model">repair.order</field>
<field name="icon">fa-handshake-o</field>
<field name="sequence">50</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,483 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Fusion Repairs Mail Templates.
Styling: 4px accent bar, 600px max-width, dark/light safe.
Mirrors fusion_claims/data/mail_template_data.xml ADP templates for consistency.
-->
<odoo>
<data noupdate="1">
<!-- ============================================================== -->
<!-- Repair Intake Received - Client Confirmation -->
<!-- ============================================================== -->
<record id="email_template_intake_received_client" model="mail.template">
<field name="name">Repair: Intake Received (Client)</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">{{ object.company_id.name }} - Service Call {{ object.name or 'received' }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or 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:#2B6CB0;"></div>
<div style="padding:32px 28px;">
<p style="color:#2B6CB0;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;">We received your service request</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'"/>, thank you for letting us know about your equipment.
Your service call reference is <strong><t t-out="object.name"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Service Call Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</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>
<t t-if="object.product_id">
<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 t-if="object.schedule_date">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Scheduled</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.schedule_date" t-options="{'widget': 'datetime'}"/></td></tr>
</t>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;">Status</td><td style="padding:10px 14px;font-size:14px;"><t t-out="dict(object._fields['state'].selection).get(object.state)"/></td></tr>
</table>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">
A team member will be in touch shortly to confirm the next steps.
If you need to reach us before then, please contact our office directly.
</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- X2: Day-before visit reminder -->
<!-- ============================================================== -->
<record id="email_template_visit_day_before" model="mail.template">
<field name="name">Repair: Day-Before Visit Reminder</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">Reminder: technician visit tomorrow for {{ object.name }}</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:#2B6CB0;"></div>
<div style="padding:32px 28px;">
<p style="color:#2B6CB0;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;">Reminder: our technician visits tomorrow</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'"/>, this is a friendly
reminder that your service call <strong><t t-out="object.name"/></strong>
is scheduled for tomorrow.
</p>
<!-- H3: pull the SPECIFIC task via context (cron passes reminder_task_id);
fall back to the first task otherwise so manual sends still work. -->
<t t-set="task_id" t-value="ctx.get('reminder_task_id') if ctx else False"/>
<t t-set="task" t-value="env['fusion.technician.task'].browse(task_id) if task_id else object.x_fc_technician_task_ids[:1]"/>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<t t-if="task">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Scheduled</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="task.scheduled_date" t-options="{'widget': 'date'}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Technician</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="task.technician_id.name or 'TBC'"/></td></tr>
</t>
<t t-if="object.product_id">
<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>
</table>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">
<strong>Need to reschedule?</strong> Reply to this email or call our office.
Please make sure the equipment is accessible and any pets are secured.
</p>
</div>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- X4: Post-visit NPS / Google review -->
<!-- ============================================================== -->
<record id="email_template_post_visit_nps" model="mail.template">
<field name="name">Repair: Post-Visit NPS</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">How did we do, {{ object.partner_id.name or 'there' }}?</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:#38a169;"></div>
<div style="padding:32px 28px;">
<p style="color:#38a169;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;">Thanks for trusting us with your equipment</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your service call <strong><t t-out="object.name"/></strong> is complete.
We would love to hear how it went - your feedback helps other clients
find us and helps us improve.
</p>
<!-- H4: URL-encode the company name so the fallback URL survives ampersands + spaces. -->
<t t-set="review_url" t-value="object.company_id.x_fc_google_review_url or ('https://www.google.com/search?' + url_encode({'q': object.company_id.name or ''}))"/>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-att-href="review_url"
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
Leave a Google review
</a>
</div>
<p style="opacity:0.55;font-size:12px;margin:0;">
If anything is not right, please reply directly to this email - we will make it right.
</p>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Bundle 8: Rush service technician alert (mid-shift squeeze) -->
<!-- ============================================================== -->
<record id="email_template_rush_tech_alert" model="mail.template">
<field name="name">Repair: Rush Squeeze - Tech Alert</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">URGENT: {{ object.partner_id.name or 'rush client' }} added to your route - {{ object.name }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</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:#c53030;"></div>
<div style="padding:32px 28px;">
<p style="color:#c53030;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
Rush stop added to your day
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;">A rush call was squeezed into your route</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Office added this between your existing stops. Please re-sequence
your day and head over as soon as you can finish your current job.
</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:35%;">Repair</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);">Client</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.name or '?'"/></td></tr>
<t t-if="object.partner_id.phone">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Phone</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><a t-attf-href="tel:{{ object.partner_id.phone }}"><t t-out="object.partner_id.phone"/></a></td></tr>
</t>
<t t-if="object.x_fc_repair_category_id">
<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.x_fc_repair_category_id.name"/></td></tr>
</t>
<t t-if="object.partner_id.street or object.partner_id.city">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Address</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.street or ''"/>, <t t-out="object.partner_id.city or ''"/></td></tr>
</t>
<t t-if="object.x_fc_rush_surcharge">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;">Rush Surcharge</td><td style="padding:10px 14px;font-size:14px;color:#c53030;font-weight:600;">$<t t-out="object.x_fc_rush_surcharge"/></td></tr>
</t>
</table>
<p style="margin:0;font-size:13px;color:#888;">
Open the task in your tech portal to see the full route and tap Start Timer when you arrive.
</p>
</div>
</div>
</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Bundle 8: Repair awaiting parts (client comms) -->
<!-- ============================================================== -->
<record id="email_template_repair_awaiting_parts" model="mail.template">
<field name="name">Repair: Awaiting Parts (Client)</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">{{ object.company_id.name }} - update on your repair {{ object.name }}</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;">We found the problem - here's the plan</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'"/>, our technician
diagnosed your equipment today but needs a part we don't carry on the
truck. We're ordering it right away from the manufacturer.
</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%;">Reference</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>
<t t-if="object.x_fc_parts_eta_date">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;">Expected return visit</td><td style="padding:10px 14px;font-size:14px;color:#d97706;font-weight:600;">~<t t-out="object.x_fc_parts_eta_date" t-options="{'widget': 'date'}"/></td></tr>
</t>
</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;">
<strong>What happens next:</strong>
</p>
<ol style="margin:8px 0 0 0;font-size:14px;line-height:1.6;">
<li>We order the parts from the manufacturer today.</li>
<li>When the parts arrive at our warehouse, we'll email you with a confirmed visit date.</li>
<li>You don't need to do anything in the meantime.</li>
</ol>
</div>
<p style="opacity:0.55;font-size:12px;margin:0;">
Questions? Reply to this email or call our office. Reference: <t t-out="object.name"/>.
</p>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Bundle 8: Specific parts ordered (per part) -->
<!-- ============================================================== -->
<record id="email_template_parts_ordered" model="mail.template">
<field name="name">Repair: Parts Ordered (Client)</field>
<field name="model_id" ref="model_fusion_repair_part_order"/>
<field name="subject">Parts ordered for your {{ object.repair_order_id.x_fc_repair_category_id.name or 'equipment' }} - {{ object.repair_order_id.name }}</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:#2B6CB0;"></div>
<div style="padding:32px 28px;">
<h2 style="font-size:20px;font-weight:700;margin:0 0 16px 0;">Parts ordered</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 16px 0;">
We've placed an order for the parts your <t t-out="object.repair_order_id.x_fc_repair_category_id.name or 'equipment'"/>
needs. Expected arrival: <strong><t t-out="object.expected_date" t-options="{'widget': 'date'}"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 16px 0;">
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Part</td><td style="padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.description"/></td></tr>
<t t-if="object.manufacturer">
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);">Manufacturer</td><td style="padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.manufacturer"/></td></tr>
</t>
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;">Ref</td><td style="padding:8px 14px;font-size:13px;"><t t-out="object.name"/></td></tr>
</table>
<p style="opacity:0.55;font-size:12px;margin:0;">We'll email again as soon as the parts arrive at our warehouse.</p>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Bundle 8: Parts received - re-dispatch coming (client) -->
<!-- ============================================================== -->
<record id="email_template_parts_received" model="mail.template">
<field name="name">Repair: Parts Received (Client)</field>
<field name="model_id" ref="model_fusion_repair_part_order"/>
<field name="subject">Parts arrived - scheduling your return visit ({{ object.repair_order_id.name }})</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:#38a169;"></div>
<div style="padding:32px 28px;">
<h2 style="font-size:22px;font-weight:700;margin:0 0 16px 0;">Good news - your parts arrived</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 16px 0;">
The parts for your repair are in. Our office will call you in the next business day
to confirm a return-visit time. You don't need to do anything right now.
</p>
<p style="opacity:0.55;font-size:12px;margin:0;">Reference: <t t-out="object.repair_order_id.name"/></p>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<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 -->
<!-- ============================================================== -->
<record id="email_template_on_call_page" model="mail.template">
<field name="name">Repair: On-Call Safety Page</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">[SAFETY PAGE] {{ object.partner_id.name or 'Unknown' }} - {{ object.name or 'n/a' }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</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:#c53030;"></div>
<div style="padding:32px 28px;">
<p style="color:#c53030;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
URGENT - SAFETY PAGE
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Safety service call requires response</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
A client just submitted a safety-flagged service request via the
<t t-out="dict(object._fields['x_fc_intake_source'].selection).get(object.x_fc_intake_source) or 'intake'"/>.
You have been paged as the on-call manager.
</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:35%;">Reference</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);">Client</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.name or '?'"/></td></tr>
<t t-if="object.partner_id.phone">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Phone</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.phone"/></td></tr>
</t>
<t t-if="object.x_fc_repair_category_id">
<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.x_fc_repair_category_id.name"/></td></tr>
</t>
</table>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-attf-href="/repair/on-call/ack/{{ object.x_fc_on_call_token or '' }}"
style="display:inline-block;padding:14px 28px;background-color:#c53030;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
Acknowledge - I will respond
</a>
</div>
<p style="opacity:0.55;font-size:12px;margin:0;">
If you do not acknowledge within 15 minutes, the next on-call
priority will be paged automatically.
</p>
</div>
</div>
</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Maintenance Due Reminder -->
<!-- ============================================================== -->
<record id="email_template_maintenance_due_reminder" model="mail.template">
<field name="name">Repair: Maintenance Due Reminder</field>
<field name="model_id" ref="model_fusion_repair_maintenance_contract"/>
<field name="subject">{{ object.company_id.name }} - Time to schedule your equipment maintenance</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:#38a169;"></div>
<div style="padding:32px 28px;">
<p style="color:#38a169;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;">Your equipment is due for maintenance</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'"/>, your
<strong><t t-out="object.product_id.display_name or 'equipment'"/></strong>
is due for its next scheduled maintenance visit on
<strong><t t-out="object.next_due_date" t-options="{'widget': 'date'}"/></strong>.
</p>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-attf-href="/repairs/maintenance/book/{{ object.booking_token }}"
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
Book my maintenance visit
</a>
</div>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">
Regular maintenance keeps your equipment safe and reliable. Use the
button above to confirm and we will reach out to schedule a time that works for you.
</p>
</div>
<p style="opacity:0.55;font-size:12px;margin:0;">
Contract reference <strong><t t-out="object.name"/></strong>.
If you no longer have this equipment, you can ignore this email.
</p>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Repair Intake Received - Office Notification -->
<!-- ============================================================== -->
<record id="email_template_intake_received_office" model="mail.template">
<field name="name">Repair: Intake Received (Office)</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">[New Service Call] {{ object.partner_id.name or 'Walk-in' }} - {{ object.name or 'n/a' }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ ','.join(p.email for p in (object.company_id.x_fc_office_notification_ids if 'x_fc_office_notification_ids' in object.company_id._fields else []) if p.email) or (object.company_id.email or '') }}</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:#d69e2e;"></div>
<div style="padding:32px 28px;">
<p style="color:#d69e2e;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
Internal: New Service Call
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">A new repair has been submitted</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Submitted by <strong><t t-out="object.x_fc_intake_user_id.name or object.user_id.name or 'system'"/></strong>
via the <strong><t t-out="dict(object._fields['x_fc_intake_source'].selection).get(object.x_fc_intake_source) or 'intake'"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</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);">Client</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.name or 'Walk-in / unknown'"/></td></tr>
<t t-if="object.partner_id.phone">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Phone</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.phone"/></td></tr>
</t>
<t t-if="object.product_id">
<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>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Urgency</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="dict(object._fields['x_fc_urgency'].selection).get(object.x_fc_urgency) or 'normal'"/></td></tr>
<t t-if="object.x_fc_third_party_equipment">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Third-party</td><td style="padding:10px 14px;color:#d69e2e;font-size:14px;font-weight:600;border-bottom:1px solid rgba(128,128,128,0.15);">Yes - equipment not sold by us</td></tr>
</t>
<t t-if="object.under_warranty">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Warranty</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:600;border-bottom:1px solid rgba(128,128,128,0.15);">Under warranty</td></tr>
</t>
</table>
</div>
</div>
</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Medical equipment categories used for repair intake routing and skills matching. -->
<record id="category_hospital_bed" model="fusion.repair.product.category">
<field name="name">Hospital Bed</field>
<field name="code">hospital_bed</field>
<field name="sequence">10</field>
<field name="icon">fa-bed</field>
<field name="description">Electric and manual hospital beds, semi-electric beds, low beds.</field>
</record>
<record id="category_wheelchair_manual" model="fusion.repair.product.category">
<field name="name">Wheelchair (Manual)</field>
<field name="code">wheelchair_manual</field>
<field name="sequence">20</field>
<field name="icon">fa-wheelchair</field>
<field name="description">Standard, transport, and tilt-in-space manual wheelchairs.</field>
</record>
<record id="category_wheelchair_power" model="fusion.repair.product.category">
<field name="name">Wheelchair (Power)</field>
<field name="code">wheelchair_power</field>
<field name="sequence">30</field>
<field name="icon">fa-wheelchair</field>
<field name="description">Power wheelchairs, scooters, and powered mobility devices.</field>
<field name="safety_critical" eval="True"/>
</record>
<record id="category_stairlift" model="fusion.repair.product.category">
<field name="name">Stairlift</field>
<field name="code">stairlift</field>
<field name="sequence">40</field>
<field name="icon">fa-arrows-v</field>
<field name="description">Straight and curved indoor stairlifts. Annual safety inspection required in many jurisdictions.</field>
<field name="safety_critical" eval="True"/>
<field name="equipment_class">lift_elevating</field>
</record>
<record id="category_porch_lift" model="fusion.repair.product.category">
<field name="name">Porch Lift</field>
<field name="code">porch_lift</field>
<field name="sequence">50</field>
<field name="icon">fa-arrow-up</field>
<field name="description">Vertical platform lifts for porches, decks, and accessible building entrances.</field>
<field name="safety_critical" eval="True"/>
<field name="equipment_class">lift_elevating</field>
</record>
<!-- Bundle 10: Lift Chair is its own category (power recliner / lift chair). -->
<record id="category_lift_chair" model="fusion.repair.product.category">
<field name="name">Lift Chair</field>
<field name="code">lift_chair</field>
<field name="sequence">55</field>
<field name="icon">fa-chair</field>
<field name="description">Powered recliner / lift chairs (Pride, Golden, MedLift). Falls under Lift &amp; Elevating Service per rate card.</field>
<field name="equipment_class">lift_elevating</field>
</record>
<record id="category_walker" model="fusion.repair.product.category">
<field name="name">Walker</field>
<field name="code">walker</field>
<field name="sequence">60</field>
<field name="icon">fa-male</field>
<field name="description">Standard walkers, hemi-walkers, and folding walkers.</field>
</record>
<record id="category_rollator" model="fusion.repair.product.category">
<field name="name">Rollator</field>
<field name="code">rollator</field>
<field name="sequence">70</field>
<field name="icon">fa-male</field>
<field name="description">Wheeled walkers with seats and brakes.</field>
</record>
<record id="category_mattress" model="fusion.repair.product.category">
<field name="name">Medical Mattress</field>
<field name="code">mattress</field>
<field name="sequence">80</field>
<field name="icon">fa-bed</field>
<field name="description">Air mattresses, alternating pressure, low air loss, and pressure relief mattresses.</field>
</record>
<record id="category_other" model="fusion.repair.product.category">
<field name="name">Other Equipment</field>
<field name="code">other</field>
<field name="sequence">100</field>
<field name="icon">fa-question-circle</field>
<field name="description">Any other medical equipment not in the standard categories.</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,245 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Seed deterministic self-check rules per equipment category.
These are used by fusion.repair.ai.service when AI is unavailable,
when AI returns malformed/unsafe content, or when AI is disabled.
noupdate=1 so admins can customise per site without losing changes.
-->
<odoo>
<data noupdate="1">
<!-- Hospital Bed -->
<record id="self_check_bed_no_power" model="fusion.repair.self.check.rule">
<field name="name">Hospital Bed - No Power</field>
<field name="category_id" ref="category_hospital_bed"/>
<field name="sequence">10</field>
<field name="symptom_keywords">won't move,dead,no power,no response</field>
<field name="instruction">Check the bed is plugged in and the outlet has power - try plugging a phone charger into the same outlet to confirm.</field>
<field name="expected_result">Bed responds when controls are pressed.</field>
</record>
<record id="self_check_bed_slow" model="fusion.repair.self.check.rule">
<field name="name">Hospital Bed - Slow / Sluggish</field>
<field name="category_id" ref="category_hospital_bed"/>
<field name="sequence">20</field>
<field name="symptom_keywords">slow,sluggish</field>
<field name="instruction">Unplug the bed for 30 seconds then plug it back in.</field>
<field name="expected_result">Movement returns to normal speed.</field>
</record>
<record id="self_check_bed_remote" model="fusion.repair.self.check.rule">
<field name="name">Hospital Bed - Remote Unresponsive</field>
<field name="category_id" ref="category_hospital_bed"/>
<field name="sequence">30</field>
<field name="symptom_keywords">remote,controller</field>
<field name="instruction">Replace the remote batteries with fresh AAA batteries.</field>
<field name="expected_result">Remote lights up and bed responds.</field>
</record>
<record id="self_check_bed_alarm" model="fusion.repair.self.check.rule">
<field name="name">Hospital Bed - Alarm</field>
<field name="category_id" ref="category_hospital_bed"/>
<field name="sequence">40</field>
<field name="symptom_keywords">beep,alarm,alert</field>
<field name="instruction">Check both side rails are fully locked in the raised position.</field>
<field name="expected_result">Alarm stops.</field>
</record>
<record id="self_check_bed_one_section" model="fusion.repair.self.check.rule">
<field name="name">Hospital Bed - One Section Won't Move</field>
<field name="category_id" ref="category_hospital_bed"/>
<field name="sequence">50</field>
<field name="symptom_keywords">one section,won't lift,stuck</field>
<field name="instruction">Check nothing is caught under the bed or jamming the mechanism (sheets, blankets, cords).</field>
<field name="expected_result">Section moves freely.</field>
</record>
<!-- Wheelchair (manual) -->
<record id="self_check_wheelchair_brake" model="fusion.repair.self.check.rule">
<field name="name">Wheelchair - Brake</field>
<field name="category_id" ref="category_wheelchair_manual"/>
<field name="sequence">10</field>
<field name="symptom_keywords">brake,stop</field>
<field name="instruction">Push the brake lever fully to the locked position and listen for a click.</field>
<field name="expected_result">Brake holds wheel firmly.</field>
</record>
<record id="self_check_wheelchair_tire" model="fusion.repair.self.check.rule">
<field name="name">Wheelchair - Hard to Push</field>
<field name="category_id" ref="category_wheelchair_manual"/>
<field name="sequence">20</field>
<field name="symptom_keywords">hard to push,drag,slow</field>
<field name="instruction">Check both tires for full inflation - firm to thumb pressure.</field>
<field name="expected_result">Wheelchair rolls freely.</field>
</record>
<record id="self_check_wheelchair_wobble" model="fusion.repair.self.check.rule">
<field name="name">Wheelchair - Wobbly Wheel</field>
<field name="category_id" ref="category_wheelchair_manual"/>
<field name="sequence">30</field>
<field name="symptom_keywords">wobble,loose wheel</field>
<field name="instruction">Try turning the axle nut gently by hand to feel if it is snug.</field>
<field name="expected_result">Wheel feels firm with no play.</field>
</record>
<record id="self_check_wheelchair_footrest" model="fusion.repair.self.check.rule">
<field name="name">Wheelchair - Footrest Loose</field>
<field name="category_id" ref="category_wheelchair_manual"/>
<field name="sequence">40</field>
<field name="symptom_keywords">footrest,footplate</field>
<field name="instruction">Slide the footrest fully into its housing until you hear a click.</field>
<field name="expected_result">Footrest feels secure.</field>
</record>
<!-- Wheelchair (power) -->
<record id="self_check_powerchair_battery" model="fusion.repair.self.check.rule">
<field name="name">Power Wheelchair - No Power</field>
<field name="category_id" ref="category_wheelchair_power"/>
<field name="sequence">10</field>
<field name="symptom_keywords">won't turn on,dead,no power,battery</field>
<field name="instruction">Confirm the battery indicator shows charge and the key switch is in the ON position.</field>
<field name="expected_result">Display lights up.</field>
</record>
<record id="self_check_powerchair_error" model="fusion.repair.self.check.rule">
<field name="name">Power Wheelchair - Error Code</field>
<field name="category_id" ref="category_wheelchair_power"/>
<field name="sequence">20</field>
<field name="symptom_keywords">error,flashing,code</field>
<field name="instruction">Note the error code shown on the joystick display, then turn off and back on after 30 seconds.</field>
<field name="expected_result">Error clears or a specific code is captured.</field>
</record>
<record id="self_check_powerchair_one_side" model="fusion.repair.self.check.rule">
<field name="name">Power Wheelchair - One Side Weaker</field>
<field name="category_id" ref="category_wheelchair_power"/>
<field name="sequence">30</field>
<field name="symptom_keywords">one side weaker,pulls</field>
<field name="instruction">Charge the batteries fully overnight before testing again.</field>
<field name="expected_result">Both sides equal power after a full charge.</field>
</record>
<!-- Stairlift (safety-critical) -->
<record id="self_check_stairlift_seat" model="fusion.repair.self.check.rule">
<field name="name">Stairlift - Won't Move</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">10</field>
<field name="symptom_keywords">won't move,stuck</field>
<field name="instruction">Check the seat is fully rotated to the forward position and the seatbelt is fastened.</field>
<field name="expected_result">Stairlift responds.</field>
</record>
<record id="self_check_stairlift_track" model="fusion.repair.self.check.rule">
<field name="name">Stairlift - Stops Midway</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">20</field>
<field name="symptom_keywords">stops midway,halts</field>
<field name="instruction">Check the track for items blocking the sensors - toys, slippers, debris.</field>
<field name="expected_result">Stairlift completes its travel.</field>
</record>
<record id="self_check_stairlift_remote" model="fusion.repair.self.check.rule">
<field name="name">Stairlift - Call Station Unresponsive</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">30</field>
<field name="symptom_keywords">remote,call station</field>
<field name="instruction">Replace the remote / call-station batteries with fresh batteries.</field>
<field name="expected_result">Call station responds.</field>
</record>
<record id="self_check_stairlift_alarm" model="fusion.repair.self.check.rule">
<field name="name">Stairlift - Beeping / Alarm</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">40</field>
<field name="symptom_keywords">beep,alarm</field>
<field name="instruction">Confirm the seat swivel lock is engaged in the down position.</field>
<field name="expected_result">Beeping stops.</field>
</record>
<!-- Porch Lift (safety-critical) -->
<record id="self_check_porch_gate" model="fusion.repair.self.check.rule">
<field name="name">Porch Lift - Won't Move</field>
<field name="category_id" ref="category_porch_lift"/>
<field name="sequence">10</field>
<field name="symptom_keywords">won't move,dead</field>
<field name="instruction">Check all gate and door safety switches are fully closed.</field>
<field name="expected_result">Lift responds.</field>
</record>
<record id="self_check_porch_controls" model="fusion.repair.self.check.rule">
<field name="name">Porch Lift - Sticky Controls</field>
<field name="category_id" ref="category_porch_lift"/>
<field name="sequence">20</field>
<field name="symptom_keywords">sticky,stuck button</field>
<field name="instruction">If outdoors, gently wipe the controls with a dry cloth and let dry.</field>
<field name="expected_result">Controls respond.</field>
</record>
<record id="self_check_porch_overshoot" model="fusion.repair.self.check.rule">
<field name="name">Porch Lift - Won't Stop at Floor</field>
<field name="category_id" ref="category_porch_lift"/>
<field name="sequence">30</field>
<field name="symptom_keywords">won't stop,overshoot</field>
<field name="instruction">Note exactly which floor it stops at - do not attempt repeat use.</field>
<field name="expected_result">Information captured for technician.</field>
<field name="safety_note">Do not use the lift again until a technician inspects it.</field>
</record>
<!-- Walker / Rollator -->
<record id="self_check_walker_wheel" model="fusion.repair.self.check.rule">
<field name="name">Walker - Wheel Stuck</field>
<field name="category_id" ref="category_walker"/>
<field name="sequence">10</field>
<field name="symptom_keywords">wheel stick,won't roll</field>
<field name="instruction">Check for hair or debris wrapped around the wheel axle.</field>
<field name="expected_result">Wheel spins freely.</field>
</record>
<record id="self_check_walker_wobble" model="fusion.repair.self.check.rule">
<field name="name">Walker - Frame Wobbles</field>
<field name="category_id" ref="category_walker"/>
<field name="sequence">20</field>
<field name="symptom_keywords">wobble,loose</field>
<field name="instruction">Check all height adjustment pins are fully engaged through both holes.</field>
<field name="expected_result">Frame feels solid.</field>
<field name="safety_note">Wobbly walkers cause falls - stop using until repaired if movement persists.</field>
</record>
<record id="self_check_rollator_brake" model="fusion.repair.self.check.rule">
<field name="name">Rollator - Brake Won't Lock</field>
<field name="category_id" ref="category_rollator"/>
<field name="sequence">10</field>
<field name="symptom_keywords">brake won't lock,brake loose</field>
<field name="instruction">Push the brake lever fully down until you feel a click.</field>
<field name="expected_result">Brake holds.</field>
</record>
<record id="self_check_rollator_seat" model="fusion.repair.self.check.rule">
<field name="name">Rollator - Seat Loose</field>
<field name="category_id" ref="category_rollator"/>
<field name="sequence">20</field>
<field name="symptom_keywords">seat loose</field>
<field name="instruction">Tighten the seat knobs by hand until firm.</field>
<field name="expected_result">Seat feels secure.</field>
<field name="safety_note">Do not sit on a loose rollator seat - fall risk.</field>
</record>
<!-- Medical Mattress -->
<record id="self_check_mattress_pump" model="fusion.repair.self.check.rule">
<field name="name">Mattress - Deflated</field>
<field name="category_id" ref="category_mattress"/>
<field name="sequence">10</field>
<field name="symptom_keywords">deflated,flat,soft</field>
<field name="instruction">Confirm the pump is plugged in, powered on, and the hose is firmly attached.</field>
<field name="expected_result">Mattress inflates.</field>
</record>
<record id="self_check_mattress_alarm" model="fusion.repair.self.check.rule">
<field name="name">Mattress - Alarm</field>
<field name="category_id" ref="category_mattress"/>
<field name="sequence">20</field>
<field name="symptom_keywords">alarm,beep</field>
<field name="instruction">Check the pump display for the error code shown, then restart the pump by unplugging for 30 seconds.</field>
<field name="expected_result">Alarm clears.</field>
</record>
<record id="self_check_mattress_leak" model="fusion.repair.self.check.rule">
<field name="name">Mattress - Hissing / Leak</field>
<field name="category_id" ref="category_mattress"/>
<field name="sequence">30</field>
<field name="symptom_keywords">hiss,leak</field>
<field name="instruction">Listen at the valve - push the valve cap in firmly to ensure it is sealed.</field>
<field name="expected_result">Hissing stops.</field>
</record>
<record id="self_check_mattress_cold" model="fusion.repair.self.check.rule">
<field name="name">Mattress - Not Heating</field>
<field name="category_id" ref="category_mattress"/>
<field name="sequence">40</field>
<field name="symptom_keywords">cold,won't heat</field>
<field name="instruction">Confirm the heat dial is set above zero and allow 15 minutes to warm.</field>
<field name="expected_result">Mattress feels warm.</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""Post-migration for 19.0.2.1.0 - align rate-card + categories to Westin's
printed service-rate card.
Sites that installed any earlier Bundle 9 build have:
- Old callout.rate rows with $120/$95/0.85 values (B9 placeholder rates)
- Stairlift / porch_lift categories with equipment_class='standard'
Both have noupdate=1 in their seed XML so a normal -u upgrade won't fix
them. This script:
1. Wipes the four B9-only rate xml_ids and re-imports the seed
2. Updates lift / porch / lift_chair categories to equipment_class='lift_elevating'
After this runs once, future upgrades respect noupdate=1 normally (admin
tweaks are preserved).
"""
from odoo.tools.sql import column_exists
def migrate(cr, version):
if not version:
return # fresh install - seed loads correctly
cr.execute("""
UPDATE fusion_repair_product_category
SET equipment_class = 'lift_elevating'
WHERE code IN ('stairlift', 'porch_lift', 'lift_chair')
AND (equipment_class IS NULL OR equipment_class = 'standard');
""")
# Wipe the four B9 rate rows so the new noupdate=1 seed re-creates them
# with the printed values. Only deletes rows that were originally seeded
# by this module (xml_id present) - admin-created rate rows stay put.
cr.execute("""
DELETE FROM fusion_repair_callout_rate
WHERE id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_repairs'
AND model = 'fusion.repair.callout.rate'
AND name IN ('callout_rate_regular', 'callout_rate_after_hours',
'callout_rate_weekend', 'callout_rate_holiday')
);
DELETE FROM ir_model_data
WHERE module = 'fusion_repairs'
AND model = 'fusion.repair.callout.rate'
AND name IN ('callout_rate_regular', 'callout_rate_after_hours',
'callout_rate_weekend', 'callout_rate_holiday');
""")

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import repair_product_category
from . import intake_template
from . import intake_question
from . import intake_answer
from . import service_catalog
from . import repair_warranty
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 repair_service_plan
from . import repair_emergency_charge
from . import repair_part_order
from . import repair_callout_rate
from . import repair_labor_warranty
from . import repair_delivery_charge
from . import product_template
from . import res_partner
from . import res_users
from . import res_config_settings
from . import technician_task
from . import repair_order
from . import sale_order
from . import intake_service
from . import repair_dashboard

View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FusionRepairIntakeAnswer(models.Model):
"""An answer to a single intake question on a specific repair order.
Persists raw answer values for audit + reporting + AI / catalogue matching.
"""
_name = 'fusion.repair.intake.answer'
_description = 'Repair Intake Answer'
_order = 'repair_id, sequence, id'
repair_id = fields.Many2one(
'repair.order',
string='Repair Order',
required=True,
ondelete='cascade',
index=True,
)
question_id = fields.Many2one(
'fusion.repair.intake.question',
string='Question',
required=True,
ondelete='restrict',
)
question_name = fields.Char(
related='question_id.name',
string='Question',
store=True,
)
question_type = fields.Selection(
related='question_id.question_type',
store=True,
)
sequence = fields.Integer(
related='question_id.sequence',
store=True,
)
# Typed value fields - one per supported type, plus a display string.
value_char = fields.Char(string='Text Answer')
value_text = fields.Text(string='Long Text Answer')
value_selection = fields.Char(string='Choice Answer')
value_boolean = fields.Boolean(string='Yes/No Answer')
value_integer = fields.Integer(string='Number Answer')
value_date = fields.Date(string='Date Answer')
value_display = fields.Char(
string='Answer',
compute='_compute_value_display',
store=True,
)
company_id = fields.Many2one(
'res.company',
related='repair_id.company_id',
store=True,
index=True,
)
@api.depends(
'question_type',
'value_char', 'value_text', 'value_selection',
'value_boolean', 'value_integer', 'value_date',
)
def _compute_value_display(self):
for answer in self:
if answer.question_type == 'char':
answer.value_display = answer.value_char or ''
elif answer.question_type == 'text':
answer.value_display = (answer.value_text or '')[:200]
elif answer.question_type == 'selection':
answer.value_display = answer.value_selection or ''
elif answer.question_type == 'boolean':
answer.value_display = 'Yes' if answer.value_boolean else 'No'
elif answer.question_type == 'integer':
answer.value_display = str(answer.value_integer or 0)
elif answer.question_type == 'date':
answer.value_display = (
fields.Date.to_string(answer.value_date) if answer.value_date else ''
)
else:
answer.value_display = ''

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
QUESTION_TYPES = [
('char', 'Short Text'),
('text', 'Long Text'),
('selection', 'Single Choice'),
('boolean', 'Yes / No'),
('integer', 'Number'),
('date', 'Date'),
]
class FusionRepairIntakeQuestion(models.Model):
"""A single question on an intake template.
Supports basic conditional display: a question is only shown when the
parent question's answer matches `parent_answer_value`. The wizard and
portal forms render based on these rules.
"""
_name = 'fusion.repair.intake.question'
_description = 'Repair Intake Question'
_order = 'sequence, id'
template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Template',
required=True,
ondelete='cascade',
index=True,
)
sequence = fields.Integer(string='Sequence', default=10)
name = fields.Char(
string='Question',
required=True,
translate=True,
help='Text shown to the user.',
)
code = fields.Char(
string='Code',
help='Stable identifier for this question (used by automation rules and reporting).',
)
help_text = fields.Char(
string='Help Text',
translate=True,
help='Optional shorter hint shown beneath the question (e.g. "e.g. SN-12345").',
)
question_type = fields.Selection(
QUESTION_TYPES,
string='Type',
required=True,
default='char',
)
required = fields.Boolean(default=False)
selection_options = fields.Text(
string='Choices',
help='One option per line, only used when type is "Single Choice".',
)
# Conditional display
parent_question_id = fields.Many2one(
'fusion.repair.intake.question',
string='Show Only If Question',
domain="[('template_id', '=', template_id), ('id', '!=', id)]",
ondelete='set null',
help='Show this question only when the parent question matches the value below.',
)
parent_answer_value = fields.Char(
string='Parent Answer Equals',
help='Value the parent answer must equal for this question to be displayed.',
)
# Symptom keyword classification - feeds the service catalogue matcher and AI prompt
symptom_keywords = fields.Char(
string='Symptom Keywords',
help='Comma-separated keywords that, when present in the answer, tag the repair '
'for catalogue matching (e.g. "battery,charge").',
)

View File

@@ -0,0 +1,554 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Shared intake service.
This AbstractModel is the SINGLE entry point for creating repair orders from
any intake surface: the backend wizard (Phase 1), the sales rep portal
(Phase 1+), and the public client self-service portal (Phase 1+).
All three surfaces call `create_repair_orders(payload, source='...')` so that
business logic - activities, emails, warranty determination, AI summary,
catalogue match, third-party flag, dispatch task creation - lives in one
place and the surfaces never drift apart.
"""
import logging
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionRepairIntakeService(models.AbstractModel):
_name = 'fusion.repair.intake.service'
_description = 'Repair Intake Service (shared by backend / sales rep / client)'
# ------------------------------------------------------------------
# PUBLIC API
# ------------------------------------------------------------------
@api.model
def create_repair_orders(self, payload, source='backend_wizard'):
"""Create one repair.order per equipment item in the payload.
:param payload: dict with keys:
- partner_id: int (required) or partner_vals: dict to create new partner
- intake_user_id: int (optional, defaults to env.user)
- quote_only: bool (optional, C6 - skips dispatch task creation)
- equipment_items: list of dicts, each with:
- product_id: int (optional)
- lot_id: int (optional)
- repair_category_id: int (optional)
- intake_template_id: int (optional)
- third_party: bool (optional)
- urgency: str (optional, default 'normal')
- issue_summary: str (optional)
- internal_notes: str (optional)
- photo_attachment_ids: list[int] (optional)
- answers: list of dicts with keys
(question_id, value_char|value_text|value_selection|
value_boolean|value_integer|value_date)
:param source: str, one of repair_order.INTAKE_SOURCES values.
:return: recordset of repair.order records created.
"""
partner_id = self._resolve_partner(payload)
if not partner_id:
raise UserError(_('A client is required to create a repair request.'))
intake_user = self.env['res.users'].browse(
payload.get('intake_user_id') or self.env.uid
)
session_ref = (
self.env['ir.sequence'].next_by_code('fusion.repair.intake.session')
or 'RIS/NEW'
)
equipment = payload.get('equipment_items') or [{}]
quote_only = bool(payload.get('quote_only'))
rush_requested = bool(payload.get('rush_requested'))
rush_tier = payload.get('rush_tier') or False
rush_techs_required = int(payload.get('rush_techs_required') or 1)
repairs = self.env['repair.order']
for item in equipment:
repair = self._create_single_repair(
partner_id=partner_id,
intake_user=intake_user,
session_ref=session_ref,
source=source,
item=item,
quote_only=quote_only,
rush_requested=rush_requested,
rush_tier=rush_tier,
rush_techs_required=rush_techs_required,
)
repairs |= repair
return repairs
# ------------------------------------------------------------------
# PARTNER RESOLUTION
# ------------------------------------------------------------------
@api.model
def _resolve_partner(self, payload):
partner_id = payload.get('partner_id')
if partner_id:
return partner_id
partner_vals = payload.get('partner_vals')
if not partner_vals:
return False
# Sensible defaults for partners created via public portals so mail
# templates pick up the right language / company.
partner_vals.setdefault('lang', self.env.user.lang or 'en_CA')
partner_vals.setdefault('company_id', self.env.company.id)
partner = self.env['res.partner'].sudo().create(partner_vals)
return partner.id
# ------------------------------------------------------------------
# CORE CREATION
# ------------------------------------------------------------------
@api.model
def _create_single_repair(self, partner_id, intake_user, session_ref,
source, item, quote_only=False,
rush_requested=False, rush_tier=False,
rush_techs_required=1):
Repair = self.env['repair.order']
product_id = item.get('product_id')
vals = {
'partner_id': partner_id,
'user_id': intake_user.id,
'x_fc_intake_user_id': intake_user.id,
'x_fc_intake_session_id': session_ref,
'x_fc_intake_source': source,
'x_fc_repair_category_id': item.get('repair_category_id') or False,
'x_fc_intake_template_id': item.get('intake_template_id') or False,
'x_fc_third_party_equipment': bool(item.get('third_party')),
'x_fc_urgency': item.get('urgency') or 'normal',
'x_fc_issue_category': item.get('issue_category') or False,
'x_fc_is_quote_only': bool(quote_only),
'x_fc_rush_requested': bool(rush_requested),
'x_fc_rush_tier': rush_tier or False,
'x_fc_rush_techs_required': rush_techs_required or 1,
'internal_notes': self._wrap_internal_notes(item),
}
if product_id:
vals['product_id'] = product_id
if item.get('lot_id'):
vals['lot_id'] = item['lot_id']
if item.get('schedule_date'):
vals['schedule_date'] = item['schedule_date']
repair = Repair.create(vals)
# Determine warranty AFTER creation (needs product on record).
if not repair.x_fc_third_party_equipment:
self._auto_link_original_sale_order(repair)
if repair._fc_compute_warranty_status():
repair.under_warranty = True
# Persist intake answers.
self._create_answers(repair, item.get('answers') or [])
# Service catalogue auto-match.
self._match_service_catalog(repair, item, quote_only=quote_only)
# Check our own repair-warranty (30/90 day re-do free).
self._check_repair_warranty(repair)
# Optional AI brief generation - never blocks intake.
self._generate_ai_summary(repair, item)
# Attach photos.
photo_ids = item.get('photo_attachment_ids') or []
if photo_ids:
attachments = self.env['ir.attachment'].sudo().browse(photo_ids).exists()
attachments.write({
'res_model': 'repair.order',
'res_id': repair.id,
})
repair.write({'x_fc_photo_ids': [(6, 0, attachments.ids)]})
# Activities.
self._schedule_activities(repair)
# Optional dispatch draft task (urgent / safety).
# Skip if the catalogue match already auto-created one.
# Skip entirely if intake is quote-only (C6).
if (
not quote_only
and repair.x_fc_urgency in ('urgent', 'safety')
and not repair.x_fc_technician_task_ids
):
self._create_dispatch_task(repair)
elif quote_only:
repair.message_post(body=Markup(_(
'Created in <b>Quote Only</b> mode - no technician dispatched.'
)))
# CL15: page the on-call manager for safety intakes after hours.
if repair.x_fc_urgency == 'safety':
try:
self.env['fusion.repair.on.call.service'].sudo().page_on_call(repair)
except Exception as e:
_logger.warning('On-call page failed for %s: %s', repair.name, e)
# Emails (client + office).
self._send_intake_emails(repair)
# Audit message in chatter.
repair.message_post(
body=Markup(_(
'Service call submitted via <b>%(source)s</b> by %(user)s. '
'Session reference: %(ref)s.'
)) % {
'source': dict(repair._fields['x_fc_intake_source'].selection).get(source) or '',
'user': intake_user.name or '',
'ref': session_ref or '',
},
)
return repair
@api.model
def _wrap_internal_notes(self, item):
notes = item.get('internal_notes') or ''
summary = item.get('issue_summary') or ''
if not (notes or summary):
return False
parts = []
if summary:
parts.append('<p><strong>Issue summary:</strong> %s</p>' % summary)
if notes:
parts.append('<p><strong>Notes:</strong> %s</p>' % notes)
return ''.join(parts)
# ------------------------------------------------------------------
# SERVICE CATALOGUE MATCH
# ------------------------------------------------------------------
@api.model
def _match_service_catalog(self, repair, item, quote_only=False):
category = repair.x_fc_repair_category_id
if not category:
return
text_hints = [
(item.get('issue_summary') or ''),
(item.get('issue_category') or ''),
(item.get('internal_notes') or ''),
]
catalog = self.env['fusion.repair.service.catalog'].sudo().find_best_match(
category.id, text_hints,
)
if not catalog:
return
repair.write({
'x_fc_service_catalog_id': catalog.id,
'x_fc_estimated_duration': catalog.estimated_hours,
'x_fc_estimated_cost': catalog.estimated_cost,
})
# Auto-create dispatch task if catalogue says so (in addition to urgency rule).
# Quote-only intakes skip this too.
if (
catalog.auto_schedule
and repair.x_fc_technician_task_count == 0
and not quote_only
):
self._create_dispatch_task(repair)
# ------------------------------------------------------------------
# REPAIR WARRANTY (our 30/90-day re-do free)
# ------------------------------------------------------------------
@api.model
def _check_repair_warranty(self, repair):
if not repair.partner_id:
return
warranty = self.env['fusion.repair.warranty.coverage'].sudo() \
.find_active_for(repair.partner_id.id, repair.product_id.id or None,
repair.lot_id.id or None)
if not warranty:
return
repair.message_post(
body=Markup(_(
'This repair MAY be covered by our active warranty <b>%(ref)s</b> '
'(expires %(exp)s). Manager review recommended before invoicing.'
)) % {
'ref': warranty.name or '',
'exp': warranty.expiry_date and str(warranty.expiry_date) or '',
},
message_type='comment',
)
# ------------------------------------------------------------------
# AI SUMMARY (try/fallback per fusion-api-integration rule)
# ------------------------------------------------------------------
@api.model
def _generate_ai_summary(self, repair, item):
try:
ApiService = self.env.get('fusion.api.service')
if not ApiService:
return
issue = (item.get('issue_summary') or '').strip()
if not issue:
return
category = repair.x_fc_repair_category_id.name or 'medical equipment'
urgency = repair.x_fc_urgency or 'normal'
messages = [
{
'role': 'system',
'content': (
'You are an assistant for a medical equipment repair service. '
'Given an intake note, output ONE short paragraph (under 80 words) '
'briefing the technician about: likely cause, what to bring, and '
'any safety considerations. NEVER provide medical advice. NEVER '
'recommend stopping equipment use. NEVER claim a definitive cause. '
'Plain English, no jargon.'
),
},
{
'role': 'user',
'content': (
f'Equipment category: {category}\n'
f'Urgency: {urgency}\n'
f'Issue: {issue}\n'
f'Notes: {(item.get("internal_notes") or "").strip()}'
),
},
]
summary = ApiService.call_openai(
consumer='fusion_repairs',
feature='intake_triage',
messages=messages,
max_tokens=200,
)
if summary:
repair.x_fc_ai_summary = summary.strip()
except Exception as e:
_logger.info('AI intake summary skipped: %s', e)
# ------------------------------------------------------------------
# ORIGINAL SO AUTO-LINK
# ------------------------------------------------------------------
@api.model
def _auto_link_original_sale_order(self, repair):
if not repair.partner_id or not repair.product_id:
return
SaleOrder = self.env['sale.order'].sudo()
domain = [
('partner_id', '=', repair.partner_id.id),
('state', 'in', ('sale', 'done')),
('order_line.product_id', '=', repair.product_id.id),
]
if repair.lot_id:
domain.append(('order_line.lot_ids', 'in', repair.lot_id.id))
candidate = SaleOrder.search(domain, order='date_order desc', limit=1)
if candidate:
repair.x_fc_original_sale_order_id = candidate
# ------------------------------------------------------------------
# ANSWERS
# ------------------------------------------------------------------
@api.model
def _create_answers(self, repair, answers):
if not answers:
return
Answer = self.env['fusion.repair.intake.answer']
for ans in answers:
qid = ans.get('question_id')
if not qid:
continue
Answer.create({
'repair_id': repair.id,
'question_id': qid,
'value_char': ans.get('value_char'),
'value_text': ans.get('value_text'),
'value_selection': ans.get('value_selection'),
'value_boolean': bool(ans.get('value_boolean')),
'value_integer': int(ans.get('value_integer') or 0),
'value_date': ans.get('value_date') or False,
})
# ------------------------------------------------------------------
# ACTIVITIES
# ------------------------------------------------------------------
@api.model
def _schedule_activities(self, repair):
"""Create the 4 intake activities described in the spec."""
try:
cs_callback_type = self.env.ref('fusion_repairs.mail_activity_type_cs_callback')
tech_dispatch_type = self.env.ref('fusion_repairs.mail_activity_type_tech_dispatch')
manager_review_type = self.env.ref('fusion_repairs.mail_activity_type_manager_review')
except ValueError:
_logger.warning('Repair activity types missing - skipping')
return
# CS callback - always, intake user
repair.activity_schedule(
activity_type_id=cs_callback_type.id,
summary=_('Call client back if any intake info was missing'),
user_id=repair.x_fc_intake_user_id.id or self.env.uid,
)
# Tech dispatch - assigned to responsible user, urgency-adjusted deadline
deadline_days = {'safety': 0, 'urgent': 1, 'normal': 2}.get(repair.x_fc_urgency, 2)
repair.activity_schedule(
activity_type_id=tech_dispatch_type.id,
summary=_('Assign a technician (urgency: %s)', repair.x_fc_urgency),
user_id=repair.user_id.id or self.env.uid,
date_deadline=fields.Date.context_today(self) + timedelta(days=deadline_days),
)
# Manager review - only for third-party equipment
if repair.x_fc_third_party_equipment:
manager_group = self.env.ref(
'fusion_repairs.group_fusion_repairs_manager',
raise_if_not_found=False,
)
manager_user = self.env.user
if manager_group:
# res.groups has no .users field in Odoo 19;
# query via res.users.all_group_ids (Odoo 19 renamed groups_id).
candidate = self.env['res.users'].sudo().search(
[('all_group_ids', 'in', manager_group.ids), ('active', '=', True)],
limit=1,
)
if candidate:
manager_user = candidate
repair.activity_schedule(
activity_type_id=manager_review_type.id,
summary=_('Third-party equipment - manager awareness'),
user_id=manager_user.id,
)
# ------------------------------------------------------------------
# DISPATCH TASK
# ------------------------------------------------------------------
@api.model
def _create_dispatch_task(self, repair):
"""Create a draft fusion.technician.task for urgent / safety repairs.
Phase 1 simple approach: no date/technician assigned, dispatcher confirms.
"""
Task = self.env['fusion.technician.task'].sudo()
try:
vals = {
'partner_id': repair.partner_id.id,
'task_type': 'repair',
'status': 'pending',
'scheduled_date': fields.Date.context_today(self),
'duration_hours': repair.x_fc_estimated_duration or 1.0,
'x_fc_repair_order_id': repair.id,
'description': repair.internal_notes or repair.name,
}
# Bundle 8: allow squeeze / re-dispatch callers to inject a
# specific scheduled_date + time_start + time_end via context so
# fusion_tasks' conflict validator doesn't reject the create.
force_sched = self._context.get('force_schedule') or {}
if force_sched:
vals.update(force_sched)
# technician_id is required AND constrained to x_fc_is_field_staff.
# D2: prefer a tech whose x_fc_repair_skills covers this repair's
# category. Falls back to ANY active field-staff user if no skilled
# tech exists, then to the lowest-id field-staff user as a placeholder.
tech_id = self._context.get('force_tech_id') or self._pick_dispatch_technician(repair)
if not tech_id:
_logger.warning(
'No field-staff user available - skipping auto-dispatch '
'task for repair %s (mark a user as Field Staff under '
'Settings > Users).',
repair.name,
)
return
vals['technician_id'] = tech_id
Task.create(vals)
except Exception as e:
_logger.warning('Failed to auto-create dispatch task for repair %s: %s',
repair.name, e)
@api.model
def _pick_dispatch_technician(self, repair):
"""D2: pick the best technician for the initial dispatch task.
Preference order:
1. The intake user IF they are field staff AND have the skill
2. Any active field-staff user with x_fc_repair_skills covering
the repair's product category
3. Any active field-staff user (no skills filter)
Returns the chosen user id, or False if none found.
"""
Users = self.env['res.users'].sudo()
category = repair.x_fc_repair_category_id
# Try intake user first if they qualify.
if repair.user_id and repair.user_id.x_fc_is_field_staff:
if not category or category in repair.user_id.x_fc_repair_skills:
return repair.user_id.id
# Skills-filtered candidates.
if category:
skilled = Users.search([
('x_fc_is_field_staff', '=', True),
('active', '=', True),
('x_fc_repair_skills', 'in', [category.id]),
], order='id', limit=1)
if skilled:
return skilled.id
# Any active field staff.
fallback = Users.search([
('x_fc_is_field_staff', '=', True),
('active', '=', True),
], order='id', limit=1)
return fallback.id if fallback else False
# ------------------------------------------------------------------
# EMAILS
# ------------------------------------------------------------------
@api.model
def _send_intake_emails(self, repair):
if not self._notifications_enabled():
return
# Client confirmation
if repair.partner_id and repair.partner_id.email:
try:
self.env.ref('fusion_repairs.email_template_intake_received_client') \
.send_mail(repair.id, force_send=False)
except Exception as e:
_logger.warning('Failed to send client intake email for %s: %s',
repair.name, e)
# Office notification
office_emails = self._office_emails(repair.company_id)
if office_emails:
try:
tpl = self.env.ref('fusion_repairs.email_template_intake_received_office')
tpl.with_context(default_email_to=','.join(office_emails)) \
.send_mail(repair.id, force_send=False, email_values={
'email_to': ','.join(office_emails),
})
except Exception as e:
_logger.warning('Failed to send office intake email for %s: %s',
repair.name, e)
@api.model
def _notifications_enabled(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param('fusion_repairs.enable_email_notifications', 'True') == 'True'
@api.model
def _office_emails(self, company):
# Reuse the office notification recipients defined by fusion_claims.
company_sudo = company.sudo()
recipients = getattr(company_sudo, 'x_fc_office_notification_ids', False)
emails = [p.email for p in (recipients or []) if p.email]
if not emails:
_logger.info(
'No office notification recipients configured on company %s - '
'skipping office intake email.',
company.name,
)
return emails

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FusionRepairIntakeTemplate(models.Model):
"""A reusable set of intake questions per medical equipment category.
Each template contains an ordered list of questions; the intake wizard
(and sales-rep / client portals) render these dynamically with
conditional show/hide based on prior answers.
"""
_name = 'fusion.repair.intake.template'
_description = 'Repair Intake Question Template'
_order = 'sequence, name'
name = fields.Char(string='Template Name', required=True, translate=True)
code = fields.Char(
string='Code',
help='Optional stable identifier for referencing this template from code/data.',
)
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(default=True)
is_default = fields.Boolean(
string='Default Fallback',
help='Used when no template is explicitly configured for the selected category. '
'Exactly one template should be flagged as default per company.',
)
description = fields.Html(string='Description', translate=True)
product_category_ids = fields.Many2many(
'fusion.repair.product.category',
'fusion_repair_intake_template_category_rel',
'template_id',
'category_id',
string='Applies to Categories',
help='Categories that automatically select this template during intake.',
)
question_ids = fields.One2many(
'fusion.repair.intake.question',
'template_id',
string='Questions',
copy=True,
)
question_count = fields.Integer(
compute='_compute_question_count',
string='Question Count',
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
@api.depends('question_ids')
def _compute_question_count(self):
for tpl in self:
tpl.question_count = len(tpl.question_ids)
def action_view_questions(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': self.name,
'res_model': 'fusion.repair.intake.question',
'view_mode': 'list,form',
'domain': [('template_id', '=', self.id)],
'context': {'default_template_id': self.id},
}

View File

@@ -0,0 +1,227 @@
# -*- 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,
)
_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'<p>Maintenance visit booked from reminder for contract <b>{self.name}</b>.</p>',
})
self.booking_repair_id = repair
self.message_post(
body=Markup(_(
'Maintenance visit <b>%(ref)s</b> booked from reminder link.'
)) % {'ref': repair.name or ''},
)
return repair
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _spawn_maintenance_contracts(self):
"""Create maintenance contracts for any delivered SO line whose
product has x_fc_maintenance_interval_months > 0."""
Contract = self.env['fusion.repair.maintenance.contract'].sudo()
today = fields.Date.context_today(self)
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
interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0
if interval <= 0:
continue
existing = Contract.search([
('partner_id', '=', so.partner_id.id),
('product_id', '=', product.id),
('original_sale_order_id', '=', so.id),
], limit=1)
if existing:
continue
Contract.create({
'partner_id': so.partner_id.id,
'product_id': product.id,
'original_sale_order_id': so.id,
'interval_months': interval,
'next_due_date': today + relativedelta(months=interval),
'state': 'active',
})

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
x_fc_repair_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Repair Category',
help='Medical equipment category - drives intake template selection and '
'technician skills filter for repairs of this product.',
)
x_fc_warranty_months = fields.Integer(
string='Warranty (Months)',
default=12,
help='Default warranty period for new units of this product. Used to auto-detect '
'warranty status on repair intake (delivery date + warranty months >= today).',
)
x_fc_maintenance_interval_months = fields.Integer(
string='Maintenance Interval (Months)',
default=0,
help='If > 0, delivering a unit of this product auto-creates a maintenance contract '
'with this recurring interval. Phase 3 feature.',
)
x_fc_intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Intake Template Override',
help='Optional override of the intake template normally chosen from the '
'repair category. Leave empty to use category default.',
)
# Bundle 9: store labor warranty granted at point of sale.
x_fc_labor_warranty_years = fields.Integer(
string='Store Labor Warranty (years)',
default=0,
help='Years of store labor warranty granted when this product is sold. '
'0 = no warranty. Setting this triggers a fusion.repair.labor.warranty '
'record per unit on sale-order confirm.',
)

View File

@@ -0,0 +1,380 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Repair AI Service - single guardrailed entry point for client-portal AI.
Per the design spec (Appendix A), this AbstractModel:
1) Builds a strict system prompt forbidding medical advice, diagnoses,
stop-using recommendations, etc.
2) Calls fusion.api.service.call_openai() if available (try/fallback per
fusion-api-integration rule - never installs as a hard dep)
3) JSON-schema validates the response and runs a forbidden-phrase regex
4) Always falls back to deterministic fusion.repair.self.check.rule
records on any failure - intake must never be blocked by AI
System prompt + JSON schema live in ir.config_parameter so the office can
refine them without code changes.
"""
import hashlib
import json
import logging
import re
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# ----- Safety filters -----
FORBIDDEN_PATTERNS = [
re.compile(r'\b(diagnos(e|is|ed|ing))\b', re.I),
re.compile(r'\byou have\b', re.I),
re.compile(r'\bmedical condition\b', re.I),
re.compile(r'\b(stop|should\s+stop)\s+using\b', re.I),
re.compile(r'\bconsult\s+(your|a)\s+(doctor|physician|nurse)\b', re.I),
re.compile(r'\b(blood\s+pressure|heart\s+rate|pulse|oxygen)\b', re.I),
re.compile(r'(\$|CAD|USD)\s?\d+', re.I), # No price mentions
]
# Universal hard-escalate: ANY equipment category - fire / smoke / sparks /
# burning / injury / trapped is always an immediate escalation. Word
# boundaries prevent "unhurt" matching "hurt" and "fireman" matching "fire".
UNIVERSAL_ESCALATION_RE = re.compile(
r'\b(fire|smoke|burning|spark|injur(y|ed)|hurt|bleeding|trapped)\b',
re.I,
)
# Category-specific safety symptoms - only fire if the category is flagged
# safety_critical=True on fusion.repair.product.category (stairlifts,
# porch lifts, power wheelchairs). "won.?t" handles both "won't" and "wont".
SAFETY_SYMPTOMS_RE = re.compile(
r"\b(stuck|motor|brake\s*fail|won.?t\s*stop|overshoot)\b",
re.I,
)
DEFAULT_SYSTEM_PROMPT = (
"You are a triage assistant for Fusion Repairs, a Canadian medical "
"equipment service company. Your ONLY job is to suggest 1-3 safe, "
"reversible self-check steps a client can try on their medical equipment "
"before scheduling a technician visit.\n\n"
"ABSOLUTE RULES:\n"
"1. NEVER provide medical advice, diagnoses, or health recommendations.\n"
"2. NEVER claim a definitive cause for the problem.\n"
"3. NEVER recommend stopping use of medical equipment.\n"
"4. NEVER use phrases like 'you have', 'I diagnose', 'you should stop', "
"'medical condition', 'consult your doctor'.\n"
"5. ONLY suggest steps that are: safe, reversible, require no tools, "
"take under 2 minutes, and pose zero risk to the client or equipment.\n"
"6. If symptoms involve smoke, sparks, burning smell, motors on "
"stairlifts/porch lifts, OR if you are uncertain -> return "
"escalate_immediately: true.\n"
"7. Maximum 3 steps. Each step <= 1 sentence. Grade-6 reading level. "
"No technical jargon.\n"
"8. NEVER reference part numbers, prices, or other clients.\n"
"9. If client reports injury, equipment fire, or person trapped -> "
"escalate_immediately: true with escalation_reason: 'emergency'.\n"
"10. You MUST output valid JSON matching the provided schema. No prose, "
"no markdown, no commentary."
)
class FusionRepairAIService(models.AbstractModel):
_name = 'fusion.repair.ai.service'
_description = 'Repair AI Service - guardrailed self-check engine'
# ------------------------------------------------------------------
# PUBLIC API
# ------------------------------------------------------------------
@api.model
def suggest_self_check(self, product_category_id=None, symptoms=None, urgency=None):
"""Return a list of safe self-check steps for the client portal.
Returns a dict with shape:
{
'escalate_immediately': bool,
'escalation_reason': str | None,
'confidence': 'high' | 'medium' | 'low',
'steps': [{'instruction': str, 'expected_result': str,
'safety_note': str | None}, ...],
'source': 'ai' | 'fallback' | 'escalated',
'disclaimer': str,
}
"""
symptoms = [s for s in (symptoms or []) if s]
category = (
self.env['fusion.repair.product.category'].sudo().browse(product_category_id)
if product_category_id else False
)
# Pre-check: hard-escalate for safety-critical category + symptom combos
# without consulting AI. This is BEFORE any AI call so even if AI is
# down we still escalate the right way.
if self._should_hard_escalate(category, symptoms, urgency):
return self._escalated_response('safety')
# Try the AI, fall back to deterministic rules on any failure.
ai_result = self._try_ai(category, symptoms)
if ai_result:
ai_result['source'] = 'ai'
ai_result['disclaimer'] = self._disclaimer()
return ai_result
return self._deterministic_fallback(category, symptoms)
# ------------------------------------------------------------------
# HARD ESCALATION
# ------------------------------------------------------------------
@api.model
def _should_hard_escalate(self, category, symptoms, urgency):
if urgency == 'safety':
return True
text = ' '.join(symptoms)
# Universal: fire / smoke / spark / burning / injury / trapped escalate
# regardless of equipment category. Electrical fire on a hospital bed
# is exactly as urgent as on a stairlift.
if UNIVERSAL_ESCALATION_RE.search(text):
return True
# Category-specific: 'stuck', 'motor', 'brake fail', etc. only escalate
# on safety-critical categories (stairlifts, porch lifts, power chairs).
if category and category.safety_critical and SAFETY_SYMPTOMS_RE.search(text):
return True
return False
@api.model
def _escalated_response(self, reason):
return {
'escalate_immediately': True,
'escalation_reason': reason,
'confidence': 'high',
'steps': [],
'source': 'escalated',
'disclaimer': self._disclaimer(),
}
# ------------------------------------------------------------------
# AI CALL (try/fallback)
# ------------------------------------------------------------------
@api.model
def _try_ai(self, category, symptoms):
try:
ApiService = self.env.get('fusion.api.service')
if not ApiService:
return None
messages = [
{'role': 'system', 'content': self._system_prompt()},
{'role': 'user', 'content': self._user_prompt(category, symptoms)},
]
cache_key = self._cache_key(category, symptoms)
cached = self._cache_get(cache_key)
if cached:
return cached
raw = ApiService.call_openai(
consumer='fusion_repairs',
feature='client_self_triage',
messages=messages,
max_tokens=400,
)
if not raw:
return None
parsed = self._safe_parse(raw)
if not parsed:
self._log_incident('parse_failed', raw)
return None
self._cache_set(cache_key, parsed)
return parsed
except Exception as e:
_logger.info('AI self-check skipped: %s', e)
return None
@api.model
def _system_prompt(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param(
'fusion_repairs.ai_self_check_system_prompt',
DEFAULT_SYSTEM_PROMPT,
)
@api.model
def _user_prompt(self, category, symptoms):
cat_name = category.name if category else 'medical equipment'
return (
f"Equipment category: {cat_name}\n"
f"Reported symptoms: {'; '.join(symptoms) if symptoms else '(none provided)'}\n"
"Output the JSON object only."
)
# ------------------------------------------------------------------
# SAFE PARSE + VALIDATE
# ------------------------------------------------------------------
@api.model
def _safe_parse(self, raw):
"""Parse the AI response, validate against the JSON schema, and run
forbidden-phrase regex filters. Returns None on any failure - caller
falls back to deterministic rules."""
if not raw:
return None
text = raw.strip()
# Strip code-fence wrapping if AI added it.
if text.startswith('```'):
text = re.sub(r'^```[a-zA-Z]*\n?', '', text)
text = re.sub(r'\n?```$', '', text)
try:
data = json.loads(text)
except (ValueError, TypeError):
return None
# Schema check (minimal - we don't pull in jsonschema as a dep)
if not isinstance(data, dict):
return None
if not isinstance(data.get('escalate_immediately'), bool):
return None
confidence = data.get('confidence')
if confidence not in ('high', 'medium', 'low'):
return None
steps = data.get('steps')
if not isinstance(steps, list) or len(steps) > 3:
return None
# Coherence: not-escalated must have at least one step.
if not data['escalate_immediately'] and not steps:
return None
# Per-step validation + forbidden-phrase scan.
cleaned_steps = []
for step in steps:
if not isinstance(step, dict):
return None
instr = step.get('instruction')
expected = step.get('expected_result')
if not isinstance(instr, str) or not instr.strip():
return None
if not isinstance(expected, str) or not expected.strip():
return None
if len(instr) > 200 or len(expected) > 200:
return None
if self._contains_forbidden(instr) or self._contains_forbidden(expected):
return None
note = step.get('safety_note')
if note is not None and (not isinstance(note, str) or len(note) > 200):
return None
if note and self._contains_forbidden(note):
return None
cleaned_steps.append({
'instruction': instr.strip(),
'expected_result': expected.strip(),
'safety_note': (note or '').strip() or None,
})
return {
'escalate_immediately': data['escalate_immediately'],
'escalation_reason': data.get('escalation_reason') or None,
'confidence': confidence,
'steps': cleaned_steps,
}
@api.model
def _contains_forbidden(self, text):
if not text:
return False
return any(p.search(text) for p in FORBIDDEN_PATTERNS)
# ------------------------------------------------------------------
# DETERMINISTIC FALLBACK
# ------------------------------------------------------------------
@api.model
def _normalise(self, text):
"""Strip punctuation + lowercase so 'wont move' matches 'won't move'
and vice versa.
IMPORTANT: apostrophes are REMOVED (not replaced with space), so
"won't" -> "wont" matches user input "wont" (without apostrophe).
Other punctuation collapses to a single space.
"""
s = (text or "").lower()
# Remove ALL apostrophe variants (straight + curly) so contraction
# forms collide with apostrophe-less forms.
for apos in ("'", "\u2019", "\u2018", "\u02bc"):
s = s.replace(apos, "")
# Everything else non-alphanumeric -> single space.
return re.sub(r"[^a-z0-9 ]+", " ", s)
@api.model
def _deterministic_fallback(self, category, symptoms):
"""Look up fusion.repair.self.check.rule records for the category
and return the matching steps. Used when AI is unavailable or
returns invalid / unsafe content."""
Rule = self.env['fusion.repair.self.check.rule'].sudo()
steps = []
if category:
haystack = self._normalise(' '.join(symptoms))
rules = Rule.search([
('category_id', '=', category.id),
('active', '=', True),
], order='sequence')
for r in rules:
kws = [
self._normalise(k)
for k in (r.symptom_keywords or '').split(',')
if k.strip()
]
if not kws or any(kw and kw in haystack for kw in kws):
steps.append({
'instruction': r.instruction or '',
'expected_result': r.expected_result or '',
'safety_note': r.safety_note or None,
})
if len(steps) >= 3:
break
return {
'escalate_immediately': len(steps) == 0,
'escalation_reason': None if steps else 'no_match',
'confidence': 'medium' if steps else 'low',
'steps': steps,
'source': 'fallback',
'disclaimer': self._disclaimer(),
}
# ------------------------------------------------------------------
# CACHE (in-memory per worker, 24h)
# ------------------------------------------------------------------
_CACHE = {}
_CACHE_TTL = 24 * 3600
@api.model
def _cache_key(self, category, symptoms):
symptom_hash = hashlib.sha256(
('|'.join(sorted(s.lower() for s in symptoms))).encode()
).hexdigest()[:16]
return f"{category.code if category else 'none'}:{symptom_hash}"
@api.model
def _cache_get(self, key):
import time
entry = self._CACHE.get(key)
if not entry:
return None
ts, value = entry
if time.time() - ts > self._CACHE_TTL:
self._CACHE.pop(key, None)
return None
return value
@api.model
def _cache_set(self, key, value):
import time
# Bound cache size to ~512 entries.
if len(self._CACHE) > 512:
self._CACHE.clear()
self._CACHE[key] = (time.time(), value)
# ------------------------------------------------------------------
# MISC
# ------------------------------------------------------------------
@api.model
def _disclaimer(self):
return ("This is not medical advice. If you're unsure, schedule a "
"technician visit. In an emergency, call 9-1-1.")
@api.model
def _log_incident(self, kind, raw):
_logger.warning('AI self-check incident (%s): %s', kind, (raw or '')[:300])

View File

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Service callout rate card.
When we dispatch a tech to a client's home (as opposed to in-store work),
this rate card answers "what do we charge?" for any combination of:
* tier (regular hours / after-hours / weekend / statutory holiday)
* number of technicians dispatched
* actual labour hours billable (after the 30 min the callout fee covers)
* round-trip travel kilometres beyond the threshold
The Bundle 8 emergency surcharge sits ON TOP of this when CS flags a
repair as a rush (same-day squeeze, etc.). They are separate concepts:
- callout rate = the BASELINE house-call price
- emergency surcharge = added "drop everything" premium
"""
from odoo import _, api, fields, models
class FusionRepairCalloutRate(models.Model):
_name = 'fusion.repair.callout.rate'
_description = 'Service Callout Rate Card'
_order = 'effective_from desc, tier, id'
name = fields.Char(compute='_compute_name', store=True)
tier = fields.Selection(
[
('regular', 'Regular Business Hours'),
('rush', 'Rush Service'),
('after_hours', 'After Hours (weekday evening)'),
('weekend', 'Weekend'),
('holiday', 'Statutory Holiday'),
],
string='Tier',
required=True,
default='regular',
)
# Bundle 10: Westin's rate card splits by equipment class. Lift &
# Elevating Service ($160 callout / $110 labour) is distinct from
# Standard Service ($95 callout / $85 labour). The lookup falls back
# from (tier, equipment_class) to (tier, 'standard').
equipment_class = fields.Selection(
[
('standard', 'Standard Service'),
('lift_elevating', 'Lift & Elevating Service'),
],
string='Equipment Class',
default='standard',
required=True,
)
# ---- Base callout (covers first 30 minutes of labour) ----
base_callout_fee = fields.Monetary(
string='Base Callout Fee (1 tech)',
currency_field='currency_id',
required=True,
default=0.0,
help='Charge for dispatching one technician to the client. INCLUDES '
'the first 30 minutes for inspection / check / report. Repair '
'labour above the 30 min is charged hourly at hourly_labor_rate.',
)
second_tech_fee = fields.Monetary(
string='Second Technician Fee',
currency_field='currency_id',
default=0.0,
help='Added to the callout when a 2nd technician is dispatched alongside '
'the first. Lower than a second base callout because they share '
'travel.',
)
additional_tech_fee = fields.Monetary(
string='Each Additional Technician Fee',
currency_field='currency_id',
default=0.0,
help='Applied to the 3rd, 4th... technician on the same callout. '
'Defaults to second_tech_fee if left zero.',
)
# ---- Hourly labour (after the included 30 min) ----
hourly_labor_rate = fields.Monetary(
string='On-Site Hourly Labour Rate (per tech)',
currency_field='currency_id',
required=True,
default=0.0,
help='Per-technician hourly rate applied to billable labour above the '
'30 min the callout covers. Minimum bill is minimum_labor_hours '
'even if the tech finished faster.',
)
# Bundle 10: separate IN-SHOP hourly rate. When the client brings the unit
# to the store (no callout, no travel) we charge a lower hourly rate.
in_shop_labor_rate = fields.Monetary(
string='In-Shop Hourly Labour Rate',
currency_field='currency_id',
default=0.0,
help='Hourly rate when work is done IN THE STORE (no callout fee, no '
'travel). Per Westin rate card: $75 standard / $110 lift.',
)
minimum_labor_hours = fields.Float(
string='Minimum Billable Hours',
default=1.0,
help='Round-up floor for labour beyond the included 30 min. Set 1.0 '
'so a 20-minute fix still bills 1.0 hours. Hours beyond the floor '
'are pro-rated in 30-minute increments per the published card.',
)
# ---- Travel ----
travel_distance_threshold_km = fields.Float(
string='Free Travel Distance (km, one-way)',
default=25.0,
help='Travel under this distance is free. Beyond it, every additional '
'kilometre is charged at travel_per_km_fee, BOTH WAYS (so the bill '
'is per-km * (one_way_km - threshold) * 2).',
)
travel_per_km_fee = fields.Monetary(
string='Per-km Fee Over Threshold',
currency_field='currency_id',
default=0.0,
help='Per technician, per kilometre, both ways.',
)
# ---- Bookkeeping ----
effective_from = fields.Date(
string='Effective From',
default=fields.Date.context_today,
required=True,
help='Rate effective from this date. Newer rates supersede older ones '
'for the same (tier, company).',
)
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
active = fields.Boolean(default=True)
description = fields.Text(
help='Optional notes shown to CS / dispatchers - e.g. "applies after 5 PM weekdays".',
)
@api.depends('tier', 'base_callout_fee', 'effective_from')
def _compute_name(self):
for r in self:
tier_label = dict(self._fields['tier'].selection).get(r.tier) or '?'
r.name = (
f'{tier_label} - ${r.base_callout_fee:.0f} callout'
f' (from {r.effective_from})'
)
@api.model
def get_for_tier(self, tier, equipment_class='standard', on_date=None):
"""Return the active rate row for `tier` + `equipment_class` effective
on `on_date`. Tries (tier, class) first, falls back to (tier, standard)
if no class-specific row is configured. Empty recordset if none at all.
"""
on_date = on_date or fields.Date.context_today(self)
Domain = lambda cls: [
('tier', '=', tier),
('equipment_class', '=', cls),
('active', '=', True),
('effective_from', '<=', on_date),
('company_id', 'in', self.env.companies.ids),
]
hit = self.sudo().search(
Domain(equipment_class or 'standard'),
order='effective_from desc', limit=1,
)
if not hit and equipment_class and equipment_class != 'standard':
hit = self.sudo().search(
Domain('standard'),
order='effective_from desc', limit=1,
)
return hit

View File

@@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Repair dashboard data provider.
Feeds the OWL client action `fusion_repairs.dashboard` with KPI counts,
recent activity, and upcoming maintenance. Lives as an AbstractModel
because it stores nothing - all values are computed on demand.
"""
from datetime import datetime, timedelta
from odoo import api, fields, models
class FusionRepairDashboard(models.AbstractModel):
_name = 'fusion.repair.dashboard'
_description = 'Repair Dashboard Data Provider'
@api.model
def get_dashboard_data(self):
"""Return everything the dashboard needs in a single call."""
Repair = self.env['repair.order']
Contract = self.env['fusion.repair.maintenance.contract']
today = fields.Date.context_today(self)
month_start = today.replace(day=1)
thirty_days = today + timedelta(days=30)
# ---------------- KPI counters ----------------
open_domain = [('state', 'not in', ('done', 'cancel'))]
urgent_domain = open_domain + [('x_fc_urgency', 'in', ('urgent', 'safety'))]
new_this_month_domain = [('create_date', '>=', month_start)]
no_task_domain = open_domain + [
('x_fc_technician_task_ids', '=', False),
]
requote_domain = open_domain + [('x_fc_requires_requote', '=', True)]
stats = {
'open_count': Repair.search_count(open_domain),
'urgent_count': Repair.search_count(urgent_domain),
'new_this_month': Repair.search_count(new_this_month_domain),
'awaiting_dispatch': Repair.search_count(no_task_domain),
'requires_requote': Repair.search_count(requote_domain),
'maintenance_due_30d': Contract.search_count([
('state', '=', 'active'),
('next_due_date', '<=', thirty_days),
]),
'maintenance_active_total': Contract.search_count([
('state', '=', 'active'),
]),
}
# ---------------- Source breakdown for the doughnut ----------------
source_rows = Repair._read_group(
open_domain,
['x_fc_intake_source'],
['__count'],
)
source_breakdown = []
source_labels = dict(Repair._fields['x_fc_intake_source'].selection)
for src, count in source_rows:
source_breakdown.append({
'key': src or 'manual',
'label': source_labels.get(src or 'manual', src or 'Other'),
'count': count,
})
# ---------------- Urgency breakdown ----------------
urgency_rows = Repair._read_group(
open_domain,
['x_fc_urgency'],
['__count'],
)
urgency_labels = dict(Repair._fields['x_fc_urgency'].selection)
urgency_breakdown = [{
'key': u or 'normal',
'label': urgency_labels.get(u or 'normal', 'Normal'),
'count': c,
} for u, c in urgency_rows]
# ---------------- Recent service calls (last 5) ----------------
recent = []
for r in Repair.search([], order='create_date desc', limit=5):
recent.append({
'id': r.id,
'name': r.name,
'partner_name': r.partner_id.name or '',
'category': r.x_fc_repair_category_id.name or '',
'urgency': r.x_fc_urgency,
'state': r.state,
'state_label': dict(Repair._fields['state'].selection).get(r.state, r.state),
'create_date': fields.Datetime.to_string(r.create_date),
'source': r.x_fc_intake_source or '',
'source_label': source_labels.get(r.x_fc_intake_source, ''),
})
# ---------------- Upcoming maintenance (next 5 due) ----------------
upcoming = []
for c in Contract.search(
[('state', '=', 'active'), ('next_due_date', '!=', False)],
order='next_due_date asc', limit=5,
):
upcoming.append({
'id': c.id,
'name': c.name,
'partner_name': c.partner_id.name or '',
'product_name': c.product_id.display_name or '',
'next_due_date': fields.Date.to_string(c.next_due_date),
'days_until': (c.next_due_date - today).days if c.next_due_date else 0,
'reminder_band': c.last_reminder_band or '',
})
# ---------------- Portal URLs (resolved server-side) ----------------
ICP = self.env['ir.config_parameter'].sudo()
base_url = ICP.get_param('web.base.url', '').rstrip('/')
portals = {
'client_portal_url': base_url + (ICP.get_param(
'fusion_repairs.client_portal_url', '/repair'
) or '/repair'),
'sales_rep_portal_url': base_url + '/my/repair/new',
}
# ---------------- M7: failure-rate analytics ----------------
# Top products by repair count in the last 90 days (excludes draft).
ninety = datetime.now() - timedelta(days=90)
failure_rows = Repair._read_group(
[
('create_date', '>=', ninety),
('product_id', '!=', False),
('state', '!=', 'cancel'),
],
['product_id'],
['__count'],
order='__count desc',
limit=8,
)
failures_by_product = [{
'product_id': p.id,
'product_name': p.display_name,
'repair_count': c,
} for p, c in failure_rows]
# Top symptom categories (issue_category) in the last 90 days.
symptom_rows = Repair._read_group(
[
('create_date', '>=', ninety),
('x_fc_issue_category', '!=', False),
('state', '!=', 'cancel'),
],
['x_fc_issue_category'],
['__count'],
order='__count desc',
limit=8,
)
failures_by_symptom = [{
'symptom': s or 'Other',
'repair_count': c,
} for s, c in symptom_rows]
# M9: margin summary (open + done in the last 90 days).
margin_rows = self.env['repair.order'].search([
('create_date', '>=', ninety),
('state', '!=', 'cancel'),
])
total_revenue = sum(margin_rows.mapped('x_fc_revenue'))
total_labour = sum(margin_rows.mapped('x_fc_labour_cost'))
total_parts = sum(margin_rows.mapped('x_fc_parts_cost'))
total_margin = total_revenue - total_labour - total_parts
margin_summary = {
'revenue': total_revenue,
'labour_cost': total_labour,
'parts_cost': total_parts,
'margin': total_margin,
'margin_pct': (total_margin / total_revenue * 100) if total_revenue else 0.0,
'sample_size': len(margin_rows),
}
return {
'stats': stats,
'urgency_breakdown': urgency_breakdown,
'source_breakdown': source_breakdown,
'recent': recent,
'upcoming': upcoming,
'portals': portals,
'failures_by_product': failures_by_product,
'failures_by_symptom': failures_by_symptom,
'margin_summary': margin_summary,
}

View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Delivery / pickup rate card (separate from repair callouts).
Per Westin's published rate card the DELIVERY / PICKUP CHARGES section is
a distinct service from repair callouts. These are charged when we move
equipment (drop-off of a sold unit, post-repair return delivery, removal
of old equipment, etc.).
"""
from odoo import api, fields, models
class FusionRepairDeliveryCharge(models.Model):
_name = 'fusion.repair.delivery.charge'
_description = 'Delivery / Pickup Rate Card'
_order = 'sequence, charge_type, id'
name = fields.Char(compute='_compute_name', store=True)
sequence = fields.Integer(default=10)
charge_type = fields.Selection(
[
('local', 'Local Service Area'),
('outside', 'Outside Local Area'),
('rush', 'Rush Pickup / Delivery'),
('lift_chair_install', 'Lift Chair Delivery and Set-Up'),
('hospital_bed_install', 'Hospital Bed Delivery and Set-Up'),
('stairlift_install', 'Stairlift Delivery and Set-Up'),
('stairlift_removal', 'Stairlift Removal'),
('other', 'Other'),
],
string='Charge Type',
required=True,
)
amount = fields.Monetary(
string='Amount',
currency_field='currency_id',
required=True,
)
travel_per_km_fee = fields.Monetary(
string='Per-km Fee (Rush, 2-way)',
currency_field='currency_id',
default=0.0,
help='Only applies to rush pickups/deliveries. Per the published card: '
'$60 plus $0.70 per km x 2-way.',
)
travel_distance_threshold_km = fields.Float(
string='Free Travel Distance (km, 2-way)',
default=0.0,
help='Only applies to rush. Above this km, every additional km is '
'charged travel_per_km_fee BOTH WAYS.',
)
description = fields.Text(translate=True)
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
)
active = fields.Boolean(default=True)
@api.depends('charge_type', 'amount')
def _compute_name(self):
for r in self:
label = dict(self._fields['charge_type'].selection).get(r.charge_type) or '?'
r.name = f'{label} - ${r.amount:.0f}'
@api.model
def get_charge(self, charge_type):
"""Return the active rate row for `charge_type`, empty recordset if none."""
return self.sudo().search([
('charge_type', '=', charge_type),
('active', '=', True),
('company_id', 'in', self.env.companies.ids),
], limit=1)
@api.model
def quote_rush(self, distance_km):
"""Convenience: returns the total for a Rush Pickup / Delivery at
`distance_km` one-way. Returns 0.0 if no rush row configured."""
rush = self.get_charge('rush')
if not rush:
return 0.0
over = max(distance_km - rush.travel_distance_threshold_km, 0.0)
return rush.amount + (over * 2.0 * rush.travel_per_km_fee)

View File

@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Emergency / rush service rate card.
The pissed-off-grumpy-client scenario: stairlift dead at 5 PM Friday, needs
service yesterday. Office bumps them into today's route OR books them
priority for tomorrow OR (if after-hours / weekend) charges an emergency
surcharge. Sometimes more than one technician is needed (e.g. lifting an
adjustable bed back onto its frame) - per_tech_multiplier handles that.
Pricing logic on repair.order:
surcharge = base_amount + base_amount * per_tech_multiplier *
(techs_required - 1)
Example: same-day stairlift, 1 tech, base $250, multiplier 0.5
-> $250 surcharge
Example: same-day stairlift, 2 techs (one to hold, one to wrench)
-> $250 + $250 * 0.5 * 1 = $375 surcharge
"""
from odoo import _, api, fields, models
class FusionRepairEmergencyCharge(models.Model):
_name = 'fusion.repair.emergency.charge'
_description = 'Rush / Emergency Service Surcharge Rate'
_order = 'category_id, tier'
name = fields.Char(
compute='_compute_name',
store=True,
)
category_id = fields.Many2one(
'fusion.repair.product.category',
string='Equipment Category',
required=True,
ondelete='cascade',
index=True,
)
tier = fields.Selection(
[
('same_day', 'Same Day (during business hours)'),
('next_day', 'Next Day Priority'),
('after_hours', 'After Hours (5pm-9pm weekday)'),
('weekend', 'Weekend'),
('holiday', 'Statutory Holiday'),
],
string='Tier',
required=True,
)
base_amount = fields.Monetary(
string='Base Surcharge',
currency_field='currency_id',
required=True,
default=0.0,
help='Surcharge for ONE technician on top of the normal labour / parts cost.',
)
per_tech_multiplier = fields.Float(
string='Additional Tech Multiplier',
default=0.5,
help='Each additional technician adds base_amount * this multiplier '
'to the surcharge. Default 0.5 means tech #2 costs half the base.',
)
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
active = fields.Boolean(default=True)
description = fields.Text(
help='Internal note - shown to CS when they pick this tier in the wizard.',
)
_cat_tier_unique = models.Constraint(
'unique(category_id, tier, company_id)',
'Only one emergency-charge row per (category, tier, company).',
)
@api.depends('category_id', 'tier', 'base_amount')
def _compute_name(self):
for r in self:
tier_label = dict(self._fields['tier'].selection).get(r.tier) or '?'
cat = r.category_id.name or '?'
r.name = f'{cat} - {tier_label} (${r.base_amount:.0f})'
@api.model
def calculate(self, category, tier, techs_required=1):
"""Return the surcharge for the given category + tier + tech count,
or 0.0 if no rate is configured."""
if not category or not tier or techs_required < 1:
return 0.0
rate = self.sudo().search([
('category_id', '=', category.id),
('tier', '=', tier),
('active', '=', True),
('company_id', 'in', self.env.companies.ids),
], limit=1)
if not rate:
return 0.0
extra = max(techs_required - 1, 0)
return rate.base_amount + (rate.base_amount * rate.per_tech_multiplier * extra)

View 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

View File

@@ -0,0 +1,231 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Store labor warranty.
Distinct from the manufacturer warranty. This is what we extend at point
of sale: "5-year labor warranty - bring it to the store, we fix the labour
for free". Carve-outs (user negligence, gross negligence, misuse, etc.)
are tracked explicitly so the visit-report wizard can VOID the warranty
in real time when the tech encounters one.
Important boundary - WHAT THE WARRANTY COVERS:
- In-store labour: FREE
- Home callout (tech dispatched): callout fee STILL applies (it includes
inspection / report); the hourly labour beyond 30 min is free
- Parts: NEVER free unless covered by separate manufacturer warranty
- Travel: ALWAYS charged when over the distance threshold
"""
from dateutil.relativedelta import relativedelta
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
VOID_REASONS = [
('user_negligence', 'User Negligence'),
('gross_negligence', 'Gross Negligence'),
('misuse', 'Misuse'),
('over_recommended_use', 'Over-Recommended Use'),
('accidental_damage', 'Accidental Damage'),
('not_covered_part', 'Part Not Covered'),
('other', 'Other (see notes)'),
]
class FusionRepairLaborWarranty(models.Model):
_name = 'fusion.repair.labor.warranty'
_inherit = ['mail.thread']
_description = 'Store Labor Warranty'
_order = 'end_date desc, id desc'
name = fields.Char(
string='Reference',
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,
tracking=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial',
tracking=True,
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sold On',
ondelete='set null',
tracking=True,
)
warranty_years = fields.Integer(
string='Years',
default=5,
required=True,
tracking=True,
)
start_date = fields.Date(
string='Start',
default=fields.Date.context_today,
required=True,
tracking=True,
)
end_date = fields.Date(
string='Ends',
compute='_compute_end_date',
store=True,
tracking=True,
)
state = fields.Selection(
[
('active', 'Active'),
('expired', 'Expired'),
('void', 'Void'),
('consumed', 'Consumed'),
],
string='Status',
default='active',
tracking=True,
compute='_compute_state',
store=True,
)
# When voided
void_reason = fields.Selection(
VOID_REASONS,
string='Void Reason',
tracking=True,
)
void_notes = fields.Text(string='Void Notes')
voided_at = fields.Datetime(string='Voided At', copy=False)
voided_by_id = fields.Many2one('res.users', string='Voided By', copy=False)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
_name_unique = models.Constraint(
'unique(name)',
'Labor-warranty references must be unique.',
)
# ------------------------------------------------------------------
# CRUD
# ------------------------------------------------------------------
@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.labor.warranty'
) or 'LW/NEW'
return super().create(vals_list)
# ------------------------------------------------------------------
# COMPUTES
# ------------------------------------------------------------------
@api.depends('start_date', 'warranty_years')
def _compute_end_date(self):
for r in self:
if r.start_date and r.warranty_years:
r.end_date = r.start_date + relativedelta(years=r.warranty_years)
else:
r.end_date = False
@api.depends('void_reason', 'end_date')
def _compute_state(self):
today = fields.Date.context_today(self)
for r in self:
if r.state == 'consumed':
continue
if r.void_reason:
r.state = 'void'
elif r.end_date and r.end_date < today:
r.state = 'expired'
else:
r.state = 'active'
# ------------------------------------------------------------------
# LOOKUP
# ------------------------------------------------------------------
@api.model
def find_active_for(self, partner, product=None, lot=None):
"""Find the active labor warranty covering (partner, product/lot).
Specificity order:
1. exact lot match
2. product + partner match
3. partner-only match (last resort)
"""
if not partner:
return self.browse()
today = fields.Date.context_today(self)
base_domain = [
('partner_id', '=', partner.id),
('state', '=', 'active'),
('end_date', '>=', today),
]
if lot:
hit = self.sudo().search(
base_domain + [('lot_id', '=', lot.id)],
order='end_date desc', limit=1,
)
if hit:
return hit
if product:
hit = self.sudo().search(
base_domain + [('product_id', '=', product.id)],
order='end_date desc', limit=1,
)
if hit:
return hit
return self.browse()
# ------------------------------------------------------------------
# VOID
# ------------------------------------------------------------------
def action_void(self, reason='other', notes=''):
if not reason:
raise UserError(_('A void reason is required.'))
for r in self:
r.write({
'void_reason': reason,
'void_notes': notes,
'voided_at': fields.Datetime.now(),
'voided_by_id': self.env.uid,
})
r.message_post(body=Markup(_(
'Warranty <b>voided</b> by %(user)s. Reason: %(reason)s.'
)) % {
'user': self.env.user.name,
'reason': dict(VOID_REASONS).get(reason, reason),
})
def action_reinstate(self):
for r in self:
r.write({
'void_reason': False,
'void_notes': False,
'voided_at': False,
'voided_by_id': False,
})
r.message_post(body=_('Warranty reinstated.'))

View File

@@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""On-call service - finds the next on-call manager and pages them.
Triggered when a safety-flagged repair comes in outside business hours
(or any time, if the user wants to call us about a stuck stairlift).
Per the design spec section "Weekend safety escalation":
1. 911 disclaimer is shown to the client
2. repair.order created with priority=high + Monday-followup activity
3. Page next on-call manager (lowest x_fc_on_call_priority among
active users with x_fc_on_call=True)
4. SMS + email sent; tokenized /repair/on-call/ack/<token> for ack
5. 15-minute escalation cron pages next priority if first doesn't ack
6. All actions logged to repair chatter
Phase 2 ships with priority-int sorting; Phase 4 will replace with proper
shift scheduling (date ranges per on-call user).
"""
import logging
import secrets
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class FusionRepairOnCallService(models.AbstractModel):
_name = 'fusion.repair.on.call.service'
_description = 'Repair On-Call Paging Service'
# ------------------------------------------------------------------
# PUBLIC API
# ------------------------------------------------------------------
@api.model
def find_next_on_call(self, exclude_user_ids=None, company_id=None):
"""Return the highest-priority active on-call user, or empty recordset.
Multi-company aware: when `company_id` is supplied, restricts to users
who belong to that company.
"""
exclude_user_ids = exclude_user_ids or []
Users = self.env['res.users'].sudo()
domain = [
('x_fc_on_call', '=', True),
('active', '=', True),
('id', 'not in', exclude_user_ids),
]
if company_id:
domain.append(('company_ids', 'in', company_id))
return Users.search(
domain, order='x_fc_on_call_priority asc, id asc', limit=1,
)
@api.model
def page_on_call(self, repair, force=False):
"""Page the next on-call manager for the given repair.
- Excludes anyone already acknowledged this cycle.
- Excludes the currently paged user (cron escalates to the NEXT priority).
- Skips during business hours unless force=True.
- Posts truthful chatter (different line on email send failure).
"""
repair.ensure_one()
if not force and self._is_business_hours():
_logger.info('On-call page skipped for %s - inside business hours',
repair.name)
return self.env['res.users']
# CRITICAL: also exclude the currently-paged user so cron escalation
# actually moves to the NEXT priority instead of re-paging the same
# person forever.
exclude = set(repair.x_fc_on_call_acknowledged_user_ids.ids)
if repair.x_fc_on_call_paged_user_id:
exclude.add(repair.x_fc_on_call_paged_user_id.id)
target = self.find_next_on_call(
exclude_user_ids=list(exclude),
company_id=repair.company_id.id,
)
if not target:
self._notify_office_no_oncall(repair)
return self.env['res.users']
token = secrets.token_urlsafe(20)
repair.write({
'x_fc_on_call_token': token,
'x_fc_on_call_paged_user_id': target.id,
'x_fc_on_call_paged_at': fields.Datetime.now(),
})
sent_ok = self._send_page_email(repair, target, token)
if sent_ok:
self._post_chatter(repair, target)
else:
# Truthful chatter when SMTP fails so the office can react.
repair.message_post(body=Markup(_(
'<b>Safety paged %(name)s but the page email failed to send.</b> '
'Verify SMTP and retry, or contact the on-call manager directly.'
)) % {'name': target.name or target.login or ''})
return target
@api.model
def acknowledge(self, repair, user):
"""Mark a repair's on-call page as acknowledged by `user`."""
repair.ensure_one()
repair.x_fc_on_call_acknowledged_user_ids = [(4, user.id)]
repair.x_fc_on_call_acknowledged_at = fields.Datetime.now()
repair.message_post(body=Markup(_(
'On-call page <b>acknowledged</b> by %s.'
)) % (user.name or user.login or ''))
@api.model
def cron_escalate_unacknowledged(self):
"""Cron: re-page the next priority for any repair whose first page
is older than 15 minutes without acknowledgement."""
ICP = self.env['ir.config_parameter'].sudo()
try:
window_min = int(ICP.get_param(
'fusion_repairs.on_call_escalate_minutes', '15'
))
except (ValueError, TypeError):
window_min = 15
cutoff = fields.Datetime.now() - timedelta(minutes=window_min)
Repair = self.env['repair.order'].sudo()
stale = Repair.search([
('x_fc_on_call_paged_at', '!=', False),
('x_fc_on_call_paged_at', '<=', cutoff),
('x_fc_on_call_acknowledged_at', '=', False),
('state', 'not in', ('done', 'cancel')),
])
# page_on_call now excludes the currently-paged user internally
# (see exclude set), so a plain call escalates to the next priority.
for r in stale:
self.page_on_call(r, force=True)
# ------------------------------------------------------------------
# HELPERS
# ------------------------------------------------------------------
@api.model
def _is_business_hours(self):
"""True when within the company resource_calendar's working time."""
cal = self.env.company.resource_calendar_id
if not cal:
return False # Treat "no calendar" as always after-hours so we always page.
now = fields.Datetime.now()
try:
return bool(cal._work_intervals_batch(now, now)[False])
except Exception:
return False
@api.model
def _send_page_email(self, repair, target, token):
"""Send the page email, return True on success, False on failure.
force_send=True because this is the single most time-critical email
in the module - mail queue latency would defeat the point.
"""
try:
tpl = self.env.ref(
'fusion_repairs.email_template_on_call_page',
raise_if_not_found=False,
)
if not tpl:
_logger.warning('On-call email template missing - cannot page %s', target.login)
return False
tpl.with_context(
on_call_token=token,
on_call_user=target,
).send_mail(repair.id, force_send=True, email_values={
'email_to': target.email or target.partner_id.email or '',
})
return True
except Exception as e:
_logger.warning('On-call page email failed for repair %s: %s',
repair.name, e)
return False
@api.model
def _post_chatter(self, repair, target):
repair.message_post(body=Markup(_(
'After-hours <b>safety paged</b> %(name)s '
'(priority %(p)s). Awaiting acknowledgement.'
)) % {
'name': target.name or target.login or '',
'p': str(target.x_fc_on_call_priority or 99),
})
@api.model
def _notify_office_no_oncall(self, repair):
_logger.error(
'No on-call user configured (x_fc_on_call=True) - safety repair '
'%s will queue for Monday with no page.',
repair.name,
)
repair.message_post(body=Markup(_(
'<span style="color:#c00"><b>WARNING:</b> No on-call user '
'configured. This safety repair was queued but no one was paged. '
'Configure x_fc_on_call on a manager.</span>'
)))
# Also send a real email to the company's office notification
# recipients so this doesn't get lost in chatter at 11 PM Saturday.
company_sudo = repair.company_id.sudo()
recipients = getattr(company_sudo, 'x_fc_office_notification_ids', False)
emails = [p.email for p in (recipients or []) if p.email]
if not emails:
return
try:
self.env['mail.mail'].sudo().create({
'subject': '[CRITICAL] No on-call user configured - %s' % repair.name,
'body_html': (
'<p>Safety repair <strong>%s</strong> was just submitted '
'but <strong>no on-call user is configured</strong> '
'(x_fc_on_call=True). No one was paged.</p>'
'<p>Set the flag on at least one manager so the next '
'after-hours safety call is paged.</p>'
) % repair.name,
'email_to': ','.join(emails),
}).send()
except Exception as e:
_logger.warning('Failed to send no-on-call office alert: %s', e)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,252 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Parts-ordering workflow.
When the tech arrives, diagnoses, and discovers the unit needs a part we
don't stock (most common with manufacturer-specific items like Handicare
stairlift control boards), they capture the part info via the mobile
visit-report wizard in a structured way so:
1. Office can order from the manufacturer in one click (description + OEM
part number + photos are exactly what procurement needs)
2. Client gets an immediate "we found the problem - here's the timeline" email
3. When parts arrive, office marks the order received and the system
auto-creates a follow-up dispatch task
The grumpy-old-client never has to call us asking for status updates.
"""
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models
class FusionRepairPartOrder(models.Model):
_name = 'fusion.repair.part.order'
_inherit = ['mail.thread']
_description = 'Repair Part Order'
_order = 'create_date desc, id desc'
name = fields.Char(
string='Reference',
default='New',
copy=False,
readonly=True,
tracking=True,
)
repair_order_id = fields.Many2one(
'repair.order',
string='Repair',
required=True,
ondelete='cascade',
index=True,
)
partner_id = fields.Many2one(
related='repair_order_id.partner_id',
store=True,
readonly=True,
)
description = fields.Char(
string='Part Description',
required=True,
tracking=True,
help='Plain English - what the tech needs (e.g. "Handicare 1100 control board, '
'silver casing").',
)
oem_part_number = fields.Char(
string='OEM Part Number',
tracking=True,
help='If the tech could read a part number off the broken component.',
)
manufacturer = fields.Char(
string='Manufacturer',
tracking=True,
)
quantity = fields.Float(
string='Quantity',
default=1.0,
required=True,
)
notes = fields.Text(
string='Tech Notes',
help='Anything procurement needs to know (alternative SKUs, colour, '
'dimensions, etc.)',
)
photo_ids = fields.Many2many(
'ir.attachment',
'fusion_repair_part_order_photo_rel',
'part_order_id', 'attachment_id',
string='Photos',
help='Photos of the broken part / label / packaging. The more the better.',
)
state = fields.Selection(
[
('draft', 'Captured by Tech'),
('ordered', 'Ordered from Manufacturer'),
('received', 'Received in Warehouse'),
('fitted', 'Fitted - Repair Complete'),
('cancelled', 'Cancelled'),
],
string='Status',
default='draft',
tracking=True,
copy=False,
)
ordered_date = fields.Date(string='Ordered On', tracking=True)
expected_date = fields.Date(string='Expected Arrival', tracking=True)
received_date = fields.Date(string='Received On', tracking=True, copy=False)
ordered_by_id = fields.Many2one(
'res.users',
string='Ordered By',
tracking=True,
copy=False,
)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
# ------------------------------------------------------------------
# CRUD
# ------------------------------------------------------------------
@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.part.order'
) or 'PART/NEW'
records = super().create(vals_list)
for rec in records:
rec._post_creation_to_repair()
return records
def _post_creation_to_repair(self):
for rec in self:
rec.repair_order_id.message_post(body=Markup(_(
'Part order <b>%(ref)s</b> captured: %(desc)s '
'(qty %(qty)s%(oem)s).'
)) % {
'ref': rec.name,
'desc': rec.description,
'qty': rec.quantity,
'oem': f' / OEM {rec.oem_part_number}' if rec.oem_part_number else '',
})
# ------------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------------
def action_mark_ordered(self):
"""Office marks this part as ordered with the manufacturer."""
for rec in self:
rec.state = 'ordered'
rec.ordered_date = fields.Date.context_today(rec)
rec.ordered_by_id = self.env.user
if not rec.expected_date:
rec.expected_date = fields.Date.context_today(rec) + timedelta(days=7)
rec._notify_client_parts_ordered()
def action_mark_received(self):
"""Office marks this part as received - triggers follow-up dispatch."""
for rec in self:
rec.state = 'received'
rec.received_date = fields.Date.context_today(rec)
rec._maybe_redispatch()
rec._notify_client_parts_received()
def action_mark_fitted(self):
for rec in self:
rec.state = 'fitted'
def action_cancel(self):
for rec in self:
rec.state = 'cancelled'
# ------------------------------------------------------------------
# WORKFLOW HELPERS
# ------------------------------------------------------------------
def _notify_client_parts_ordered(self):
for rec in self:
tpl = self.env.ref(
'fusion_repairs.email_template_parts_ordered',
raise_if_not_found=False,
)
if tpl and rec.partner_id and rec.partner_id.email:
try:
tpl.send_mail(rec.id, force_send=False)
except Exception:
pass
def _notify_client_parts_received(self):
for rec in self:
tpl = self.env.ref(
'fusion_repairs.email_template_parts_received',
raise_if_not_found=False,
)
if tpl and rec.partner_id and rec.partner_id.email:
try:
tpl.send_mail(rec.id, force_send=False)
except Exception:
pass
def _maybe_redispatch(self):
"""When the LAST outstanding part on a repair arrives, auto-create
a follow-up tech task so the office doesn't have to remember.
Schedules for tomorrow + first free hour slot to avoid colliding
with existing day-of tasks (the fusion_tasks model raises on
time-window conflicts).
"""
from datetime import date as _date
for rec in self:
repair = rec.repair_order_id
outstanding = repair.x_fc_part_order_ids.filtered(
lambda p: p.state in ('draft', 'ordered')
)
if outstanding:
continue # still waiting on other parts
repair.x_fc_parts_awaiting = False
repair.x_fc_parts_eta_date = False
# Find tomorrow's first free slot for the same tech (or
# lightest-loaded skilled tech).
target_date = _date.today() + timedelta(days=1)
target_tech = (
repair.x_fc_technician_task_ids[:1].technician_id.id
if repair.x_fc_technician_task_ids else False
)
if not target_tech:
target_tech = self.env['repair.order'] \
.sudo()._fc_find_lightest_today_tech.__func__(repair)
ctx = {
'force_schedule': {
'scheduled_date': target_date,
'time_start': 9.0,
'time_end': 10.0,
},
}
if target_tech:
ctx['force_tech_id'] = target_tech
try:
self.env['fusion.repair.intake.service'].sudo() \
.with_context(**ctx) \
._create_dispatch_task(repair)
repair.message_post(body=Markup(_(
'All ordered parts received. Auto-dispatched a follow-up '
'visit for <b>%(date)s 09:00 - 10:00</b>.'
)) % {'date': target_date.isoformat()})
except Exception as e:
# If slot 9-10 collides, just log and let the dispatcher
# pick a slot manually - we don't want to swallow the email.
repair.message_post(body=Markup(_(
'All ordered parts received but the auto-dispatch slot '
'%(date)s 09:00-10:00 collided. Please pick a time '
'manually. (%(err)s)'
)) % {'date': target_date.isoformat(), 'err': str(e)})

View File

@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FusionRepairProductCategory(models.Model):
"""Medical equipment categories used to route repair intake and match skills."""
_name = 'fusion.repair.product.category'
_description = 'Repair Product Category'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True, translate=True)
code = fields.Char(
string='Code',
required=True,
help='Stable identifier used by code (e.g. "stairlift"). Lowercase, no spaces.',
)
sequence = fields.Integer(string='Sequence', default=10)
icon = fields.Char(
string='Icon',
default='fa-wrench',
help='Font Awesome icon class shown next to the category in pickers.',
)
description = fields.Text(string='Description', translate=True)
active = fields.Boolean(default=True)
safety_critical = fields.Boolean(
string='Safety-Critical',
help='Categories where motor / mechanical issues warrant immediate escalation '
'(stairlifts, porch lifts). Used by the AI self-check engine to skip '
'self-help and force escalation when safety symptoms appear.',
)
# Bundle 10: aligns Westin's printed rate card - LIFT & ELEVATING SERVICE
# has its own higher rates (stairlifts, porch lifts, lift chairs, hoyer lifts).
equipment_class = fields.Selection(
[
('standard', 'Standard Service'),
('lift_elevating', 'Lift & Elevating Service'),
],
string='Equipment Class',
default='standard',
required=True,
help='Determines which callout rate row applies. Lift & Elevating uses '
'higher per-card rates (e.g. $160 callout vs $95 standard).',
)
intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Default Intake Template',
help='Default intake question set shown when this category is selected.',
)
_code_unique = models.Constraint(
'unique(code)',
'Category code must be unique.',
)
@api.depends('name', 'code')
def _compute_display_name(self):
for cat in self:
cat.display_name = cat.name or cat.code or ''

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Deterministic self-check rules.
Seeded per equipment category + symptom keyword combination. Used by
fusion.repair.ai.service when:
- AI is unavailable (fusion_api not installed / OpenAI down)
- AI returns malformed / unsafe content
- The category has no AI configured
Also rendered directly on the client portal when AI is disabled per spec.
"""
from odoo import fields, models
class FusionRepairSelfCheckRule(models.Model):
_name = 'fusion.repair.self.check.rule'
_description = 'Repair Self-Check Rule (deterministic fallback)'
_order = 'category_id, sequence, id'
name = fields.Char(string='Title', required=True, translate=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
category_id = fields.Many2one(
'fusion.repair.product.category',
string='Equipment Category',
required=True,
index=True,
ondelete='cascade',
)
symptom_keywords = fields.Char(
string='Symptom Keywords',
help='Comma-separated, lowercase. Empty matches any symptom.',
)
instruction = fields.Text(
string='Instruction',
required=True,
translate=True,
help='What to ask the client to do. Plain English, <= 1 sentence.',
)
expected_result = fields.Text(
string='Expected Result',
required=True,
translate=True,
help='What success looks like ("alarm stops", "wheel spins freely").',
)
safety_note = fields.Text(
string='Safety Note',
translate=True,
help='Optional warning shown in red below the instruction.',
)

View File

@@ -0,0 +1,270 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
r"""Pre-paid service plans (M5).
Architecture:
product.template
\--> x_fc_is_service_plan = True
x_fc_plan_visits_included (e.g. 4)
x_fc_plan_duration_months (e.g. 12)
sale.order.confirm()
\--> for each line whose product is a service plan,
create a fusion.repair.service.plan.subscription
(partner + product + visits_included + start_date + end_date)
fusion.repair.maintenance.contract.create_repair_from_booking()
visit_report_wizard.action_confirm()
\--> burns down one visit if the partner has an active matching plan
(for the same product or category)
fusion.repair.dashboard.get_dashboard_data()
\--> exposes active_plan_count + plans_low_count for the dashboard
"""
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
x_fc_is_service_plan = fields.Boolean(
string='Service Plan',
help='Sell this product as a pre-paid maintenance package. '
'Confirming a sale order with this product creates a '
'visit subscription for the customer.',
)
x_fc_plan_visits_included = fields.Integer(
string='Visits Included',
default=4,
help='Number of maintenance visits the customer is entitled to under this plan.',
)
x_fc_plan_duration_months = fields.Integer(
string='Plan Duration (months)',
default=12,
help='Plan ends this many months after the sale-order date even if visits remain.',
)
x_fc_plan_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Plan Category',
help='If set, plan visits only burn down for repairs on equipment of this category. '
'Leave blank to apply to any equipment from this customer.',
)
class FusionRepairServicePlanSubscription(models.Model):
_name = 'fusion.repair.service.plan.subscription'
_inherit = ['mail.thread']
_description = 'Pre-paid Service Plan Subscription'
_order = 'end_date desc, id desc'
name = fields.Char(
string='Reference', 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='Plan Product',
required=True, tracking=True,
domain="[('x_fc_is_service_plan', '=', True)]",
)
category_id = fields.Many2one(
'fusion.repair.product.category',
string='Covers Category',
help='Computed from the plan product. Only burns visits for repairs '
'whose category matches.',
)
sale_order_id = fields.Many2one(
'sale.order', string='Sold On',
ondelete='set null', tracking=True,
)
visits_included = fields.Integer(string='Visits Included', required=True, default=4)
visits_used = fields.Integer(string='Visits Used', default=0, tracking=True)
visits_remaining = fields.Integer(
string='Remaining',
compute='_compute_visits_remaining', store=True,
)
start_date = fields.Date(
string='Start', required=True, default=fields.Date.context_today, tracking=True,
)
end_date = fields.Date(string='Expires', required=True, tracking=True)
state = fields.Selection(
[
('active', 'Active'),
('exhausted', 'Visits Exhausted'),
('expired', 'Expired'),
('cancelled', 'Cancelled'),
],
string='Status',
compute='_compute_state', store=True, tracking=True,
)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
)
burn_history_ids = fields.One2many(
'fusion.repair.service.plan.burn',
'subscription_id',
string='Burn History',
)
# ------------------------------------------------------------------
# CRUD
# ------------------------------------------------------------------
@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.service.plan.subscription'
) or 'PLAN/NEW'
if vals.get('product_id') and not vals.get('end_date'):
product = self.env['product.product'].sudo().browse(vals['product_id'])
months = product.product_tmpl_id.x_fc_plan_duration_months or 12
start = vals.get('start_date') or fields.Date.context_today(self)
vals['end_date'] = fields.Date.from_string(str(start)) + relativedelta(months=months)
if vals.get('product_id') and 'category_id' not in vals:
product = self.env['product.product'].sudo().browse(vals['product_id'])
if product.product_tmpl_id.x_fc_plan_category_id:
vals['category_id'] = product.product_tmpl_id.x_fc_plan_category_id.id
if vals.get('product_id') and 'visits_included' not in vals:
product = self.env['product.product'].sudo().browse(vals['product_id'])
vals['visits_included'] = product.product_tmpl_id.x_fc_plan_visits_included or 4
return super().create(vals_list)
# ------------------------------------------------------------------
# COMPUTES
# ------------------------------------------------------------------
@api.depends('visits_included', 'visits_used')
def _compute_visits_remaining(self):
for s in self:
s.visits_remaining = (s.visits_included or 0) - (s.visits_used or 0)
@api.depends('visits_remaining', 'end_date')
def _compute_state(self):
today = fields.Date.context_today(self)
for s in self:
if s.state == 'cancelled':
continue
if s.end_date and s.end_date < today:
s.state = 'expired'
elif s.visits_remaining <= 0:
s.state = 'exhausted'
else:
s.state = 'active'
# ------------------------------------------------------------------
# BURN ENGINE
# ------------------------------------------------------------------
@api.model
def find_for_repair(self, repair):
"""Return the most-recently-started active subscription covering this
repair (partner match + category match if the plan specifies one)."""
if not repair.partner_id:
return self.browse()
domain = [
('partner_id', '=', repair.partner_id.id),
('state', '=', 'active'),
('visits_remaining', '>', 0),
]
subs = self.search(domain, order='start_date desc')
for s in subs:
if not s.category_id or s.category_id == repair.x_fc_repair_category_id:
return s
return self.browse()
def burn_visit(self, repair):
"""Deduct one visit from this subscription and log the burn."""
self.ensure_one()
if self.visits_remaining <= 0:
return False
self.visits_used += 1
self.env['fusion.repair.service.plan.burn'].sudo().create({
'subscription_id': self.id,
'repair_order_id': repair.id,
'burned_on': fields.Date.context_today(self),
})
self.message_post(body=_(
'Visit burned for repair %s. %s of %s remaining.'
) % (repair.name, self.visits_remaining, self.visits_included))
return True
def action_cancel(self):
for s in self:
s.state = 'cancelled'
s.message_post(body=_('Plan cancelled.'))
class FusionRepairServicePlanBurn(models.Model):
_name = 'fusion.repair.service.plan.burn'
_description = 'Service Plan Visit Burn'
_order = 'burned_on desc, id desc'
subscription_id = fields.Many2one(
'fusion.repair.service.plan.subscription',
string='Subscription', required=True, ondelete='cascade',
)
repair_order_id = fields.Many2one(
'repair.order', string='Repair', required=True, ondelete='cascade',
)
burned_on = fields.Date(string='Burned On', required=True,
default=fields.Date.context_today)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
res = super().action_confirm()
# Spawn subscriptions for each service-plan line.
for order in self:
for line in order.order_line:
tmpl = line.product_id.product_tmpl_id
if not tmpl.x_fc_is_service_plan:
continue
# One subscription per quantity unit (so a SO line with qty=2
# creates two distinct plans - rare but supported).
qty = int(line.product_uom_qty or 1)
for _i in range(max(qty, 1)):
self.env['fusion.repair.service.plan.subscription'].sudo().create({
'partner_id': order.partner_id.id,
'product_id': line.product_id.id,
'sale_order_id': order.id,
'start_date': fields.Date.context_today(self),
})
# Bundle 9: spawn store labor warranties for any product line with
# x_fc_labor_warranty_years > 0.
self._fc_spawn_labor_warranties()
return res
def _fc_spawn_labor_warranties(self):
Warranty = self.env['fusion.repair.labor.warranty'].sudo()
for order in self:
for line in order.order_line:
tmpl = line.product_id.product_tmpl_id
years = tmpl.x_fc_labor_warranty_years or 0
if years <= 0:
continue
# One warranty record per unit so each can be voided
# independently if a specific unit is misused.
qty = int(line.product_uom_qty or 1)
for _i in range(max(qty, 1)):
Warranty.create({
'partner_id': order.partner_id.id,
'product_id': line.product_id.id,
'sale_order_id': order.id,
'warranty_years': years,
'start_date': fields.Date.context_today(self),
})

View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Repair warranty coverage.
Tracks the 30/90-day warranty we offer on completed repair work.
When a new repair is created on the same equipment within the
coverage window, the intake wizard / portal shows a banner:
"This repair may be covered by our warranty - no charge".
Phase 2 ships the model + manual creation from a completed repair.
Phase 4 will add automatic creation when a repair moves to 'done'.
"""
from datetime import timedelta
from odoo import api, fields, models
class FusionRepairWarrantyCoverage(models.Model):
_name = 'fusion.repair.warranty.coverage'
_description = 'Repair Warranty Coverage'
_order = 'expiry_date desc, id desc'
name = fields.Char(string='Reference', compute='_compute_name', store=True)
repair_id = fields.Many2one(
'repair.order',
string='Original Repair',
required=True,
ondelete='cascade',
index=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
related='repair_id.partner_id',
store=True,
index=True,
)
product_id = fields.Many2one(
'product.product',
string='Equipment',
related='repair_id.product_id',
store=True,
index=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
related='repair_id.lot_id',
store=True,
)
start_date = fields.Date(
string='Start Date',
required=True,
default=fields.Date.context_today,
)
coverage_days = fields.Integer(
string='Coverage Window (days)',
default=30,
required=True,
)
expiry_date = fields.Date(
string='Expires',
compute='_compute_expiry_date',
store=True,
)
# Non-stored compute - DO NOT add store=True. The 'active vs not' status is
# time-dependent (today >= expiry_date), and a stored compute would never
# auto-refresh as days pass. find_active_for() filters by expiry_date directly.
is_active = fields.Boolean(
string='Active',
compute='_compute_is_active',
)
notes = fields.Text()
company_id = fields.Many2one(
'res.company',
string='Company',
related='repair_id.company_id',
store=True,
)
@api.depends('repair_id.name', 'expiry_date')
def _compute_name(self):
for w in self:
w.name = (
f"Warranty {w.repair_id.name or '?'} (until {w.expiry_date or '?'})"
)
@api.depends('start_date', 'coverage_days')
def _compute_expiry_date(self):
for w in self:
if w.start_date and w.coverage_days:
w.expiry_date = w.start_date + timedelta(days=w.coverage_days)
else:
w.expiry_date = False
@api.depends('expiry_date')
def _compute_is_active(self):
today = fields.Date.context_today(self)
for w in self:
w.is_active = bool(w.expiry_date and w.expiry_date >= today)
# ------------------------------------------------------------------
# LOOKUP
# ------------------------------------------------------------------
@api.model
def find_active_for(self, partner_id, product_id=None, lot_id=None):
"""Return active warranty coverage matching the partner + equipment, if any.
Requires at least one of lot_id or product_id - without an equipment
identifier we would match any warranty on the partner, which would
falsely flag unrelated equipment as covered.
"""
if not partner_id:
return self.browse()
if not lot_id and not product_id:
return self.browse()
today = fields.Date.context_today(self)
domain = [
('partner_id', '=', partner_id),
('expiry_date', '>=', today),
]
if lot_id:
domain.append(('lot_id', '=', lot_id))
elif product_id:
domain.append(('product_id', '=', product_id))
return self.search(domain, order='expiry_date desc', limit=1)

View File

@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# NOTE: res.config.settings only supports boolean/integer/float/char/
# selection/many2one/datetime types per project Odoo 19 conventions.
fc_repairs_enable_email_notifications = fields.Boolean(
string='Enable Repair Email Notifications',
config_parameter='fusion_repairs.enable_email_notifications',
default=True,
help='Master toggle for automated repair-related emails to clients and office.',
)
fc_repairs_outstanding_balance_threshold = fields.Float(
string='Outstanding Balance Warning ($)',
config_parameter='fusion_repairs.outstanding_balance_threshold',
default=100.0,
help='Show a warning banner during intake if the client has open invoices '
'totalling more than this amount.',
)
fc_repairs_duplicate_call_window_days = fields.Integer(
string='Duplicate Call Window (Days)',
config_parameter='fusion_repairs.duplicate_call_window_days',
default=14,
help='When the intake wizard finds an open repair from this many days back on '
'the same phone number, it offers "add note to existing repair instead".',
)
fc_repairs_variance_threshold_pct = fields.Integer(
string='Pricing Variance Threshold (%)',
config_parameter='fusion_repairs.variance_threshold_pct',
default=20,
help='If actual cost exceeds estimated cost by more than this percentage, '
'invoicing is blocked until a manager reviews / a re-quote email is sent.',
)
fc_repairs_variance_threshold_amount = fields.Float(
string='Pricing Variance Threshold ($)',
config_parameter='fusion_repairs.variance_threshold_amount',
default=100.0,
help='Absolute variance amount that also triggers re-quote (whichever hits first).',
)
fc_repairs_client_portal_url = fields.Char(
string='Public Client Portal URL Path',
config_parameter='fusion_repairs.client_portal_url',
default='/repair',
help='URL path mentioned in voicemail greetings and printed on QR stickers. '
'Phase 1 ships with the form at this path.',
)
fc_repairs_client_portal_rate_limit_per_hour = fields.Integer(
string='Client Portal Rate Limit (per hour, per IP)',
config_parameter='fusion_repairs.client_portal_rate_limit_per_hour',
default=10,
)

View File

@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
PREFERRED_WINDOW = [
('morning', 'Morning (9 AM - 12 PM)'),
('afternoon', 'Afternoon (12 PM - 5 PM)'),
('evening', 'Evening (after 5 PM)'),
('any', 'Any Time'),
]
class ResPartner(models.Model):
_inherit = 'res.partner'
# ------------------------------------------------------------------
# SERVICE PREFERENCES (P1 - shown in client history sidebar)
# ------------------------------------------------------------------
x_fc_preferred_tech_id = fields.Many2one(
'res.users',
string='Preferred Technician',
domain="[('x_fc_is_field_staff', '=', True)]",
help='If set, this technician is suggested first on dispatch.',
)
x_fc_preferred_window = fields.Selection(
PREFERRED_WINDOW,
string='Preferred Visit Window',
default='any',
)
x_fc_access_notes = fields.Text(
string='Access Notes',
help='Free-form notes for technicians arriving at this address: '
'gate code, dog warning, where to park, side door entry, etc.',
)
# ------------------------------------------------------------------
# CLIENT HISTORY SIDEBAR (C2 - pulled lazily on demand)
# ------------------------------------------------------------------
x_fc_repair_count = fields.Integer(
compute='_compute_x_fc_repair_count',
string='Repairs Count',
compute_sudo=True,
help='Lightweight count of repair orders for this partner. Heavier history '
'data is fetched lazily by the wizard / portal sidebar via RPC.',
)
def _compute_x_fc_repair_count(self):
# Non-stored compute - safe to omit @api.depends.
if not self.ids:
for partner in self:
partner.x_fc_repair_count = 0
return
Repair = self.env['repair.order'].sudo()
data = Repair._read_group(
[('partner_id', 'in', self.ids)],
['partner_id'],
['__count'],
)
counts = {row[0].id: row[1] for row in data}
for partner in self:
partner.x_fc_repair_count = counts.get(partner.id, 0)

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class ResUsers(models.Model):
"""Extends res.users with fusion_repairs specific fields.
Reuses the existing x_fc_is_field_staff Boolean from fusion_tasks
as the technician flag - do NOT recreate that field here.
All technician selectors in fusion_repairs use the same domain
[('x_fc_is_field_staff', '=', True)] for consistency with fusion_tasks.
"""
_inherit = 'res.users'
x_fc_repair_skills = fields.Many2many(
'fusion.repair.product.category',
'fusion_repair_user_skill_rel',
'user_id',
'category_id',
string='Repair Skills',
help='Medical equipment categories this user is qualified to service. '
'Used by dispatcher to filter candidate technicians for a repair.',
)
x_fc_tech_cost_rate = fields.Monetary(
string='Tech Cost Rate (/h)',
currency_field='company_currency_id',
help='Internal cost per hour - used for repair margin calculation (Phase 4).',
)
# On-call rotation - Phase 2 (simple priority-int approach).
x_fc_on_call = fields.Boolean(
string='On-Call Eligible',
help='Tick if this user is eligible for the weekend / after-hours on-call rotation.',
)
x_fc_on_call_priority = fields.Integer(
string='On-Call Priority',
default=99,
help='Lower number = paged first. The escalation cron picks the lowest priority '
'available user when a safety repair is submitted after hours.',
)
x_fc_on_call_phone = fields.Char(
string='On-Call Phone Override',
help='Phone number to use for on-call SMS / calls. If empty, falls back to '
'the user partner phone.',
)
company_currency_id = fields.Many2one(
'res.currency',
related='company_id.currency_id',
readonly=True,
)

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""sale.order extensions: smart buttons that link an original purchase SO
to its downstream repairs, maintenance contracts, and repair invoices.
Mirrors the count + action_view_* pattern from
fusion_claims/views/sale_order_views.xml line ~1176.
"""
from odoo import _, api, fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
x_fc_repair_order_ids = fields.One2many(
'repair.order',
'x_fc_original_sale_order_id',
string='Repairs',
)
x_fc_repair_order_count = fields.Integer(
compute='_compute_x_fc_repair_order_count',
)
x_fc_maintenance_contract_ids = fields.One2many(
'fusion.repair.maintenance.contract',
'original_sale_order_id',
string='Maintenance Contracts',
)
x_fc_maintenance_contract_count = fields.Integer(
compute='_compute_x_fc_maintenance_contract_count',
)
@api.depends('x_fc_repair_order_ids')
def _compute_x_fc_repair_order_count(self):
for so in self:
so.x_fc_repair_order_count = len(so.x_fc_repair_order_ids)
@api.depends('x_fc_maintenance_contract_ids')
def _compute_x_fc_maintenance_contract_count(self):
for so in self:
so.x_fc_maintenance_contract_count = len(so.x_fc_maintenance_contract_ids)
def action_view_repair_orders(self):
self.ensure_one()
if len(self.x_fc_repair_order_ids) == 1:
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_repair_order_ids.name,
'res_model': 'repair.order',
'view_mode': 'form',
'res_id': self.x_fc_repair_order_ids.id,
}
return {
'type': 'ir.actions.act_window',
'name': _('Repairs from %(name)s', name=self.name),
'res_model': 'repair.order',
'view_mode': 'list,form',
'domain': [('x_fc_original_sale_order_id', '=', self.id)],
}
def action_view_maintenance_contracts(self):
self.ensure_one()
if len(self.x_fc_maintenance_contract_ids) == 1:
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_maintenance_contract_ids.name,
'res_model': 'fusion.repair.maintenance.contract',
'view_mode': 'form',
'res_id': self.x_fc_maintenance_contract_ids.id,
}
return {
'type': 'ir.actions.act_window',
'name': _('Maintenance Contracts from %(name)s', name=self.name),
'res_model': 'fusion.repair.maintenance.contract',
'view_mode': 'list,form',
'domain': [('original_sale_order_id', '=', self.id)],
}

View File

@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Service catalogue.
Each fusion.repair.service.catalog record is a named repair / maintenance
service (e.g. "Stairlift motor replacement", "Bed remote troubleshoot")
with estimated duration, estimated cost, default parts, and symptom
keywords used to auto-match an intake to the right catalogue entry.
The catalogue feeds:
- intake auto-match -> sets x_fc_service_catalog_id +
x_fc_estimated_duration + x_fc_estimated_cost on the repair
- visit report -> default labour line + parts pre-fill
- pricing variance -> compares estimate vs actual
"""
from odoo import api, fields, models
class FusionRepairServiceCatalog(models.Model):
_name = 'fusion.repair.service.catalog'
_description = 'Repair Service Catalogue Entry'
_order = 'sequence, name'
name = fields.Char(string='Service Name', required=True, translate=True)
code = fields.Char(string='Code', help='Stable identifier (lowercase, no spaces).')
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
# Routing & matching
product_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Equipment Category',
required=True,
index=True,
)
symptom_keywords = fields.Char(
string='Symptom Keywords',
help='Comma-separated keywords used to auto-match an intake to this catalogue entry. '
'Matched against the issue summary, issue category, and intake answer text.',
)
# Service product (what actually gets invoiced)
service_product_id = fields.Many2one(
'product.product',
string='Service Product',
domain=[('type', '=', 'service')],
help='Product line added to the repair sale order for the labour portion.',
)
default_parts_product_ids = fields.Many2many(
'product.product',
'fusion_repair_catalog_parts_rel',
'catalog_id', 'product_id',
string='Default Parts',
help='Parts typically used. Pre-loaded onto the visit report wizard for the tech to confirm.',
)
pricelist_id = fields.Many2one(
'product.pricelist',
string='Pricelist Override',
help='Optional pricelist applied to repair SOs from this catalogue entry. '
'Leave blank to use the partner default pricelist.',
)
# Estimates
estimated_hours = fields.Float(
string='Estimated Labour (h)',
default=1.0,
help='Used to size the technician task and the visit report labour default.',
)
estimated_cost = fields.Monetary(
string='Estimated Cost',
currency_field='company_currency_id',
help='Headline estimate shown to the client/CS during intake. Phase 1 is a flat number; '
'Phase 2+ may compute from labour + parts.',
)
# Automation hints
auto_schedule = fields.Boolean(
string='Auto-Create Tech Task',
help='When True, the intake service creates a draft technician task immediately for any '
'repair matched to this catalogue entry (even at normal urgency).',
)
task_type = fields.Selection(
[('delivery', 'Delivery'), ('repair', 'Repair'), ('pickup', 'Pickup'),
('troubleshoot', 'Troubleshoot'), ('assessment', 'Assessment'),
('installation', 'Installation'), ('maintenance', 'Maintenance'),
('other', 'Other')],
string='Default Task Type',
default='repair',
)
company_currency_id = fields.Many2one(
'res.currency',
related='company_id.currency_id',
readonly=True,
)
@api.depends('name', 'code')
def _compute_display_name(self):
for c in self:
c.display_name = c.name or c.code or ''
# ------------------------------------------------------------------
# MATCHING
# ------------------------------------------------------------------
@api.model
def find_best_match(self, product_category_id, text_hints):
"""Return the best-matching catalogue entry, or empty recordset.
Returns empty when no symptom keywords match. We never "guess" a default
catalog because the match drives estimated cost + auto-dispatch task -
a wrong guess would propagate into pricing and scheduling.
:param product_category_id: int id of the equipment category
:param text_hints: list[str] - text snippets to look for symptom keywords in
"""
import re
if not product_category_id:
return self.browse()
haystack = ' '.join(s.lower() for s in (text_hints or []) if s).strip()
if not haystack:
return self.browse()
candidates = self.search([
('product_category_id', '=', product_category_id),
('active', '=', True),
], order='sequence')
if not candidates:
return self.browse()
best = None
best_score = 0
for c in candidates:
kws = [k.strip().lower() for k in (c.symptom_keywords or '').split(',') if k.strip()]
# Word-boundary match avoids false positives where "battery" matches
# inside "no battery problem".
score = sum(
1 for kw in kws
if kw and re.search(rf'\b{re.escape(kw)}\b', haystack)
)
if score > best_score:
best = c
best_score = score
# No keywords matched -> return empty rather than the lowest-sequence guess.
if best and best_score > 0:
return best
return self.browse()

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from urllib.parse import quote_plus
from markupsafe import Markup
from odoo import _, fields, models
from odoo.exceptions import UserError
class FusionTechnicianTaskRepairs(models.Model):
"""Adds the back-link from fusion.technician.task to repair.order so
repairs and tasks share one timeline. Also hooks task completion to
roll a linked maintenance contract to its next cycle.
"""
_inherit = 'fusion.technician.task'
x_fc_repair_order_id = fields.Many2one(
'repair.order',
string='Repair Order',
ondelete='set null',
index=True,
tracking=True,
help='Repair order this task fulfils. Set automatically when the intake '
'wizard auto-creates a draft task for urgent / safety calls.',
)
x_fc_repair_intake_session_id = fields.Char(
related='x_fc_repair_order_id.x_fc_intake_session_id',
string='Intake Session',
store=True,
index=True,
)
# X2: per-task day-before reminder flag. Per-task (not per-repair) so
# a repair with multiple visits gets a separate reminder for each one.
x_fc_day_before_reminder_sent = fields.Boolean(
string='Day-Before Reminder Sent',
copy=False,
)
# ------------------------------------------------------------------
# T3 - Labour timer. The tech taps Start when they begin work and
# Stop when done; the accumulated minutes feeds the visit-report
# actual hours field. Multiple start/stop cycles are accumulated.
# ------------------------------------------------------------------
x_fc_timer_running_since = fields.Datetime(
string='Timer Running Since',
copy=False,
)
x_fc_timer_accumulated_minutes = fields.Float(
string='Accumulated Minutes',
default=0.0,
copy=False,
help='Total labour minutes captured by the tech timer. '
'Divide by 60 for the hours that prefill the visit report.',
)
def action_timer_start(self):
for t in self:
if t.x_fc_timer_running_since:
continue # already running
t.x_fc_timer_running_since = fields.Datetime.now()
t.message_post(body=Markup(_('Labour timer <b>started</b>.')))
def action_timer_stop(self):
for t in self:
if not t.x_fc_timer_running_since:
continue
from datetime import datetime
elapsed_minutes = (
datetime.now() - t.x_fc_timer_running_since
).total_seconds() / 60.0
t.x_fc_timer_accumulated_minutes = (
t.x_fc_timer_accumulated_minutes or 0.0
) + elapsed_minutes
t.x_fc_timer_running_since = False
t.message_post(body=Markup(_(
'Labour timer <b>stopped</b>. Added %(mins).1f min, total %(tot).1f min.'
)) % {
'mins': elapsed_minutes,
'tot': t.x_fc_timer_accumulated_minutes or 0.0,
})
def write(self, vals):
"""When a maintenance task transitions to 'completed', roll the
linked contract to its next cycle. Failure to roll never blocks
the underlying task write.
"""
res = super().write(vals)
if vals.get('status') == 'completed':
for task in self:
if task.task_type != 'maintenance':
continue
repair = task.x_fc_repair_order_id
contract = repair.x_fc_maintenance_contract_id if repair else False
if not contract:
continue
try:
contract.last_service_date = fields.Date.context_today(task)
contract.roll_next_due_date()
contract.message_post(body=Markup(
'Rolled forward after maintenance task '
'<b>%s</b> completed. Next due %s.'
) % (task.name or '', str(contract.next_due_date or '')))
except Exception:
# Never let a contract roll failure block the task write.
pass
return res
def action_view_repair_order(self):
self.ensure_one()
if not self.x_fc_repair_order_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_repair_order_id.name,
'res_model': 'repair.order',
'view_mode': 'form',
'res_id': self.x_fc_repair_order_id.id,
}
# ------------------------------------------------------------------
# T1: Open in Maps - returns an act_url action that opens the device's
# default maps app (Apple Maps on iOS, Google Maps on Android, browser
# otherwise). Address is built from the task's address fields with the
# partner address as a fallback.
# ------------------------------------------------------------------
def action_open_in_maps(self):
self.ensure_one()
# Prefer fusion_tasks.address_display because in real data address_street
# often contains the full Google-Places-formatted address; concatenating
# the other address_* fields would duplicate city/zip.
addr = (getattr(self, 'address_display', '') or '').strip()
if not addr and self.partner_id:
p = self.partner_id
parts = [
p.street, p.street2, p.city,
p.state_id.name if p.state_id else False,
p.zip,
p.country_id.name if p.country_id else False,
]
addr = ', '.join(str(x) for x in parts if x)
if not addr:
raise UserError(_('No address on this task or its client.'))
return {
'type': 'ir.actions.act_url',
'url': f'https://www.google.com/maps?q={quote_plus(addr)}',
'target': 'new',
}

View File

@@ -0,0 +1,167 @@
<?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;
}
/* wkhtmltopdf does not implement flex/gap reliably -
use inline-block layout instead. */
.cert-info {
margin: 10mm 0;
text-align: center;
}
.cert-info-item {
display: inline-block;
font-size: 10pt;
text-align: left;
margin: 0 9mm;
vertical-align: top;
}
.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;
width: 100%;
}
.cert-footer-row {
width: 100%;
}
.cert-sig {
display: inline-block;
font-size: 10pt;
color: #666;
border-top: 1px solid #999;
padding-top: 2mm;
width: 70mm;
text-align: center;
float: right;
}
.cert-number {
display: inline-block;
font-size: 9pt;
color: #888;
font-family: ui-monospace, monospace;
float: left;
padding-top: 6mm;
}
</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>

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 4-up sticker sheet on letter paper. Tweak grid in the QWeb template
to match your label stock. -->
<record id="action_report_qr_stickers" model="ir.actions.report">
<field name="name">QR Stickers</field>
<field name="model">fusion.repair.qr.sticker.wizard</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_repairs.report_qr_stickers</field>
<field name="report_file">fusion_repairs.report_qr_stickers</field>
<field name="print_report_name">'QR Stickers - %s' % (object.id)</field>
</record>
<template id="report_qr_stickers">
<t t-call="web.html_container">
<t t-call="web.internal_layout">
<div class="page">
<style>
.qr-sticker-grid {
display: flex;
flex-wrap: wrap;
gap: 12mm;
padding: 8mm;
}
.qr-sticker {
width: 80mm;
height: 50mm;
border: 1px dashed #999;
padding: 6mm;
display: flex;
align-items: center;
gap: 6mm;
page-break-inside: avoid;
}
.qr-sticker .qr-img img {
width: 38mm; height: 38mm;
}
.qr-sticker .qr-info {
font-family: sans-serif;
font-size: 9pt;
line-height: 1.3;
}
.qr-sticker .qr-info .qr-title {
font-weight: 700;
font-size: 11pt;
margin-bottom: 2mm;
}
.qr-sticker .qr-info .qr-serial {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 8pt;
}
</style>
<div class="qr-sticker-grid">
<t t-foreach="docs" t-as="doc">
<t t-foreach="doc.lot_ids" t-as="lot">
<t t-set="url" t-value="doc.get_sticker_url(lot)"/>
<t t-set="qr_uri" t-value="doc.get_qr_data_uri(url)"/>
<div class="qr-sticker">
<div class="qr-img">
<img t-if="qr_uri" t-att-src="qr_uri" alt="QR"/>
<span t-else="" style="font-size:7pt;color:#900;">QR lib missing</span>
</div>
<div class="qr-info">
<div class="qr-title">Scan for service</div>
<div>
<t t-out="lot.product_id.display_name or ''"/>
</div>
<div class="qr-serial">
SN <t t-out="lot.name or ''"/>
</div>
<div style="margin-top:2mm;font-size:7pt;color:#666;">
Or visit:
<t t-out="doc._portal_base_url()"/>
</div>
</div>
</div>
</t>
</t>
</div>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,53 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_repair_product_category_user,Repair Category User Read,model_fusion_repair_product_category,group_fusion_repairs_user,1,0,0,0
access_repair_product_category_manager,Repair Category Manager Full,model_fusion_repair_product_category,group_fusion_repairs_manager,1,1,1,1
access_repair_intake_template_user,Intake Template User Read,model_fusion_repair_intake_template,group_fusion_repairs_user,1,0,0,0
access_repair_intake_template_manager,Intake Template Manager Full,model_fusion_repair_intake_template,group_fusion_repairs_manager,1,1,1,1
access_repair_intake_question_user,Intake Question User Read,model_fusion_repair_intake_question,group_fusion_repairs_user,1,0,0,0
access_repair_intake_question_manager,Intake Question Manager Full,model_fusion_repair_intake_question,group_fusion_repairs_manager,1,1,1,1
access_repair_intake_answer_user,Intake Answer User Full,model_fusion_repair_intake_answer,group_fusion_repairs_user,1,1,1,0
access_repair_intake_answer_manager,Intake Answer Manager Full,model_fusion_repair_intake_answer,group_fusion_repairs_manager,1,1,1,1
access_repair_intake_answer_tech_portal,Intake Answer Technician Read,model_fusion_repair_intake_answer,fusion_tasks.group_field_technician,1,0,0,0
access_repair_intake_wizard_user,Intake Wizard User Full,model_fusion_repair_intake_wizard,group_fusion_repairs_user,1,1,1,1
access_repair_intake_wizard_equipment_user,Intake Wizard Equipment User Full,model_fusion_repair_intake_wizard_equipment,group_fusion_repairs_user,1,1,1,1
access_repair_service_catalog_user,Catalogue User Read,model_fusion_repair_service_catalog,group_fusion_repairs_user,1,0,0,0
access_repair_service_catalog_manager,Catalogue Manager Full,model_fusion_repair_service_catalog,group_fusion_repairs_manager,1,1,1,1
access_repair_warranty_user,Warranty User Read,model_fusion_repair_warranty_coverage,group_fusion_repairs_user,1,0,0,0
access_repair_warranty_manager,Warranty Manager Full,model_fusion_repair_warranty_coverage,group_fusion_repairs_manager,1,1,1,1
access_repair_visit_report_wizard_user,Visit Report Wizard User,model_fusion_repair_visit_report_wizard,group_fusion_repairs_user,1,1,1,1
access_repair_visit_report_wizard_line_user,Visit Report Line User,model_fusion_repair_visit_report_wizard_line,group_fusion_repairs_user,1,1,1,1
access_repair_maintenance_user,Maintenance Contract User Read,model_fusion_repair_maintenance_contract,group_fusion_repairs_user,1,0,0,0
access_repair_maintenance_dispatcher,Maintenance Contract Dispatcher,model_fusion_repair_maintenance_contract,group_fusion_repairs_dispatcher,1,1,1,0
access_repair_maintenance_manager,Maintenance Contract Manager Full,model_fusion_repair_maintenance_contract,group_fusion_repairs_manager,1,1,1,1
access_repair_order_repairs_user,Repair Order Repairs User Read/Write,repair.model_repair_order,group_fusion_repairs_user,1,1,1,0
access_repair_order_repairs_manager,Repair Order Repairs Manager Full,repair.model_repair_order,group_fusion_repairs_manager,1,1,1,1
access_technician_task_repairs_user,Technician Task Repairs User Schedule,fusion_tasks.model_fusion_technician_task,group_fusion_repairs_user,1,1,1,0
access_technician_task_repairs_manager,Technician Task Repairs Manager Full,fusion_tasks.model_fusion_technician_task,group_fusion_repairs_manager,1,1,1,1
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 Read-Only,model_fusion_repair_inspection_certificate,fusion_tasks.group_field_technician,1,0,0,0
access_service_plan_sub_user,Service Plan Sub User Read,model_fusion_repair_service_plan_subscription,group_fusion_repairs_user,1,0,0,0
access_service_plan_sub_dispatcher,Service Plan Sub Dispatcher,model_fusion_repair_service_plan_subscription,group_fusion_repairs_dispatcher,1,1,1,0
access_service_plan_sub_manager,Service Plan Sub Manager Full,model_fusion_repair_service_plan_subscription,group_fusion_repairs_manager,1,1,1,1
access_service_plan_burn_user,Service Plan Burn User Read,model_fusion_repair_service_plan_burn,group_fusion_repairs_user,1,0,0,0
access_service_plan_burn_manager,Service Plan Burn Manager Full,model_fusion_repair_service_plan_burn,group_fusion_repairs_manager,1,1,1,1
access_emergency_charge_user,Emergency Charge User Read,model_fusion_repair_emergency_charge,group_fusion_repairs_user,1,0,0,0
access_emergency_charge_manager,Emergency Charge Manager Full,model_fusion_repair_emergency_charge,group_fusion_repairs_manager,1,1,1,1
access_part_order_user,Part Order User Read,model_fusion_repair_part_order,group_fusion_repairs_user,1,0,0,0
access_part_order_dispatcher,Part Order Dispatcher,model_fusion_repair_part_order,group_fusion_repairs_dispatcher,1,1,1,0
access_part_order_manager,Part Order Manager Full,model_fusion_repair_part_order,group_fusion_repairs_manager,1,1,1,1
access_part_order_technician,Part Order Field Tech Create,model_fusion_repair_part_order,fusion_tasks.group_field_technician,1,1,1,0
access_visit_report_partline_user,Visit Report Part Line User Full,model_fusion_repair_visit_report_wizard_partline,group_fusion_repairs_user,1,1,1,1
access_visit_report_partline_tech,Visit Report Part Line Field Tech Full,model_fusion_repair_visit_report_wizard_partline,fusion_tasks.group_field_technician,1,1,1,1
access_callout_rate_user,Callout Rate User Read,model_fusion_repair_callout_rate,group_fusion_repairs_user,1,0,0,0
access_callout_rate_manager,Callout Rate Manager Full,model_fusion_repair_callout_rate,group_fusion_repairs_manager,1,1,1,1
access_delivery_charge_user,Delivery Charge User Read,model_fusion_repair_delivery_charge,group_fusion_repairs_user,1,0,0,0
access_delivery_charge_manager,Delivery Charge Manager Full,model_fusion_repair_delivery_charge,group_fusion_repairs_manager,1,1,1,1
access_labor_warranty_user,Labor Warranty User Read,model_fusion_repair_labor_warranty,group_fusion_repairs_user,1,0,0,0
access_labor_warranty_sales_rep,Labor Warranty Sales Rep Write,model_fusion_repair_labor_warranty,group_fusion_repairs_sales_rep,1,1,0,0
access_labor_warranty_manager,Labor Warranty Manager Full,model_fusion_repair_labor_warranty,group_fusion_repairs_manager,1,1,1,1
access_labor_warranty_technician,Labor Warranty Field Tech Read,model_fusion_repair_labor_warranty,fusion_tasks.group_field_technician,1,1,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_repair_product_category_user Repair Category User Read model_fusion_repair_product_category group_fusion_repairs_user 1 0 0 0
3 access_repair_product_category_manager Repair Category Manager Full model_fusion_repair_product_category group_fusion_repairs_manager 1 1 1 1
4 access_repair_intake_template_user Intake Template User Read model_fusion_repair_intake_template group_fusion_repairs_user 1 0 0 0
5 access_repair_intake_template_manager Intake Template Manager Full model_fusion_repair_intake_template group_fusion_repairs_manager 1 1 1 1
6 access_repair_intake_question_user Intake Question User Read model_fusion_repair_intake_question group_fusion_repairs_user 1 0 0 0
7 access_repair_intake_question_manager Intake Question Manager Full model_fusion_repair_intake_question group_fusion_repairs_manager 1 1 1 1
8 access_repair_intake_answer_user Intake Answer User Full model_fusion_repair_intake_answer group_fusion_repairs_user 1 1 1 0
9 access_repair_intake_answer_manager Intake Answer Manager Full model_fusion_repair_intake_answer group_fusion_repairs_manager 1 1 1 1
10 access_repair_intake_answer_tech_portal Intake Answer Technician Read model_fusion_repair_intake_answer fusion_tasks.group_field_technician 1 0 0 0
11 access_repair_intake_wizard_user Intake Wizard User Full model_fusion_repair_intake_wizard group_fusion_repairs_user 1 1 1 1
12 access_repair_intake_wizard_equipment_user Intake Wizard Equipment User Full model_fusion_repair_intake_wizard_equipment group_fusion_repairs_user 1 1 1 1
13 access_repair_service_catalog_user Catalogue User Read model_fusion_repair_service_catalog group_fusion_repairs_user 1 0 0 0
14 access_repair_service_catalog_manager Catalogue Manager Full model_fusion_repair_service_catalog group_fusion_repairs_manager 1 1 1 1
15 access_repair_warranty_user Warranty User Read model_fusion_repair_warranty_coverage group_fusion_repairs_user 1 0 0 0
16 access_repair_warranty_manager Warranty Manager Full model_fusion_repair_warranty_coverage group_fusion_repairs_manager 1 1 1 1
17 access_repair_visit_report_wizard_user Visit Report Wizard User model_fusion_repair_visit_report_wizard group_fusion_repairs_user 1 1 1 1
18 access_repair_visit_report_wizard_line_user Visit Report Line User model_fusion_repair_visit_report_wizard_line group_fusion_repairs_user 1 1 1 1
19 access_repair_maintenance_user Maintenance Contract User Read model_fusion_repair_maintenance_contract group_fusion_repairs_user 1 0 0 0
20 access_repair_maintenance_dispatcher Maintenance Contract Dispatcher model_fusion_repair_maintenance_contract group_fusion_repairs_dispatcher 1 1 1 0
21 access_repair_maintenance_manager Maintenance Contract Manager Full model_fusion_repair_maintenance_contract group_fusion_repairs_manager 1 1 1 1
22 access_repair_order_repairs_user Repair Order Repairs User Read/Write repair.model_repair_order group_fusion_repairs_user 1 1 1 0
23 access_repair_order_repairs_manager Repair Order Repairs Manager Full repair.model_repair_order group_fusion_repairs_manager 1 1 1 1
24 access_technician_task_repairs_user Technician Task Repairs User Schedule fusion_tasks.model_fusion_technician_task group_fusion_repairs_user 1 1 1 0
25 access_technician_task_repairs_manager Technician Task Repairs Manager Full fusion_tasks.model_fusion_technician_task group_fusion_repairs_manager 1 1 1 1
26 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
27 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
28 access_qr_sticker_wizard_user QR Sticker Wizard User Full model_fusion_repair_qr_sticker_wizard group_fusion_repairs_user 1 1 1 1
29 access_repair_inspection_user Inspection Cert User Read model_fusion_repair_inspection_certificate group_fusion_repairs_user 1 0 0 0
30 access_repair_inspection_dispatcher Inspection Cert Dispatcher model_fusion_repair_inspection_certificate group_fusion_repairs_dispatcher 1 1 1 0
31 access_repair_inspection_manager Inspection Cert Manager Full model_fusion_repair_inspection_certificate group_fusion_repairs_manager 1 1 1 1
32 access_repair_inspection_technician Inspection Cert Field Tech Read-Only model_fusion_repair_inspection_certificate fusion_tasks.group_field_technician 1 0 0 0
33 access_service_plan_sub_user Service Plan Sub User Read model_fusion_repair_service_plan_subscription group_fusion_repairs_user 1 0 0 0
34 access_service_plan_sub_dispatcher Service Plan Sub Dispatcher model_fusion_repair_service_plan_subscription group_fusion_repairs_dispatcher 1 1 1 0
35 access_service_plan_sub_manager Service Plan Sub Manager Full model_fusion_repair_service_plan_subscription group_fusion_repairs_manager 1 1 1 1
36 access_service_plan_burn_user Service Plan Burn User Read model_fusion_repair_service_plan_burn group_fusion_repairs_user 1 0 0 0
37 access_service_plan_burn_manager Service Plan Burn Manager Full model_fusion_repair_service_plan_burn group_fusion_repairs_manager 1 1 1 1
38 access_emergency_charge_user Emergency Charge User Read model_fusion_repair_emergency_charge group_fusion_repairs_user 1 0 0 0
39 access_emergency_charge_manager Emergency Charge Manager Full model_fusion_repair_emergency_charge group_fusion_repairs_manager 1 1 1 1
40 access_part_order_user Part Order User Read model_fusion_repair_part_order group_fusion_repairs_user 1 0 0 0
41 access_part_order_dispatcher Part Order Dispatcher model_fusion_repair_part_order group_fusion_repairs_dispatcher 1 1 1 0
42 access_part_order_manager Part Order Manager Full model_fusion_repair_part_order group_fusion_repairs_manager 1 1 1 1
43 access_part_order_technician Part Order Field Tech Create model_fusion_repair_part_order fusion_tasks.group_field_technician 1 1 1 0
44 access_visit_report_partline_user Visit Report Part Line User Full model_fusion_repair_visit_report_wizard_partline group_fusion_repairs_user 1 1 1 1
45 access_visit_report_partline_tech Visit Report Part Line Field Tech Full model_fusion_repair_visit_report_wizard_partline fusion_tasks.group_field_technician 1 1 1 1
46 access_callout_rate_user Callout Rate User Read model_fusion_repair_callout_rate group_fusion_repairs_user 1 0 0 0
47 access_callout_rate_manager Callout Rate Manager Full model_fusion_repair_callout_rate group_fusion_repairs_manager 1 1 1 1
48 access_delivery_charge_user Delivery Charge User Read model_fusion_repair_delivery_charge group_fusion_repairs_user 1 0 0 0
49 access_delivery_charge_manager Delivery Charge Manager Full model_fusion_repair_delivery_charge group_fusion_repairs_manager 1 1 1 1
50 access_labor_warranty_user Labor Warranty User Read model_fusion_repair_labor_warranty group_fusion_repairs_user 1 0 0 0
51 access_labor_warranty_sales_rep Labor Warranty Sales Rep Write model_fusion_repair_labor_warranty group_fusion_repairs_sales_rep 1 1 0 0
52 access_labor_warranty_manager Labor Warranty Manager Full model_fusion_repair_labor_warranty group_fusion_repairs_manager 1 1 1 1
53 access_labor_warranty_technician Labor Warranty Field Tech Read model_fusion_repair_labor_warranty fusion_tasks.group_field_technician 1 1 0 0

View File

@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================================================================== -->
<!-- MODULE CATEGORY -->
<!-- ==================================================================== -->
<record id="module_category_fusion_repairs" model="ir.module.category">
<field name="name">Fusion Repairs</field>
<field name="sequence">47</field>
</record>
<!-- ==================================================================== -->
<!-- FUSION REPAIRS PRIVILEGE (Odoo 19 res.groups.privilege pattern) -->
<!-- ==================================================================== -->
<record id="res_groups_privilege_fusion_repairs" model="res.groups.privilege">
<field name="name">Fusion Repairs</field>
<field name="sequence">47</field>
<field name="category_id" ref="module_category_fusion_repairs"/>
</record>
<!-- ==================================================================== -->
<!-- GROUPS -->
<!-- ==================================================================== -->
<record id="group_fusion_repairs_user" model="res.groups">
<field name="name">Repairs: User (CS Intake)</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">CS / front-office staff who take repair intake calls and view repairs.</field>
</record>
<record id="group_fusion_repairs_dispatcher" model="res.groups">
<field name="name">Repairs: Dispatcher</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_repairs_user'))]"/>
<field name="comment">Assigns technicians to repairs, reschedules visits, manages parts pre-pull picklists.</field>
</record>
<!-- Bundle 9: sales-rep group. Distinct from CS so labor-fee waiving can
be authorised by either a manager or a sales rep, but never a front-
office CS user. Manager implies it. -->
<record id="group_fusion_repairs_sales_rep" model="res.groups">
<field name="name">Repairs: Sales Rep</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_repairs_user'))]"/>
<field name="comment">Sales reps who can waive labor fees on their accounts (CS cannot waive).</field>
</record>
<record id="group_fusion_repairs_manager" model="res.groups">
<field name="name">Repairs: Manager</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_repairs_dispatcher')), (4, ref('group_fusion_repairs_sales_rep'))]"/>
<field name="comment">Configures intake templates, pricing, maintenance contracts, on-call rotation, variance overrides. Implies all lower groups including sales rep.</field>
</record>
<!-- =====================================================================
Admin auto-membership: anyone with base.group_system (Settings /
Administration) automatically gets Repairs Manager, which implies
Dispatcher and User. So admin users see the Fusion Repairs app
and have full access without needing to be added manually.
===================================================================== -->
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('fusion_repairs.group_fusion_repairs_manager'))]"/>
</record>
<!-- ==================================================================== -->
<!-- RECORD RULES -->
<!-- ==================================================================== -->
<!-- Multi-company isolation on repair.order -->
<record id="rule_repair_order_company" model="ir.rule">
<field name="name">Repair Order: Multi-Company</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
<!-- Field technicians (from fusion_tasks) see only repairs they're assigned to as technician on a linked task.
Uses STORED fields (technician_id + additional_technician_ids) - not the computed all_technician_ids.
NOTE: per-group rules in Odoo are OR'd. A user who is BOTH a field
technician AND a Repairs User/Dispatcher/Manager will see all repairs
because the permissive Repairs rules below grant access via the OR. -->
<record id="rule_repair_order_technician_own" model="ir.rule">
<field name="name">Repair Order: Technician sees own repairs</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">['|', ('x_fc_technician_task_ids.technician_id', '=', user.id), ('x_fc_technician_task_ids.additional_technician_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('fusion_tasks.group_field_technician'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Repairs office users (User / Dispatcher / Manager) see all repairs
across companies they have access to. OR'd with the technician rule
above so admin / dispatchers who happen to also be in field_technician
still see everything. -->
<record id="rule_repair_order_repairs_user_full" model="ir.rule">
<field name="name">Repair Order: Repairs Office Full Access</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_repairs_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_repair_order_repairs_manager_unlink" model="ir.rule">
<field name="name">Repair Order: Repairs Manager Can Delete</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_repairs_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Repairs office users can read AND schedule technician tasks. This is
what makes "office can dispatch / reschedule" work without requiring
them to also be in the sales_team groups that fusion_tasks normally
keys off of. -->
<record id="rule_technician_task_repairs_office" model="ir.rule">
<field name="name">Technician Task: Repairs Office Access</field>
<field name="model_id" ref="fusion_tasks.model_fusion_technician_task"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_repairs_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_technician_task_repairs_manager_unlink" model="ir.rule">
<field name="name">Technician Task: Repairs Manager Can Delete</field>
<field name="model_id" ref="fusion_tasks.model_fusion_technician_task"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_repairs_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Intake answer access scoped to repair access -->
<record id="rule_repair_intake_answer_company" model="ir.rule">
<field name="name">Repair Intake Answer: Multi-Company</field>
<field name="model_id" ref="model_fusion_repair_intake_answer"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
<!-- Inspection certs: only manager can edit AFTER issue (everyone else read-only).
Visit-report wizard uses sudo() to create new certs from a tech visit. -->
<record id="rule_inspection_cert_readonly" model="ir.rule">
<field name="name">Inspection Certificate: Read-only for non-managers</field>
<field name="model_id" ref="model_fusion_repair_inspection_certificate"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_repairs_dispatcher'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Sales Rep Portal: sees only repair orders they submitted -->
<record id="rule_repair_order_sales_rep_portal" model="ir.rule">
<field name="name">Repair Order: Sales Rep Portal - Own Repairs</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">[('x_fc_intake_user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -0,0 +1,149 @@
/** @odoo-module **/
// Fusion Repairs dashboard - OWL client action.
// Uses standalone rpc() from @web/core/network/rpc per project rule #3
// and useService("action") to navigate to backend act_window actions.
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
export class FusionRepairsDashboard extends Component {
static template = "fusion_repairs.Dashboard";
static props = ["*"];
setup() {
this.action = useService("action");
this.notification = useService("notification");
this.state = useState({
loading: true,
stats: {},
urgency_breakdown: [],
source_breakdown: [],
recent: [],
upcoming: [],
portals: {},
failures_by_product: [],
failures_by_symptom: [],
margin_summary: {},
});
onWillStart(async () => {
await this._loadData();
});
}
async _loadData() {
try {
const data = await rpc("/web/dataset/call_kw", {
model: "fusion.repair.dashboard",
method: "get_dashboard_data",
args: [],
kwargs: {},
});
this.state.stats = data.stats || {};
this.state.urgency_breakdown = data.urgency_breakdown || [];
this.state.source_breakdown = data.source_breakdown || [];
this.state.recent = data.recent || [];
this.state.upcoming = data.upcoming || [];
this.state.portals = data.portals || {};
this.state.failures_by_product = data.failures_by_product || [];
this.state.failures_by_symptom = data.failures_by_symptom || [];
this.state.margin_summary = data.margin_summary || {};
} catch (e) {
this.notification.add(_t("Could not load dashboard data."), {
type: "danger",
});
} finally {
this.state.loading = false;
}
}
async refresh() {
this.state.loading = true;
await this._loadData();
}
openAction(xmlId, extraContext) {
return this.action.doAction(xmlId, {
additionalContext: extraContext || {},
});
}
openWizard() {
return this.action.doAction("fusion_repairs.action_open_repair_intake_wizard");
}
openRepair(repairId) {
return this.action.doAction({
type: "ir.actions.act_window",
res_model: "repair.order",
res_id: repairId,
views: [[false, "form"]],
target: "current",
});
}
openContract(contractId) {
return this.action.doAction({
type: "ir.actions.act_window",
res_model: "fusion.repair.maintenance.contract",
res_id: contractId,
views: [[false, "form"]],
target: "current",
});
}
openUrl(url) {
if (url) {
window.open(url, "_blank", "noopener");
}
}
async copyUrl(url) {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
this.notification.add(_t("Copied to clipboard."), { type: "success" });
} catch (e) {
this.notification.add(_t("Could not copy URL. Select and copy manually."), {
type: "warning",
});
}
}
formatDate(value) {
if (!value) return "";
return value.slice(0, 10);
}
formatMoney(value) {
const v = Number(value || 0);
return v.toLocaleString("en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
});
}
formatPercent(value) {
const v = Number(value || 0);
return `${v.toFixed(1)}%`;
}
urgencyPillClass(urgency) {
if (urgency === "safety") return "fr-pill fr-pill-safety";
if (urgency === "urgent") return "fr-pill fr-pill-urgent";
return "fr-pill fr-pill-normal";
}
urgencyLabel(urgency) {
const map = { safety: "Safety", urgent: "Urgent", normal: "Normal" };
return map[urgency] || "Normal";
}
}
registry
.category("actions")
.add("fusion_repairs.dashboard", FusionRepairsDashboard);

View File

@@ -0,0 +1,341 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_repairs.Dashboard">
<div class="o_fusion_repairs_dashboard">
<!-- Loading state -->
<div t-if="state.loading" class="fr-loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<div class="mt-3">Loading dashboard...</div>
</div>
<!-- Loaded -->
<t t-if="!state.loading">
<!-- Hero header -->
<div class="fr-hero">
<h1>Fusion Repairs</h1>
<p>Service calls, technician dispatch, maintenance and self-service in one place.</p>
</div>
<!-- Quick actions -->
<div class="fr-section-title">Quick Actions</div>
<div class="fr-grid fr-grid-actions">
<button class="fr-action fr-action-primary"
t-on-click="() => this.openWizard()">
<span class="fr-action-icon"><i class="fa fa-plus-circle"/></span>
<span class="fr-action-text">
<span class="fr-action-title">New Service Call</span>
<span class="fr-action-sub">Open the guided intake wizard</span>
</span>
</button>
<button class="fr-action"
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard')">
<span class="fr-action-icon"><i class="fa fa-th-large"/></span>
<span class="fr-action-text">
<span class="fr-action-title">Service Calls</span>
<span class="fr-action-sub">Kanban of every repair</span>
</span>
</button>
<button class="fr-action"
t-on-click="() => this.openAction('fusion_repairs.action_maintenance_contract')">
<span class="fr-action-icon"><i class="fa fa-calendar-check-o"/></span>
<span class="fr-action-text">
<span class="fr-action-title">Maintenance Contracts</span>
<span class="fr-action-sub">
<t t-out="state.stats.maintenance_active_total"/> active
</span>
</span>
</button>
<button class="fr-action"
t-on-click="() => this.openAction('fusion_repairs.action_repair_warranty_coverage')">
<span class="fr-action-icon"><i class="fa fa-shield"/></span>
<span class="fr-action-text">
<span class="fr-action-title">Repair Warranties</span>
<span class="fr-action-sub">Our 30 / 90-day coverage</span>
</span>
</button>
</div>
<!-- KPI tiles -->
<div class="fr-section-title">Right Now</div>
<div class="fr-grid fr-grid-stats">
<div class="fr-stat fr-stat-accent"
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard', {search_default_open: 1})"
style="cursor:pointer;">
<span class="fr-stat-label">Open Service Calls</span>
<span class="fr-stat-value"><t t-out="state.stats.open_count or 0"/></span>
<span class="fr-stat-sub">Not yet closed</span>
</div>
<div class="fr-stat fr-stat-danger"
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard', {search_default_safety: 1, search_default_urgent: 1, search_default_open: 1})"
style="cursor:pointer;">
<span class="fr-stat-label">Urgent + Safety</span>
<span class="fr-stat-value"><t t-out="state.stats.urgent_count or 0"/></span>
<span class="fr-stat-sub">High-priority queue</span>
</div>
<div class="fr-stat fr-stat-warning"
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard', {search_default_open: 1})"
style="cursor:pointer;">
<span class="fr-stat-label">Awaiting Dispatch</span>
<span class="fr-stat-value"><t t-out="state.stats.awaiting_dispatch or 0"/></span>
<span class="fr-stat-sub">No technician task yet</span>
</div>
<div class="fr-stat fr-stat-warning"
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard', {search_default_open: 1})"
style="cursor:pointer;">
<span class="fr-stat-label">Needs Re-Quote</span>
<span class="fr-stat-value"><t t-out="state.stats.requires_requote or 0"/></span>
<span class="fr-stat-sub">Over variance threshold</span>
</div>
<div class="fr-stat fr-stat-accent"
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard', {search_default_week: 1})"
style="cursor:pointer;">
<span class="fr-stat-label">New This Month</span>
<span class="fr-stat-value"><t t-out="state.stats.new_this_month or 0"/></span>
<span class="fr-stat-sub">Across all intake surfaces</span>
</div>
<div class="fr-stat fr-stat-success"
t-on-click="() => this.openAction('fusion_repairs.action_maintenance_contract')"
style="cursor:pointer;">
<span class="fr-stat-label">Maintenance Due (30d)</span>
<span class="fr-stat-value"><t t-out="state.stats.maintenance_due_30d or 0"/></span>
<span class="fr-stat-sub">Contracts to ring this month</span>
</div>
</div>
<!-- Self-service portals to share -->
<div class="fr-section-title">Self-Service Portals</div>
<div class="fr-grid fr-grid-portals">
<div class="fr-portal">
<div class="fr-portal-head">
<i class="fa fa-globe"/> Public Client Portal
</div>
<div class="fr-portal-sub">
Share this link in your voicemail or on equipment QR stickers.
Clients can submit a service request 24/7 without logging in.
</div>
<div class="fr-portal-url" t-out="state.portals.client_portal_url"/>
<div class="fr-portal-actions">
<button class="btn btn-primary btn-sm"
t-on-click="() => this.openUrl(state.portals.client_portal_url)">
<i class="fa fa-external-link me-1"/> Open
</button>
<button class="btn btn-light btn-sm"
t-on-click="() => this.copyUrl(state.portals.client_portal_url)">
<i class="fa fa-clipboard me-1"/> Copy
</button>
</div>
</div>
<div class="fr-portal">
<div class="fr-portal-head">
<i class="fa fa-mobile"/> Sales Rep Portal
</div>
<div class="fr-portal-sub">
Mobile-friendly intake form for sales reps in the field.
Sales reps with portal access only see repairs they submitted.
</div>
<div class="fr-portal-url" t-out="state.portals.sales_rep_portal_url"/>
<div class="fr-portal-actions">
<button class="btn btn-primary btn-sm"
t-on-click="() => this.openUrl(state.portals.sales_rep_portal_url)">
<i class="fa fa-external-link me-1"/> Open
</button>
<button class="btn btn-light btn-sm"
t-on-click="() => this.copyUrl(state.portals.sales_rep_portal_url)">
<i class="fa fa-clipboard me-1"/> Copy
</button>
</div>
</div>
</div>
<!-- Recent + Upcoming -->
<div class="fr-section-title">Activity</div>
<div class="fr-grid fr-grid-lists">
<div class="fr-list">
<h3><i class="fa fa-clock-o me-2"/>Recent Service Calls</h3>
<t t-if="state.recent.length === 0">
<div class="fr-list-empty">No service calls yet</div>
</t>
<t t-foreach="state.recent" t-as="r" t-key="r.id">
<div class="fr-list-row" t-on-click="() => this.openRepair(r.id)">
<div class="fr-list-main">
<span class="fr-list-title">
<t t-out="r.name"/>
<span t-att-class="urgencyPillClass(r.urgency)" class="ms-2">
<t t-out="urgencyLabel(r.urgency)"/>
</span>
</span>
<span class="fr-list-sub">
<t t-out="r.partner_name"/>
<t t-if="r.category"> &#183; <t t-out="r.category"/></t>
</span>
</div>
<span class="fr-list-meta">
<span class="fr-pill fr-pill-state"><t t-out="r.state_label"/></span>
</span>
</div>
</t>
</div>
<div class="fr-list">
<h3><i class="fa fa-calendar me-2"/>Upcoming Maintenance</h3>
<t t-if="state.upcoming.length === 0">
<div class="fr-list-empty">No upcoming maintenance</div>
</t>
<t t-foreach="state.upcoming" t-as="c" t-key="c.id">
<div class="fr-list-row" t-on-click="() => this.openContract(c.id)">
<div class="fr-list-main">
<span class="fr-list-title">
<t t-out="c.name"/>
<t t-if="c.days_until !== undefined and c.days_until &lt;= 7">
<span class="fr-pill fr-pill-urgent ms-2">
<t t-out="c.days_until"/>d
</span>
</t>
</span>
<span class="fr-list-sub">
<t t-out="c.partner_name"/> &#183; <t t-out="c.product_name"/>
</span>
</div>
<span class="fr-list-meta">
<t t-out="formatDate(c.next_due_date)"/>
</span>
</div>
</t>
</div>
</div>
<!-- Analytics (M7 + M9) -->
<div class="fr-section-title">Last 90 Days</div>
<div class="fr-grid fr-grid-lists">
<div class="fr-list">
<h3><i class="fa fa-line-chart me-2"/>Margin Summary</h3>
<t t-if="!state.margin_summary or !state.margin_summary.sample_size">
<div class="fr-list-empty">No data yet for the last 90 days</div>
</t>
<t t-if="state.margin_summary and state.margin_summary.sample_size">
<div class="fr-list-row">
<div class="fr-list-main">
<span class="fr-list-title">Revenue</span>
<span class="fr-list-sub">Posted invoices on repair SOs</span>
</div>
<span class="fr-list-meta">
<t t-out="formatMoney(state.margin_summary.revenue)"/>
</span>
</div>
<div class="fr-list-row">
<div class="fr-list-main">
<span class="fr-list-title">Labour Cost</span>
<span class="fr-list-sub">Hours x tech cost rate</span>
</div>
<span class="fr-list-meta">
- <t t-out="formatMoney(state.margin_summary.labour_cost)"/>
</span>
</div>
<div class="fr-list-row">
<div class="fr-list-main">
<span class="fr-list-title">Parts Cost</span>
<span class="fr-list-sub">Standard price of consumed parts</span>
</div>
<span class="fr-list-meta">
- <t t-out="formatMoney(state.margin_summary.parts_cost)"/>
</span>
</div>
<div class="fr-list-row" style="border-top:2px solid #d8dadd;">
<div class="fr-list-main">
<span class="fr-list-title">Margin</span>
<span class="fr-list-sub">
<t t-out="formatPercent(state.margin_summary.margin_pct)"/>
on <t t-out="state.margin_summary.sample_size"/> repairs
</span>
</div>
<span class="fr-list-meta" style="font-weight:600;">
<t t-out="formatMoney(state.margin_summary.margin)"/>
</span>
</div>
</t>
</div>
<div class="fr-list">
<h3><i class="fa fa-bar-chart me-2"/>Failure Rate by Product</h3>
<t t-if="state.failures_by_product.length === 0">
<div class="fr-list-empty">No repairs in the last 90 days</div>
</t>
<t t-foreach="state.failures_by_product" t-as="p" t-key="p.product_id">
<div class="fr-list-row">
<div class="fr-list-main">
<span class="fr-list-title"><t t-out="p.product_name"/></span>
</div>
<span class="fr-list-meta">
<t t-out="p.repair_count"/>
</span>
</div>
</t>
</div>
</div>
<div class="fr-grid fr-grid-lists">
<div class="fr-list">
<h3><i class="fa fa-tags me-2"/>Failure Rate by Symptom</h3>
<t t-if="state.failures_by_symptom.length === 0">
<div class="fr-list-empty">No symptoms tagged in the last 90 days</div>
</t>
<t t-foreach="state.failures_by_symptom" t-as="s" t-key="s.symptom">
<div class="fr-list-row">
<div class="fr-list-main">
<span class="fr-list-title"><t t-out="s.symptom"/></span>
</div>
<span class="fr-list-meta">
<t t-out="s.repair_count"/>
</span>
</div>
</t>
</div>
</div>
<!-- Configuration -->
<div class="fr-section-title">Configuration</div>
<div class="fr-grid fr-grid-config">
<button class="fr-action"
t-on-click="() => this.openAction('fusion_repairs.action_repair_product_category')">
<span class="fr-action-icon"><i class="fa fa-tags"/></span>
<span class="fr-action-text">
<span class="fr-action-title">Equipment Categories</span>
<span class="fr-action-sub">Hospital beds, stairlifts...</span>
</span>
</button>
<button class="fr-action"
t-on-click="() => this.openAction('fusion_repairs.action_repair_intake_template')">
<span class="fr-action-icon"><i class="fa fa-question-circle"/></span>
<span class="fr-action-text">
<span class="fr-action-title">Intake Templates</span>
<span class="fr-action-sub">Question banks per category</span>
</span>
</button>
<button class="fr-action"
t-on-click="() => this.openAction('fusion_repairs.action_repair_service_catalog')">
<span class="fr-action-icon"><i class="fa fa-wrench"/></span>
<span class="fr-action-text">
<span class="fr-action-title">Service Catalogue</span>
<span class="fr-action-sub">Auto-match + estimated cost</span>
</span>
</button>
</div>
<div class="mt-4 text-end">
<button class="btn btn-sm btn-light" t-on-click="() => this.refresh()">
<i class="fa fa-refresh me-1"/> Refresh
</button>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,173 @@
/** @odoo-module **/
/*
* Public client repair portal - frontend interactions.
*
* B3 phone lookup -> POST /repair/lookup_phone (jsonrpc); pre-fills the form
* B2 AI self-check -> POST /repair/self_check (jsonrpc); renders 1-3 steps
*
* Uses Odoo 19's Interaction class. All DOM building uses createElement +
* textContent (never innerHTML) so untrusted server output cannot inject markup.
*/
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
function el(tag, className, text) {
const e = document.createElement(tag);
if (className) e.className = className;
if (text != null) e.textContent = text;
return e;
}
export class FusionRepairsClientForm extends Interaction {
static selector = "form[data-fr-client-form='1']";
dynamicContent = {
"#fr_lookup_btn": { "t-on-click.prevent": this.onLookup.bind(this) },
"#fr_selfcheck_btn": { "t-on-click.prevent": this.onSelfCheck.bind(this) },
};
setup() {
this.lookupResult = this.el.querySelector("#fr_lookup_result");
this.selfCheckResult = this.el.querySelector("#fr_selfcheck_result");
}
// ------------------------------------------------------------------
// B3: phone lookup - pre-fill the form for returning clients
// ------------------------------------------------------------------
async onLookup() {
const phoneEl = this.el.querySelector("#fr_lookup_phone");
const phone = (phoneEl?.value || "").trim();
if (!phone) {
this.renderLookupMsg("alert-warning", "Enter a phone number first.");
return;
}
this.renderLookupMsg("alert-info", "Looking you up...");
let result;
try {
result = await rpc("/repair/lookup_phone", { phone });
} catch (err) {
this.renderLookupMsg("alert-warning",
"Lookup failed. Please fill the form below as usual.");
return;
}
if (result && result.error === "rate_limited") {
this.renderLookupMsg("alert-warning",
"Too many lookups from your location - please fill the form below.");
return;
}
const partners = (result && result.partners) || [];
if (partners.length === 0) {
this.renderLookupMsg("alert-secondary",
"We don't have a match yet. Please fill in the form below.");
return;
}
const p = partners[0];
this.el.querySelector("#fr_client_name").value = p.name || "";
this.el.querySelector("#fr_client_phone").value = phone;
if (p.email) this.el.querySelector("#fr_client_email").value = p.email;
if (p.street) this.el.querySelector("#fr_client_street").value = p.street;
if (p.city) this.el.querySelector("#fr_client_city").value = p.city;
this.el.querySelector("#fr_known_partner_id").value = p.id;
this.renderLookupMsg("alert-success",
`Welcome back! We've pre-filled your contact details. (Account: ${p.name})`);
}
renderLookupMsg(cls, text) {
if (!this.lookupResult) return;
this.lookupResult.replaceChildren(el("div", `alert ${cls} mb-0 mt-2`, text));
}
// ------------------------------------------------------------------
// B2: AI self-check
// ------------------------------------------------------------------
async onSelfCheck() {
const categoryId = parseInt(this.el.querySelector("#fr_category_id")?.value, 10);
const symptoms = (this.el.querySelector("#fr_issue_summary")?.value || "").trim();
if (!categoryId) {
this.renderSelfCheckMsg("alert-warning", "Pick the equipment category first.");
return;
}
if (!symptoms) {
this.renderSelfCheckMsg("alert-warning",
"Please describe what's wrong first (Step 3).");
return;
}
this.renderSelfCheckMsg("alert-info", "Looking up safe self-check steps...");
let result;
try {
result = await rpc("/repair/self_check", {
category_id: categoryId,
symptoms: [symptoms],
urgency: this.el.querySelector("[name='urgency']")?.value || "normal",
});
} catch (err) {
this.renderSelfCheckMsg("alert-warning",
"Couldn't check right now. Please go ahead and submit the form.");
return;
}
if (result && result.error === "rate_limited") {
this.renderSelfCheckMsg("alert-warning",
"Too many requests from your location. Please submit the form.");
return;
}
this.renderSelfCheckResult(result);
}
renderSelfCheckMsg(cls, text) {
if (!this.selfCheckResult) return;
this.selfCheckResult.replaceChildren(el("div", `alert ${cls}`, text));
}
renderSelfCheckResult(result) {
if (!this.selfCheckResult) return;
const children = [];
if (!result) {
this.selfCheckResult.replaceChildren();
return;
}
const card = el("div", "card border-info");
const body = el("div", "card-body");
if (result.escalate_immediately) {
const alert = el("div", "alert alert-warning mb-2");
const strong = el("strong", null,
"Please submit the form below. ");
const tail = document.createTextNode(
"Based on what you described, this isn't something to try fixing yourself. " +
"Our technician will help you.");
alert.append(strong, tail);
body.appendChild(alert);
} else {
body.appendChild(el("p", "text-muted small mb-3",
"Here are a few safe things you can try in under 2 minutes. " +
"If they don't help, submit the form below and we'll come to you."));
(result.steps || []).forEach((step, idx) => {
const stepWrap = el("div", "mb-3 p-2 border-start border-3 border-info");
stepWrap.appendChild(el("div", "fw-bold",
`${idx + 1}. ${step.instruction}`));
if (step.expected_result) {
stepWrap.appendChild(el("div", "small text-muted",
`Expected result: ${step.expected_result}`));
}
if (step.safety_note) {
stepWrap.appendChild(el("div", "small text-danger mt-1",
`Safety: ${step.safety_note}`));
}
body.appendChild(stepWrap);
});
}
if (result.disclaimer) {
body.appendChild(el("div", "small text-muted fst-italic mt-2",
result.disclaimer));
}
card.appendChild(body);
children.push(card);
this.selfCheckResult.replaceChildren(...children);
}
}
registry.category("public.interactions").add(
"fusion_repairs.client_form",
FusionRepairsClientForm,
);

View File

@@ -0,0 +1,107 @@
/** @odoo-module **/
// Sales rep portal - new service call form interactions.
// Uses Odoo 19 public Interaction class per project frontend rules
// (NOT IIFE / DOMContentLoaded). Uses only safe DOM construction
// (textContent + createElement) - no innerHTML, no XSS risk.
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
export class SalesRepRepairIntake extends Interaction {
static selector = ".o_fusion_repairs_portal";
dynamicContent = {
"#partner_search": {
"t-on-input": this._onPartnerSearchInput,
},
};
setup() {
this._partnerSearchTimer = null;
}
_onPartnerSearchInput(ev) {
const query = (ev.target.value || "").trim();
if (this._partnerSearchTimer) {
clearTimeout(this._partnerSearchTimer);
}
if (query.length < 3) {
this._renderMatches([]);
return;
}
this._partnerSearchTimer = setTimeout(async () => {
try {
const result = await rpc("/my/repair/lookup_partner", { query });
this._renderMatches(result.matches || []);
} catch (e) {
this._renderMatches([]);
}
}, 250);
}
_renderMatches(matches) {
const list = document.getElementById("partner_matches");
if (!list) {
return;
}
while (list.firstChild) {
list.removeChild(list.firstChild);
}
for (const m of matches) {
list.appendChild(this._buildMatchItem(m));
}
}
_buildMatchItem(m) {
const item = document.createElement("button");
item.type = "button";
item.className = "list-group-item list-group-item-action text-start";
const nameStrong = document.createElement("strong");
nameStrong.textContent = m.name || "";
item.appendChild(nameStrong);
if (m.phone) {
const phone = document.createElement("span");
phone.className = "text-muted ms-2";
phone.textContent = m.phone;
item.appendChild(phone);
}
if (m.repair_count) {
const badge = document.createElement("span");
badge.className = "badge bg-secondary ms-2";
badge.textContent = `${m.repair_count} repair(s)`;
item.appendChild(badge);
}
if (m.street) {
const addr = document.createElement("div");
addr.className = "small text-muted";
addr.textContent = [m.street, m.city].filter(Boolean).join(", ");
item.appendChild(addr);
}
item.addEventListener("click", () => this._selectPartner(m));
return item;
}
_selectPartner(m) {
document.getElementById("partner_id_input").value = m.id;
document.getElementById("partner_selected_name").textContent =
m.name + (m.phone ? ` (${m.phone})` : "");
document
.getElementById("partner_selected")
.classList.remove("d-none");
const list = document.getElementById("partner_matches");
while (list.firstChild) {
list.removeChild(list.firstChild);
}
document.getElementById("partner_search").value = m.name;
}
}
registry
.category("public.interactions")
.add("fusion_repairs.sales_rep_intake", SalesRepRepairIntake);

View File

@@ -0,0 +1,63 @@
// Fusion Repairs design tokens.
// Compile-time branching on $o-webclient-color-scheme makes the SAME SCSS file
// produce different values for the light bundle (web.assets_backend) and the
// dark bundle (web.assets_web_dark). Each token is wrapped in a CSS custom
// property so runtime overrides are still possible if ever needed.
//
// IMPORTANT: do NOT @import this file - per project Odoo 19 rule, register
// it as a separate entry in web.assets_backend BEFORE dashboard.scss so the
// variables are in scope when the dashboard file is compiled.
$o-webclient-color-scheme: bright !default;
// Default (light) palette.
$_fr-page-hex: #f3f4f6;
$_fr-card-hex: #ffffff;
$_fr-card-elevated-hex: #ffffff;
$_fr-border-hex: #d8dadd;
$_fr-border-soft-hex: #e5e7eb;
$_fr-text-hex: #1f2937;
$_fr-muted-hex: #6b7280;
$_fr-accent-hex: #2b6cb0;
$_fr-success-hex: #16a34a;
$_fr-warning-hex: #d97706;
$_fr-danger-hex: #dc2626;
$_fr-info-bg-hex: #eff6ff;
$_fr-success-bg-hex: #ecfdf5;
$_fr-warning-bg-hex: #fffbeb;
$_fr-danger-bg-hex: #fef2f2;
@if $o-webclient-color-scheme == dark {
$_fr-page-hex: #14181d !global;
$_fr-card-hex: #1f242b !global;
$_fr-card-elevated-hex: #262c34 !global;
$_fr-border-hex: #2d333b !global;
$_fr-border-soft-hex: #242a31 !global;
$_fr-text-hex: #e6e8eb !global;
$_fr-muted-hex: #9aa3ad !global;
$_fr-accent-hex: #60a5fa !global;
$_fr-success-hex: #34d399 !global;
$_fr-warning-hex: #fbbf24 !global;
$_fr-danger-hex: #f87171 !global;
$_fr-info-bg-hex: #1e3a5f !global;
$_fr-success-bg-hex: #14342a !global;
$_fr-warning-bg-hex: #3b2f15 !global;
$_fr-danger-bg-hex: #3c1d1d !global;
}
// CSS-variable-wrapped tokens. Use these everywhere in dashboard.scss.
$fr-page: var(--fr-page-bg, #{$_fr-page-hex});
$fr-card: var(--fr-card-bg, #{$_fr-card-hex});
$fr-card-elevated: var(--fr-card-elevated-bg, #{$_fr-card-elevated-hex});
$fr-border: var(--fr-border, #{$_fr-border-hex});
$fr-border-soft: var(--fr-border-soft, #{$_fr-border-soft-hex});
$fr-text: var(--fr-text, #{$_fr-text-hex});
$fr-muted: var(--fr-muted, #{$_fr-muted-hex});
$fr-accent: var(--fr-accent, #{$_fr-accent-hex});
$fr-success: var(--fr-success, #{$_fr-success-hex});
$fr-warning: var(--fr-warning, #{$_fr-warning-hex});
$fr-danger: var(--fr-danger, #{$_fr-danger-hex});
$fr-info-bg: var(--fr-info-bg, #{$_fr-info-bg-hex});
$fr-success-bg: var(--fr-success-bg, #{$_fr-success-bg-hex});
$fr-warning-bg: var(--fr-warning-bg, #{$_fr-warning-bg-hex});
$fr-danger-bg: var(--fr-danger-bg, #{$_fr-danger-bg-hex});

View File

@@ -0,0 +1,323 @@
// Fusion Repairs dashboard.
// Uses tokens from _fr_tokens.scss (registered first in the bundle).
// Three-layer contrast: page (grayest) -> section -> card (brightest).
.o_fusion_repairs_dashboard {
background-color: $fr-page;
color: $fr-text;
// Fill the action manager AND scroll vertically. min-height/100vh broke
// scrolling because it bypassed the parent's flex sizing.
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 24px;
.fr-hero {
background: linear-gradient(135deg, $fr-accent 0%, color-mix(in srgb, $fr-accent 60%, $fr-success) 100%);
color: #ffffff;
border-radius: 12px;
padding: 28px 32px;
margin-bottom: 24px;
display: flex;
flex-direction: column;
gap: 6px;
h1 {
font-size: 26px;
font-weight: 700;
margin: 0;
color: #ffffff;
}
p {
opacity: 0.9;
margin: 0;
color: #ffffff;
}
}
.fr-section-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: $fr-muted;
margin: 24px 0 12px 0;
}
.fr-grid {
display: grid;
gap: 16px;
margin-bottom: 8px;
&.fr-grid-stats {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
&.fr-grid-actions {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
&.fr-grid-portals {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
&.fr-grid-config {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
&.fr-grid-lists {
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
}
}
.fr-stat {
background-color: $fr-card;
border: 1px solid $fr-border;
border-radius: 10px;
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 4px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.fr-stat-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: $fr-muted;
}
.fr-stat-value {
font-size: 32px;
font-weight: 700;
line-height: 1.1;
color: $fr-text;
}
.fr-stat-sub {
font-size: 12px;
color: $fr-muted;
}
&.fr-stat-accent .fr-stat-value { color: $fr-accent; }
&.fr-stat-warning .fr-stat-value { color: $fr-warning; }
&.fr-stat-danger .fr-stat-value { color: $fr-danger; }
&.fr-stat-success .fr-stat-value { color: $fr-success; }
}
.fr-action {
background-color: $fr-card;
border: 1px solid $fr-border;
border-radius: 10px;
padding: 18px 20px;
cursor: pointer;
text-align: left;
display: flex;
align-items: center;
gap: 14px;
color: $fr-text;
font: inherit;
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
border-color: $fr-accent;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
}
.fr-action-icon {
width: 44px;
height: 44px;
min-width: 44px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: $fr-info-bg;
color: $fr-accent;
font-size: 18px;
}
.fr-action-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.fr-action-title {
font-weight: 600;
font-size: 14px;
color: $fr-text;
}
.fr-action-sub {
font-size: 12px;
color: $fr-muted;
}
&.fr-action-primary {
background: linear-gradient(135deg, $fr-accent 0%, color-mix(in srgb, $fr-accent 65%, $fr-success) 100%);
border-color: transparent;
color: #ffffff;
.fr-action-icon {
background-color: rgba(255, 255, 255, 0.18);
color: #ffffff;
}
.fr-action-title,
.fr-action-sub {
color: #ffffff;
}
.fr-action-sub { opacity: 0.85; }
&:hover { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); }
}
}
.fr-portal {
background-color: $fr-card;
border: 1px solid $fr-border;
border-radius: 10px;
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 10px;
.fr-portal-head {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 14px;
i {
color: $fr-accent;
}
}
.fr-portal-sub {
font-size: 12px;
color: $fr-muted;
}
.fr-portal-url {
background-color: $fr-info-bg;
color: $fr-text;
padding: 6px 10px;
border-radius: 6px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
word-break: break-all;
}
.fr-portal-actions {
display: flex;
gap: 8px;
margin-top: 4px;
.btn {
font-size: 12px;
padding: 6px 12px;
}
}
}
.fr-list {
background-color: $fr-card;
border: 1px solid $fr-border;
border-radius: 10px;
padding: 18px 20px;
h3 {
font-size: 14px;
font-weight: 600;
margin: 0 0 12px 0;
color: $fr-text;
}
.fr-list-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-top: 1px solid $fr-border-soft;
cursor: pointer;
gap: 12px;
&:first-of-type {
border-top: none;
}
&:hover {
background-color: $fr-info-bg;
margin: 0 -8px;
padding-left: 8px;
padding-right: 8px;
border-radius: 6px;
}
.fr-list-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.fr-list-title {
font-weight: 600;
font-size: 13px;
color: $fr-text;
}
.fr-list-sub {
font-size: 12px;
color: $fr-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fr-list-meta {
font-size: 11px;
color: $fr-muted;
white-space: nowrap;
}
}
.fr-list-empty {
text-align: center;
color: $fr-muted;
font-size: 13px;
padding: 24px 0;
}
}
.fr-pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
&.fr-pill-normal {
background-color: $fr-border-soft;
color: $fr-text;
}
&.fr-pill-urgent {
background-color: $fr-warning-bg;
color: $fr-warning;
}
&.fr-pill-safety {
background-color: $fr-danger-bg;
color: $fr-danger;
}
&.fr-pill-state {
background-color: $fr-info-bg;
color: $fr-accent;
}
}
.fr-loading {
text-align: center;
padding: 60px 0;
color: $fr-muted;
}
@media (max-width: 600px) {
padding: 16px;
.fr-hero { padding: 20px 22px; }
.fr-hero h1 { font-size: 22px; }
}
}

View File

@@ -0,0 +1,39 @@
/* Public client portal - mobile-first.
* Follows project SCSS rules: no hardcoded theme colours, large tap targets,
* adapts to website light/dark theme automatically.
*/
.o_fusion_repairs_client {
.form-control,
.form-select,
.btn {
min-height: 44px;
}
.btn-lg {
min-height: 56px;
font-size: 1.125rem;
}
.card {
border-radius: 0.75rem;
}
h1.display-5,
h2 {
line-height: 1.2;
}
@media (max-width: 575px) {
section {
padding-top: 1.5rem !important;
padding-bottom: 1.5rem !important;
}
.card-footer {
position: sticky;
bottom: 0;
background: inherit;
z-index: 2;
}
}
}

View File

@@ -0,0 +1,39 @@
/* Sales rep portal - mobile-first additions.
* Follows project CLAUDE.md rules:
* - Tap targets >=44px
* - No hardcoded theme colours
* - Cards float on a slightly grayer page background
*/
.o_fusion_repairs_portal {
.card {
border-radius: 0.5rem;
}
.form-control,
.form-select,
.btn {
min-height: 44px;
}
.btn-lg {
min-height: 52px;
}
#partner_matches {
.list-group-item {
padding: 0.75rem 1rem;
cursor: pointer;
}
}
/* Sticky bottom CTA on small screens for the submit form. */
@media (max-width: 575px) {
.card-footer {
position: sticky;
bottom: 0;
background: inherit;
z-index: 2;
}
}
}

View File

@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Intake Template -->
<record id="view_repair_intake_template_list" model="ir.ui.view">
<field name="name">fusion.repair.intake.template.list</field>
<field name="model">fusion.repair.intake.template</field>
<field name="arch" type="xml">
<list string="Intake Templates">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="question_count"/>
<field name="is_default"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_repair_intake_template_form" model="ir.ui.view">
<field name="name">fusion.repair.intake.template.form</field>
<field name="model">fusion.repair.intake.template</field>
<field name="arch" type="xml">
<form string="Intake Template">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="e.g. Stairlift - Standard Intake"/>
</h1>
</div>
<group>
<group>
<field name="code"/>
<field name="sequence"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="is_default"/>
<field name="active"/>
</group>
</group>
<field name="product_category_ids" widget="many2many_tags"/>
<notebook>
<page string="Questions" name="questions">
<field name="question_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code" optional="hide"/>
<field name="question_type"/>
<field name="required"/>
<field name="help_text" optional="hide"/>
<field name="selection_options" optional="hide"/>
<field name="symptom_keywords" optional="hide"/>
</list>
<form>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="question_type"/>
<field name="required"/>
</group>
<group>
<field name="sequence"/>
<field name="parent_question_id"/>
<field name="parent_answer_value"
invisible="not parent_question_id"/>
</group>
</group>
<field name="help_text" placeholder="Optional hint shown beneath the question"/>
<field name="selection_options"
invisible="question_type != 'selection'"
placeholder="One option per line"/>
<field name="symptom_keywords" placeholder="e.g. battery,charge,won't turn on"/>
</form>
</field>
</page>
<page string="Description" name="description">
<field name="description"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_repair_intake_template" model="ir.actions.act_window">
<field name="name">Intake Templates</field>
<field name="res_model">fusion.repair.intake.template</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_maintenance_contract_list" model="ir.ui.view">
<field name="name">fusion.repair.maintenance.contract.list</field>
<field name="model">fusion.repair.maintenance.contract</field>
<field name="arch" type="xml">
<list string="Maintenance Contracts" decoration-warning="next_due_date and next_due_date &lt;= context_today()">
<field name="name"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="interval_months"/>
<field name="last_service_date"/>
<field name="next_due_date"/>
<field name="last_reminder_band" optional="show"/>
<field name="state" widget="badge"
decoration-success="state == 'active'"
decoration-muted="state == 'cancelled'"
decoration-warning="state == 'paused'"/>
</list>
</field>
</record>
<record id="view_maintenance_contract_form" model="ir.ui.view">
<field name="name">fusion.repair.maintenance.contract.form</field>
<field name="model">fusion.repair.maintenance.contract</field>
<field name="arch" type="xml">
<form string="Maintenance Contract">
<header>
<field name="state" widget="statusbar"
statusbar_visible="draft,active,paused,cancelled"/>
</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"/>
<field name="lot_id"/>
<field name="original_sale_order_id" readonly="1"/>
</group>
<group>
<field name="interval_months"/>
<field name="last_service_date"/>
<field name="next_due_date"/>
<field name="last_reminder_band" readonly="1"/>
<field name="booking_repair_id" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_maintenance_contract" model="ir.actions.act_window">
<field name="name">Maintenance Contracts</field>
<field name="res_model">fusion.repair.maintenance.contract</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level app menu - lands on the OWL dashboard with quick-actions and KPIs -->
<menuitem id="menu_fusion_repairs_root"
name="Fusion Repairs"
sequence="48"
web_icon="fusion_repairs,static/description/icon.png"
action="action_fusion_repairs_home_dashboard"
groups="fusion_repairs.group_fusion_repairs_user"/>
<menuitem id="menu_fusion_repairs_home"
name="Dashboard"
parent="menu_fusion_repairs_root"
action="action_fusion_repairs_home_dashboard"
sequence="5"/>
<menuitem id="menu_fusion_repairs_dashboard"
name="Service Calls"
parent="menu_fusion_repairs_root"
action="action_fusion_repair_dashboard"
sequence="10"/>
<menuitem id="menu_fusion_repairs_new_call"
name="New Service Call"
parent="menu_fusion_repairs_root"
action="action_open_repair_intake_wizard"
sequence="15"/>
<menuitem id="menu_fusion_repairs_all_orders"
name="All Repair Orders"
parent="menu_fusion_repairs_root"
action="repair.action_repair_order_tree"
sequence="20"/>
<menuitem id="menu_fusion_repairs_maintenance_contracts"
name="Maintenance Contracts"
parent="menu_fusion_repairs_root"
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"/>
<menuitem id="menu_fusion_repairs_service_plans"
name="Service Plans"
parent="menu_fusion_repairs_root"
action="action_service_plan_subscription"
sequence="37"/>
<menuitem id="menu_fusion_repairs_part_orders"
name="Parts to Order"
parent="menu_fusion_repairs_root"
action="action_repair_part_order"
sequence="38"/>
<menuitem id="menu_fusion_repairs_emergency_charges"
name="Emergency Surcharges"
parent="menu_fusion_repairs_configuration"
action="action_repair_emergency_charge"
sequence="60"/>
<menuitem id="menu_fusion_repairs_callout_rate"
name="Callout Rate Card"
parent="menu_fusion_repairs_configuration"
action="action_repair_callout_rate"
sequence="65"/>
<menuitem id="menu_fusion_repairs_delivery_charge"
name="Delivery / Pickup Charges"
parent="menu_fusion_repairs_configuration"
action="action_repair_delivery_charge"
sequence="67"/>
<menuitem id="menu_fusion_repairs_labor_warranty"
name="Labor Warranties"
parent="menu_fusion_repairs_root"
action="action_repair_labor_warranty"
sequence="36"/>
<!-- Configuration -->
<menuitem id="menu_fusion_repairs_configuration"
name="Configuration"
parent="menu_fusion_repairs_root"
sequence="90"
groups="fusion_repairs.group_fusion_repairs_manager"/>
<menuitem id="menu_fusion_repairs_categories"
name="Equipment Categories"
parent="menu_fusion_repairs_configuration"
action="action_repair_product_category"
sequence="10"/>
<menuitem id="menu_fusion_repairs_intake_templates"
name="Intake Templates"
parent="menu_fusion_repairs_configuration"
action="action_repair_intake_template"
sequence="20"/>
<menuitem id="menu_fusion_repairs_service_catalog"
name="Service Catalogue"
parent="menu_fusion_repairs_configuration"
action="action_repair_service_catalog"
sequence="30"/>
<menuitem id="menu_fusion_repairs_warranty"
name="Repair Warranties"
parent="menu_fusion_repairs_configuration"
action="action_repair_warranty_coverage"
sequence="40"/>
<menuitem id="menu_fusion_repairs_qr_stickers"
name="Generate QR Stickers"
parent="menu_fusion_repairs_configuration"
action="action_qr_sticker_wizard"
sequence="50"/>
</odoo>

View File

@@ -0,0 +1,287 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================== -->
<!-- /repair landing -->
<!-- ============================================================== -->
<template id="portal_client_repair_landing" name="Repair - Landing">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center text-center">
<div class="col-12 col-lg-8">
<h1 class="display-5 fw-bold">Need a repair?</h1>
<p class="lead text-muted mb-4">
Tell us about your equipment and what's going wrong.
We'll get to it on the next business day - or sooner if urgent.
</p>
<!-- B4: surface the QR ?sn= context so the user knows we recognized their device -->
<t t-if="serial_info">
<div class="alert alert-success text-start mb-4" role="alert">
<i class="fa fa-qrcode me-1"/>
Recognized <strong t-out="serial_info.get('product_name')"/>
(Serial: <code t-out="serial_info.get('serial')"/>).
We'll pre-fill your service request.
</div>
</t>
<a t-att-href="form_url" class="btn btn-primary btn-lg px-5 py-3">
Start a Service Request
</a>
<div class="text-muted mt-4 small">
<i class="fa fa-shield-alt me-1"/>
Your information is private and used only to schedule your repair.
</div>
<div class="alert alert-warning mt-4 text-start">
<strong>Is anyone hurt right now?</strong>
If you have a medical emergency, please hang up and dial <strong>9-1-1</strong>.
</div>
<div class="text-muted mt-4 small">
Already a customer? Have your phone number handy - we'll recognize your account.
</div>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ============================================================== -->
<!-- /repair/new form -->
<!-- ============================================================== -->
<template id="portal_client_repair_form" name="Repair - Form">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-7">
<h2 class="mb-3">Service Request</h2>
<p class="text-muted">
Fill in the form below. A team member will follow up shortly.
</p>
<t t-if="error == 'missing'">
<div class="alert alert-danger">Please fill in all required fields.</div>
</t>
<t t-if="error == 'spam'">
<div class="alert alert-danger">Submission blocked. If this is a mistake, please call our office.</div>
</t>
<t t-if="error == 'rate_limited'">
<div class="alert alert-warning">Too many requests from your location. Please try again in an hour.</div>
</t>
<t t-if="error == 'server'">
<div class="alert alert-danger">Something went wrong. Please try again or call us directly.</div>
</t>
<form action="/repair/submit" method="POST"
enctype="multipart/form-data"
class="card shadow-sm"
id="fr_repair_form"
data-fr-client-form="1">
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
<!-- B3: phone lookup pre-fills + ?sn= QR pre-fills -->
<input type="hidden" name="known_partner_id" id="fr_known_partner_id" value=""/>
<input type="hidden" name="serial_number" id="fr_serial_number"
t-att-value="serial_info and serial_info.get('serial') or ''"/>
<!-- Honeypot. Real users never see this. -->
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
<label>Company name</label>
<input type="text" name="hp_company" tabindex="-1" autocomplete="off"/>
</div>
<div class="card-body p-4">
<!-- B3: phone-first lookup for returning clients -->
<div class="alert alert-info mb-3" id="fr_lookup_panel">
<h6 class="mb-2"><i class="fa fa-search me-1"/>Already a client?</h6>
<div class="input-group">
<input type="tel" id="fr_lookup_phone"
class="form-control"
placeholder="Enter phone (e.g. 905-555-1234)"/>
<button class="btn btn-outline-primary" type="button"
id="fr_lookup_btn">Look me up</button>
</div>
<small class="text-muted">
We'll pre-fill your contact info so you don't have to retype it.
</small>
<div id="fr_lookup_result" class="mt-2"></div>
</div>
<h5>1. Your contact details</h5>
<div class="mb-3">
<label class="form-label">Your name <span class="text-danger">*</span></label>
<input type="text" name="client_name" id="fr_client_name" class="form-control form-control-lg" required="required"/>
</div>
<div class="mb-3">
<label class="form-label">Phone number <span class="text-danger">*</span></label>
<input type="tel" name="client_phone" id="fr_client_phone" class="form-control form-control-lg" required="required" placeholder="(519) 555-1234"/>
</div>
<div class="mb-3">
<label class="form-label">Email (so we can send a confirmation)</label>
<input type="email" name="client_email" id="fr_client_email" class="form-control"/>
</div>
<div class="mb-3">
<label class="form-label">Street address</label>
<input type="text" name="client_street" id="fr_client_street" class="form-control"/>
</div>
<div class="mb-3">
<label class="form-label">City</label>
<input type="text" name="client_city" id="fr_client_city" class="form-control"/>
</div>
<hr/>
<h5>2. What equipment needs service?</h5>
<!-- B4: surface ?sn= context so user knows we identified their device -->
<t t-if="serial_info">
<div class="alert alert-success mb-3">
<i class="fa fa-qrcode me-1"/>
Pre-filled from QR scan: <strong t-out="serial_info.get('product_name')"/>
(Serial <code t-out="serial_info.get('serial')"/>)
</div>
</t>
<div class="mb-3">
<label class="form-label">Equipment category <span class="text-danger">*</span></label>
<select name="category_id" id="fr_category_id" class="form-select form-select-lg" required="required">
<option value="">Choose one...</option>
<t t-foreach="categories" t-as="cat">
<option t-att-value="cat.id"
t-att-selected="serial_info and serial_info.get('category_id') == cat.id">
<t t-out="cat.name"/>
</option>
</t>
</select>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="third_party" name="third_party"/>
<label class="form-check-label" for="third_party">
I didn't buy this equipment from Fusion / Westin
</label>
</div>
<hr/>
<h5>3. What's wrong?</h5>
<div class="mb-3">
<label class="form-label">Short description <span class="text-danger">*</span></label>
<input type="text" name="issue_summary" id="fr_issue_summary" class="form-control form-control-lg" required="required" placeholder="e.g. 'stairlift beeps and won't move'"/>
</div>
<div class="mb-3">
<label class="form-label">Anything else we should know?</label>
<textarea name="internal_notes" class="form-control" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Photos or short video (optional)</label>
<input type="file" name="photos" class="form-control" accept="image/*,video/*" multiple="multiple" capture="environment"/>
</div>
<!-- B2: AI self-check button. Triggered manually so we don't
spam the AI / fallback on every keystroke. -->
<div class="mb-3">
<button type="button" class="btn btn-outline-info"
id="fr_selfcheck_btn">
<i class="fa fa-magic me-1"/>
Try 1-3 safe self-check steps first (optional)
</button>
<small class="text-muted d-block mt-1">
We'll suggest a couple of things you can safely try in under 2 minutes.
If they don't help, just submit and we'll come to you.
</small>
<div id="fr_selfcheck_result" class="mt-3"></div>
</div>
<hr/>
<h5>4. How urgent is it?</h5>
<div class="mb-3">
<select name="urgency" class="form-select form-select-lg" required="required">
<option value="normal">Normal - within a few days</option>
<option value="urgent">Urgent - within 24 hours</option>
<option value="safety">Safety issue - right now</option>
</select>
<small class="text-muted">
If anyone is hurt, hang up and call <strong>9-1-1</strong>.
</small>
</div>
</div>
<div class="card-footer text-end">
<button type="submit" class="btn btn-primary btn-lg">
Submit Request
</button>
</div>
</form>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ============================================================== -->
<!-- /repair/thanks -->
<!-- ============================================================== -->
<template id="portal_client_repair_thanks" name="Repair - Thanks">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-7 text-center">
<i class="fa fa-check-circle fa-4x text-success mb-3"/>
<h1 class="mb-3">Got it!</h1>
<p class="lead text-muted">
Your service request <strong t-if="ref"><t t-out="ref"/></strong> was received.
We'll get back to you on the next business day or sooner if you marked it urgent.
</p>
</div>
<!-- B5: post-submit upsell - maintenance plan + next steps -->
<div class="col-12 col-lg-7 mt-4">
<div class="card border-info">
<div class="card-body">
<h5 class="card-title">
<i class="fa fa-lightbulb-o me-1 text-warning"/>
Want to avoid this next time?
</h5>
<p class="card-text">
Most of our regular clients enrol in an <strong>annual
maintenance plan</strong> - we visit twice a year, catch wear
before it becomes a breakdown, and you pay a lot less for
peace of mind than for an emergency call-out.
</p>
<ul class="small text-muted">
<li>Priority booking - your calls jump the queue</li>
<li>Free safety inspection certificate (stairlifts, porch lifts)</li>
<li>Discounted parts</li>
<li>Annual reminder so you never forget</li>
</ul>
<a href="/shop?category=maintenance" class="btn btn-info">
See our maintenance plans
</a>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title mb-2">
<i class="fa fa-info-circle me-1"/>
What happens next
</h6>
<ol class="small mb-0">
<li>You will get a confirmation email within a few minutes.</li>
<li>Our office reviews your request the next business day.</li>
<li>We call you to confirm a technician visit time.</li>
<li>You will get a reminder the day before the visit.</li>
</ol>
</div>
</div>
<div class="text-center mt-4">
<a href="/repair" class="btn btn-outline-secondary">Back to home</a>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================== -->
<!-- Booking landing page -->
<!-- ============================================================== -->
<template id="portal_maintenance_book" name="Maintenance - Book Visit">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-7">
<h1 class="mb-3">Book your maintenance visit</h1>
<p class="lead text-muted">
Hello <strong t-out="contract.partner_id.name"/>!
Your <strong t-out="contract.product_id.display_name"/> is due for service.
</p>
<t t-if="already_booked">
<div class="alert alert-info">
<strong>Already booked.</strong>
Your maintenance visit reference is
<strong t-out="contract.booking_repair_id.name"/>. We will be in touch shortly.
</div>
</t>
<t t-if="not already_booked">
<form t-attf-action="/repairs/maintenance/book/{{ contract.booking_token }}/confirm"
method="POST" class="card shadow-sm">
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
<div class="card-body p-4">
<div class="mb-3">
<label class="form-label">Preferred date</label>
<input type="date" name="preferred_date"
class="form-control form-control-lg"
t-att-value="default_date"/>
<small class="text-muted">
A team member will call to confirm the exact time.
</small>
</div>
<p class="small text-muted mb-0">
By submitting, you confirm you want this maintenance visit.
Contract reference <strong t-out="contract.name"/>.
</p>
</div>
<div class="card-footer text-end">
<button type="submit" class="btn btn-success btn-lg">
Yes, book my visit
</button>
</div>
</form>
</t>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ============================================================== -->
<!-- Thanks -->
<!-- ============================================================== -->
<template id="portal_maintenance_thanks" name="Maintenance - Booked">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center text-center">
<div class="col-12 col-lg-7">
<i class="fa fa-calendar-check fa-4x text-success mb-3"/>
<h1>Booking received</h1>
<p class="lead text-muted">
Your maintenance visit <strong t-out="repair.name"/> has been scheduled
and our office will reach out to confirm the exact time.
</p>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ============================================================== -->
<!-- On-call ack pages (CL15) -->
<!-- ============================================================== -->
<template id="portal_on_call_ack_ok" name="On-Call Page Acknowledged">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center text-center">
<div class="col-12 col-lg-7">
<i class="fa fa-check-circle fa-4x text-success mb-3"/>
<h1>Page acknowledged</h1>
<p class="lead text-muted">
Thank you. <strong t-out="repair_name"/> is now your responsibility -
no further pages will be sent for this request.
</p>
<a href="/web" class="btn btn-outline-secondary mt-3">Open Odoo</a>
</div>
</div>
</section>
</div>
</t>
</template>
<template id="portal_on_call_ack_invalid" name="On-Call Page Invalid">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center text-center">
<div class="col-12 col-lg-7">
<i class="fa fa-exclamation-triangle fa-3x text-warning mb-3"/>
<h1>Link not valid</h1>
<p class="lead text-muted">
This on-call acknowledgement link is no longer valid. It may have been
acknowledged already or the page expired. Open Odoo and look up the
repair in the Fusion Repairs dashboard.
</p>
<a href="/web" class="btn btn-outline-secondary mt-3">Open Odoo</a>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ============================================================== -->
<!-- Invalid token -->
<!-- ============================================================== -->
<template id="portal_maintenance_invalid_token" name="Maintenance - Invalid Link">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center text-center">
<div class="col-12 col-lg-7">
<i class="fa fa-exclamation-triangle fa-3x text-warning mb-3"/>
<h1>Link not valid</h1>
<p class="lead text-muted">
This booking link is no longer valid or has been used. Please call our
office directly to schedule your maintenance visit.
</p>
<a href="/repair" class="btn btn-outline-secondary">Submit a service request</a>
</div>
</div>
</section>
</div>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,281 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================== -->
<!-- Sales Rep Repair Intake Form -->
<!-- ============================================================== -->
<template id="portal_sales_rep_repair_form" name="Sales Rep - New Service Call">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="o_portal_my_doc o_fusion_repairs_portal">
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-lg-8">
<h2 class="mb-3">New Service Call</h2>
<p class="text-muted">
Submit a repair request on behalf of a client. The office will follow up to schedule a technician.
</p>
<t t-if="request.params.get('error') == 'partner'">
<div class="alert alert-danger">Please select a client.</div>
</t>
<t t-if="request.params.get('error') == 'server'">
<div class="alert alert-danger">An error occurred saving the request. Please try again.</div>
</t>
<form action="/my/repair/submit" method="POST"
enctype="multipart/form-data"
class="card shadow-sm">
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
<div class="card-body p-4">
<!-- Step 1: Client lookup -->
<h5 class="mb-3">1. Client</h5>
<div class="mb-3">
<label class="form-label">Search by name, phone or email</label>
<input type="text"
id="partner_search"
class="form-control form-control-lg"
placeholder="Start typing..."
autocomplete="off"/>
<div id="partner_matches" class="list-group mt-2"></div>
<input type="hidden" name="partner_id" id="partner_id_input"/>
<div id="partner_selected" class="alert alert-info mt-2 d-none">
<strong>Selected:</strong>
<span id="partner_selected_name"></span>
</div>
</div>
<hr/>
<!-- Step 2: Equipment -->
<h5 class="mb-3">2. Equipment</h5>
<div class="mb-3">
<label class="form-label">Equipment category</label>
<select name="category_id" class="form-select form-select-lg" required="required">
<option value="">Choose a category...</option>
<t t-foreach="categories" t-as="cat">
<option t-att-value="cat.id"
t-att-data-safety="1 if cat.safety_critical else 0">
<t t-out="cat.name"/>
</option>
</t>
</select>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input"
id="third_party" name="third_party"/>
<label class="form-check-label" for="third_party">
This equipment was not purchased from us
</label>
</div>
<hr/>
<!-- Step 3: Issue -->
<h5 class="mb-3">3. What's the issue?</h5>
<div class="mb-3">
<label class="form-label">Short summary</label>
<input type="text" name="issue_summary"
class="form-control form-control-lg"
placeholder="e.g. 'stairlift stops halfway up'"
required="required"/>
</div>
<div class="mb-3">
<label class="form-label">Symptom keyword (optional)</label>
<input type="text" name="issue_category"
class="form-control"
placeholder="e.g. battery, motor, remote"/>
</div>
<div class="mb-3">
<label class="form-label">Details / what the client said</label>
<textarea name="internal_notes" class="form-control" rows="3"
placeholder="Free-form notes from the call..."></textarea>
</div>
<hr/>
<!-- Step 4: Urgency -->
<h5 class="mb-3">4. Urgency</h5>
<div class="mb-3">
<select name="urgency" class="form-select form-select-lg" required="required">
<option value="normal" selected="selected">Normal (within a few days)</option>
<option value="urgent">Urgent (within 24 hours)</option>
<option value="safety">Safety issue (right now)</option>
</select>
</div>
<hr/>
<!-- Step 5: Photos -->
<h5 class="mb-3">5. Photos (optional)</h5>
<div class="mb-3">
<input type="file" name="photos"
class="form-control"
accept="image/*,video/*" multiple="multiple"
capture="environment"/>
<small class="text-muted">Tap to take a photo or pick from gallery.</small>
</div>
</div>
<div class="card-footer text-end">
<button type="submit" class="btn btn-primary btn-lg">
Submit Service Call
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</t>
</template>
<!-- ============================================================== -->
<!-- Sales Rep "My Service Calls" list -->
<!-- ============================================================== -->
<template id="portal_sales_rep_repair_list" name="Sales Rep - My Service Calls">
<t t-call="portal.portal_layout">
<div class="o_portal_my_doc o_fusion_repairs_portal">
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>My Service Calls</h2>
<a href="/my/repair/new" class="btn btn-primary">+ New Service Call</a>
</div>
<t t-if="not repairs">
<div class="alert alert-info">
You haven't submitted any service calls yet.
<a href="/my/repair/new">Submit your first one.</a>
</div>
</t>
<div class="row g-3">
<t t-foreach="repairs" t-as="repair">
<div class="col-12 col-md-6">
<a t-att-href="'/my/repair/%s' % repair.id"
class="card text-decoration-none text-reset shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h5 class="card-title mb-1">
<t t-out="repair.name"/>
</h5>
<span t-attf-class="badge bg-#{'danger' if repair.x_fc_urgency == 'safety' else ('warning' if repair.x_fc_urgency == 'urgent' else 'secondary')}">
<t t-out="dict(repair._fields['x_fc_urgency'].selection).get(repair.x_fc_urgency)"/>
</span>
</div>
<p class="card-text small text-muted mb-1">
<i class="fa fa-user me-1"/>
<t t-out="repair.partner_id.name or 'Unknown'"/>
</p>
<p class="card-text small text-muted mb-1" t-if="repair.x_fc_repair_category_id">
<i class="fa fa-wrench me-1"/>
<t t-out="repair.x_fc_repair_category_id.name"/>
</p>
<p class="card-text small mb-0">
<span class="badge bg-light text-dark">
<t t-out="dict(repair._fields['state'].selection).get(repair.state)"/>
</span>
<span class="text-muted ms-2">
<t t-out="repair.create_date" t-options="{'widget': 'datetime'}"/>
</span>
</p>
</div>
</a>
</div>
</t>
</div>
</div>
</div>
</t>
</template>
<!-- ============================================================== -->
<!-- Sales Rep Repair Detail -->
<!-- ============================================================== -->
<template id="portal_sales_rep_repair_detail" name="Sales Rep - Repair Detail">
<t t-call="portal.portal_layout">
<div class="o_portal_my_doc o_fusion_repairs_portal">
<div class="container py-4">
<t t-if="thanks">
<div class="alert alert-success">
Service call <strong><t t-out="repair.name"/></strong> submitted.
The office will follow up shortly to schedule a technician.
</div>
</t>
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="mb-1"><t t-out="repair.name"/></h2>
<p class="text-muted mb-0">
<t t-out="dict(repair._fields['state'].selection).get(repair.state)"/>
<span class="ms-2">&#183;</span>
<span class="ms-2">
<t t-out="repair.create_date" t-options="{'widget': 'datetime'}"/>
</span>
</p>
</div>
<a href="/my/repairs" class="btn btn-outline-secondary">Back to list</a>
</div>
<div class="card mb-3 shadow-sm">
<div class="card-body">
<h5 class="card-title">Client</h5>
<p class="mb-1"><strong t-out="repair.partner_id.name"/></p>
<p class="text-muted small mb-0" t-if="repair.partner_id.phone">
<i class="fa fa-phone me-1"/><t t-out="repair.partner_id.phone"/>
</p>
</div>
</div>
<div class="card mb-3 shadow-sm">
<div class="card-body">
<h5 class="card-title">Equipment &amp; Issue</h5>
<p class="mb-1" t-if="repair.x_fc_repair_category_id">
<strong>Category:</strong>
<t t-out="repair.x_fc_repair_category_id.name"/>
</p>
<p class="mb-1" t-if="repair.product_id">
<strong>Product:</strong>
<t t-out="repair.product_id.display_name"/>
</p>
<p class="mb-1">
<strong>Urgency:</strong>
<t t-out="dict(repair._fields['x_fc_urgency'].selection).get(repair.x_fc_urgency)"/>
</p>
<p class="mb-1" t-if="repair.x_fc_third_party_equipment">
<span class="badge bg-warning">Third-party equipment</span>
</p>
<p class="mb-0 mt-2" t-if="repair.internal_notes">
<div t-field="repair.internal_notes"/>
</p>
</div>
</div>
<div class="card mb-3 shadow-sm" t-if="repair.x_fc_technician_task_count">
<div class="card-body">
<h5 class="card-title">Scheduled Visit</h5>
<t t-foreach="repair.x_fc_technician_task_ids" t-as="task">
<p class="mb-1">
<i class="fa fa-calendar me-1"/>
<t t-out="task.scheduled_date"/>
<span class="ms-2">with <t t-out="task.technician_id.name"/></span>
<span t-attf-class="badge ms-2 bg-#{'success' if task.status == 'completed' else 'info'}">
<t t-out="dict(task._fields['status'].selection).get(task.status)"/>
</span>
</p>
</t>
</div>
</div>
</div>
</div>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_callout_rate_list" model="ir.ui.view">
<field name="name">fusion.repair.callout.rate.list</field>
<field name="model">fusion.repair.callout.rate</field>
<field name="arch" type="xml">
<list string="Callout Rate Card" editable="bottom">
<field name="tier"/>
<field name="base_callout_fee" widget="monetary"/>
<field name="second_tech_fee" widget="monetary"/>
<field name="additional_tech_fee" widget="monetary"/>
<field name="hourly_labor_rate" widget="monetary"/>
<field name="minimum_labor_hours"/>
<field name="travel_distance_threshold_km"/>
<field name="travel_per_km_fee" widget="monetary"/>
<field name="effective_from"/>
<field name="currency_id" invisible="1"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="action_repair_callout_rate" model="ir.actions.act_window">
<field name="name">Callout Rate Card</field>
<field name="res_model">fusion.repair.callout.rate</field>
<field name="view_mode">list</field>
</record>
</odoo>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Client action that mounts the OWL FusionRepairsDashboard component. -->
<record id="action_fusion_repairs_home_dashboard" model="ir.actions.client">
<field name="name">Fusion Repairs</field>
<field name="tag">fusion_repairs.dashboard</field>
</record>
</odoo>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_delivery_charge_list" model="ir.ui.view">
<field name="name">fusion.repair.delivery.charge.list</field>
<field name="model">fusion.repair.delivery.charge</field>
<field name="arch" type="xml">
<list string="Delivery / Pickup Charges" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="charge_type"/>
<field name="amount" widget="monetary"/>
<field name="travel_per_km_fee" widget="monetary"/>
<field name="travel_distance_threshold_km"/>
<field name="description" optional="show"/>
<field name="currency_id" invisible="1"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="action_repair_delivery_charge" model="ir.actions.act_window">
<field name="name">Delivery / Pickup Charges</field>
<field name="res_model">fusion.repair.delivery.charge</field>
<field name="view_mode">list</field>
</record>
</odoo>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_emergency_charge_list" model="ir.ui.view">
<field name="name">fusion.repair.emergency.charge.list</field>
<field name="model">fusion.repair.emergency.charge</field>
<field name="arch" type="xml">
<list string="Rush / Emergency Surcharges" editable="bottom">
<field name="category_id"/>
<field name="tier"/>
<field name="base_amount" widget="monetary"/>
<field name="per_tech_multiplier"/>
<field name="currency_id" invisible="1"/>
<field name="active" widget="boolean_toggle"/>
<field name="description" optional="hide"/>
</list>
</field>
</record>
<record id="action_repair_emergency_charge" model="ir.actions.act_window">
<field name="name">Emergency Surcharges</field>
<field name="res_model">fusion.repair.emergency.charge</field>
<field name="view_mode">list</field>
</record>
</odoo>

View 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>

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_labor_warranty_list" model="ir.ui.view">
<field name="name">fusion.repair.labor.warranty.list</field>
<field name="model">fusion.repair.labor.warranty</field>
<field name="arch" type="xml">
<list string="Store Labor Warranties"
decoration-success="state == 'active'"
decoration-warning="state == 'expired'"
decoration-danger="state == 'void'"
decoration-muted="state == 'consumed'">
<field name="name"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="lot_id" optional="show"/>
<field name="sale_order_id" optional="show"/>
<field name="warranty_years"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="state" widget="badge"/>
<field name="void_reason" optional="hide"/>
</list>
</field>
</record>
<record id="view_repair_labor_warranty_form" model="ir.ui.view">
<field name="name">fusion.repair.labor.warranty.form</field>
<field name="model">fusion.repair.labor.warranty</field>
<field name="arch" type="xml">
<form string="Store Labor Warranty">
<header>
<button name="action_reinstate" type="object"
string="Reinstate" class="btn-secondary"
invisible="state != 'void'"
groups="fusion_repairs.group_fusion_repairs_manager"
confirm="Reinstate this voided warranty? Use only when the original void was a mistake."/>
<field name="state" widget="statusbar"
statusbar_visible="active,expired,void,consumed"/>
</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="sale_order_id" options="{'no_create': True}"/>
</group>
<group>
<field name="warranty_years"/>
<field name="start_date"/>
<field name="end_date" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group string="Void Information" invisible="state != 'void'">
<field name="void_reason" readonly="1"/>
<field name="voided_by_id" readonly="1"/>
<field name="voided_at" readonly="1"/>
<field name="void_notes" readonly="1"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_repair_labor_warranty" model="ir.actions.act_window">
<field name="name">Labor Warranties</field>
<field name="res_model">fusion.repair.labor.warranty</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,419 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================== -->
<!-- Form view extensions -->
<!-- ============================================================== -->
<record id="view_repair_order_form_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">repair.order.form.inherit.fusion_repairs</field>
<field name="model">repair.order</field>
<field name="inherit_id" ref="repair.view_repair_order_form"/>
<field name="arch" type="xml">
<!-- Header action buttons (visit report + collect payment) -->
<xpath expr="//header" position="inside">
<button name="action_open_visit_report"
type="object"
string="Visit Report"
class="btn-primary"
invisible="state in ('draft', 'cancel') or x_fc_technician_task_count == 0"
groups="fusion_repairs.group_fusion_repairs_user"/>
<button name="action_collect_payment"
type="object"
string="Collect Payment"
class="btn-secondary"
invisible="state != 'done'"
groups="fusion_repairs.group_fusion_repairs_user"/>
<button name="action_offer_loaner"
type="object"
string="Offer Loaner"
class="btn-secondary"
icon="fa-handshake-o"
invisible="state in ('done', 'cancel')"
groups="fusion_repairs.group_fusion_repairs_user"/>
<!-- Bundle 8: squeeze a rush into today's tech route + acknowledge surcharge -->
<button name="action_squeeze_into_today"
type="object"
string="Squeeze into Today"
class="btn-warning"
icon="fa-flash"
invisible="state in ('done', 'cancel') or not x_fc_rush_requested"
groups="fusion_repairs.group_fusion_repairs_dispatcher"/>
<button name="action_acknowledge_rush"
type="object"
string="Client Agreed to Rush Price"
class="btn-warning"
icon="fa-check"
invisible="not x_fc_rush_requested or x_fc_rush_acknowledged_at"
groups="fusion_repairs.group_fusion_repairs_user"/>
<!-- Bundle 9: warranty + waive (waive group-gated server-side too) -->
<button name="action_check_labor_warranty"
type="object"
string="Check Labor Warranty"
class="btn-secondary"
icon="fa-shield"
invisible="state in ('done', 'cancel')"
groups="fusion_repairs.group_fusion_repairs_user"/>
<button name="action_waive_labor_fee"
type="object"
string="Waive Labor Fee"
class="btn-warning"
icon="fa-percent"
invisible="x_fc_labor_waived or state in ('done', 'cancel')"
groups="fusion_repairs.group_fusion_repairs_sales_rep"/>
</xpath>
<!-- Smart buttons: Technician Tasks + Intake Answers + Original SO. -->
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_technician_tasks"
type="object"
class="oe_stat_button"
icon="fa-wrench"
invisible="x_fc_technician_task_count == 0">
<field name="x_fc_technician_task_count" widget="statinfo" string="Tech Tasks"/>
</button>
<button name="action_view_intake_answers"
type="object"
class="oe_stat_button"
icon="fa-list-alt"
invisible="x_fc_intake_answer_count == 0">
<field name="x_fc_intake_answer_count" widget="statinfo" string="Answers"/>
</button>
<button name="action_view_original_sale_order"
type="object"
class="oe_stat_button"
icon="fa-dollar"
invisible="not x_fc_original_sale_order_id">
<field name="x_fc_original_sale_order_id" widget="statinfo" string="Original SO"/>
</button>
</xpath>
<!-- Add intake metadata under partner_id -->
<xpath expr="//field[@name='partner_id']" position="after">
<field name="x_fc_repair_category_id" options="{'no_create': True}"/>
<field name="x_fc_urgency" widget="badge"
decoration-success="x_fc_urgency == 'normal'"
decoration-warning="x_fc_urgency == 'urgent'"
decoration-danger="x_fc_urgency == 'safety'"/>
<field name="x_fc_third_party_equipment"/>
<field name="x_fc_is_quote_only"/>
<field name="x_fc_intake_source" readonly="1"/>
<field name="x_fc_intake_user_id" readonly="1" invisible="not x_fc_intake_user_id"/>
<field name="x_fc_intake_session_id" readonly="1" invisible="not x_fc_intake_session_id"/>
</xpath>
<!-- Add a Fusion Repairs notebook tab with intake + photos. -->
<xpath expr="//notebook" position="inside">
<page string="Intake" name="fusion_intake">
<group>
<group>
<field name="x_fc_intake_template_id" readonly="1"/>
<field name="x_fc_issue_category"/>
</group>
<group>
<field name="x_fc_warranty_override_reason"
placeholder="Reason if warranty status was overridden"/>
<field name="x_fc_estimated_duration" widget="float_time"/>
</group>
</group>
<separator string="Answers"/>
<field name="x_fc_intake_answer_ids" readonly="1">
<list>
<field name="sequence" column_invisible="True"/>
<field name="question_name"/>
<field name="value_display"/>
<field name="question_type" optional="hide"/>
</list>
</field>
<separator string="Photos &amp; Videos"/>
<field name="x_fc_photo_ids" widget="many2many_binary"/>
</page>
<page string="Pricing" name="fusion_pricing" invisible="not x_fc_estimated_cost and not x_fc_actual_cost">
<group>
<group>
<field name="x_fc_estimated_cost" widget="monetary"/>
<field name="x_fc_actual_cost" widget="monetary"/>
</group>
<group>
<field name="x_fc_cost_variance_pct" widget="float" digits="[16,2]"/>
<field name="x_fc_requires_requote"/>
<field name="company_currency_id" invisible="1"/>
</group>
</group>
</page>
<page string="AI Brief" name="fusion_ai" invisible="not x_fc_ai_summary">
<field name="x_fc_ai_summary" readonly="1"/>
</page>
<page string="Margin" name="fusion_margin"
groups="fusion_repairs.group_fusion_repairs_manager">
<group>
<group>
<field name="x_fc_revenue" widget="monetary"/>
<field name="x_fc_labour_cost" widget="monetary"/>
<field name="x_fc_parts_cost" widget="monetary"/>
</group>
<group>
<field name="x_fc_margin" widget="monetary"/>
<field name="x_fc_margin_pct" widget="float" digits="[12,1]"/>
<field name="company_currency_id" invisible="1"/>
</group>
</group>
</page>
<page string="Callout Pricing" name="fusion_callout">
<group>
<group string="Inputs">
<field name="x_fc_callout_tier"/>
<field name="x_fc_in_shop"/>
<field name="x_fc_callout_techs"
invisible="x_fc_in_shop"/>
<field name="x_fc_callout_distance_km"
invisible="x_fc_in_shop"/>
<field name="x_fc_callout_labor_hours"/>
</group>
<group string="Warranty / Waiver">
<field name="x_fc_labor_warranty_id" readonly="1"/>
<field name="x_fc_labor_warranty_status" widget="badge"
decoration-success="x_fc_labor_warranty_status == 'eligible'"
decoration-warning="x_fc_labor_warranty_status in ('expired', 'waived')"
decoration-danger="x_fc_labor_warranty_status == 'void_misuse'"/>
<field name="x_fc_labor_waived" readonly="1"/>
<field name="x_fc_labor_waived_by_id" readonly="1"
invisible="not x_fc_labor_waived"/>
<field name="x_fc_labor_waived_at" readonly="1"
invisible="not x_fc_labor_waived"/>
<field name="x_fc_labor_waived_reason"
invisible="not x_fc_labor_waived"
readonly="not x_fc_labor_waived"/>
</group>
</group>
<separator string="Quote Breakdown"/>
<group>
<group>
<field name="x_fc_quote_callout_base" widget="monetary" readonly="1"/>
<field name="x_fc_quote_extra_techs" widget="monetary" readonly="1"/>
<field name="x_fc_quote_labor" widget="monetary" readonly="1"/>
<field name="x_fc_quote_travel" widget="monetary" readonly="1"/>
</group>
<group>
<field name="x_fc_quote_waived" widget="monetary" readonly="1"/>
<field name="x_fc_quote_total" widget="monetary" readonly="1"
class="oe_subtotal_footer_separator"/>
</group>
</group>
</page>
<page string="Rush / Parts" name="fusion_rush_parts">
<group>
<group string="Rush Service">
<field name="x_fc_rush_requested"/>
<field name="x_fc_rush_tier"
invisible="not x_fc_rush_requested"/>
<field name="x_fc_rush_techs_required"
invisible="not x_fc_rush_requested"/>
<field name="x_fc_rush_surcharge"
widget="monetary"
readonly="1"
invisible="not x_fc_rush_requested"/>
<field name="x_fc_rush_acknowledged_at"
readonly="1"
invisible="not x_fc_rush_requested"/>
<field name="x_fc_rush_acknowledged_by_id"
readonly="1"
invisible="not x_fc_rush_requested"/>
</group>
<group string="Awaiting Parts">
<field name="x_fc_parts_awaiting" readonly="1"/>
<field name="x_fc_parts_eta_date" readonly="1"
invisible="not x_fc_parts_awaiting"/>
<field name="x_fc_part_order_count" readonly="1"/>
</group>
</group>
<field name="x_fc_part_order_ids" readonly="1">
<list>
<field name="name"/>
<field name="description"/>
<field name="oem_part_number"/>
<field name="quantity"/>
<field name="expected_date"/>
<field name="received_date"/>
<field name="state" widget="badge"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
<!-- ============================================================== -->
<!-- Kanban: add urgency badge + intake source -->
<!-- ============================================================== -->
<record id="view_repair_order_kanban_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">repair.order.kanban.inherit.fusion_repairs</field>
<field name="model">repair.order</field>
<field name="inherit_id" ref="repair.view_repair_kanban"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="x_fc_urgency"/>
<field name="x_fc_third_party_equipment"/>
</xpath>
</field>
</record>
<!-- ============================================================== -->
<!-- List: add urgency + source columns -->
<!-- ============================================================== -->
<record id="view_repair_order_list_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">repair.order.list.inherit.fusion_repairs</field>
<field name="model">repair.order</field>
<field name="inherit_id" ref="repair.view_repair_order_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="x_fc_urgency" widget="badge"
decoration-success="x_fc_urgency == 'normal'"
decoration-warning="x_fc_urgency == 'urgent'"
decoration-danger="x_fc_urgency == 'safety'"
optional="show"/>
<field name="x_fc_intake_source" optional="hide"/>
<field name="x_fc_third_party_equipment" optional="hide"/>
</xpath>
</field>
</record>
<!-- ============================================================== -->
<!-- New Service Call action - opens the wizard as a modal -->
<!-- ============================================================== -->
<record id="action_open_repair_intake_wizard" model="ir.actions.act_window">
<field name="name">New Service Call</field>
<field name="res_model">fusion.repair.intake.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- ============================================================== -->
<!-- Fusion Repairs Service Calls dashboard -->
<!-- Branded kanban / list of repair.order filtered to repairs that -->
<!-- came through one of the Fusion intake surfaces, with the -->
<!-- New Service Call wizard wired into the header. -->
<!-- ============================================================== -->
<record id="view_fusion_repair_dashboard_kanban" model="ir.ui.view">
<field name="name">repair.order.dashboard.fusion_repairs</field>
<field name="model">repair.order</field>
<field name="arch" type="xml">
<kanban default_group_by="state"
class="o_kanban_small_column o_kanban_repair_dashboard"
sample="1">
<field name="name"/>
<field name="partner_id"/>
<field name="state"/>
<field name="x_fc_urgency"/>
<field name="x_fc_third_party_equipment"/>
<field name="x_fc_repair_category_id"/>
<field name="x_fc_intake_source"/>
<field name="x_fc_estimated_cost"/>
<field name="company_currency_id"/>
<field name="schedule_date"/>
<templates>
<t t-name="card">
<div class="d-flex justify-content-between mb-2">
<strong class="o_kanban_record_title">
<field name="name"/>
</strong>
<span t-attf-class="badge {{ {'safety':'text-bg-danger','urgent':'text-bg-warning','normal':'text-bg-secondary'}[record.x_fc_urgency.raw_value] }}">
<field name="x_fc_urgency"/>
</span>
</div>
<div class="text-muted small mb-1">
<i class="fa fa-user me-1"/>
<field name="partner_id"/>
</div>
<div class="text-muted small mb-1" t-if="record.x_fc_repair_category_id.raw_value">
<i class="fa fa-wrench me-1"/>
<field name="x_fc_repair_category_id"/>
</div>
<div class="text-muted small mb-1" t-if="record.schedule_date.raw_value">
<i class="fa fa-calendar me-1"/>
<field name="schedule_date" widget="date"/>
</div>
<div class="d-flex justify-content-between mt-2">
<span class="small text-muted">
<field name="x_fc_intake_source"/>
</span>
<span t-if="record.x_fc_third_party_equipment.raw_value"
class="badge text-bg-warning small">3rd-party</span>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_fusion_repair_dashboard_search" model="ir.ui.view">
<field name="name">repair.order.search.fusion_repairs</field>
<field name="model">repair.order</field>
<field name="arch" type="xml">
<search string="Service Calls">
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_repair_category_id"/>
<filter string="Today" name="today"
domain="[('create_date', '&gt;=', datetime.datetime.combine(context_today(), datetime.time(0,0,0)))]"/>
<filter string="This Week" name="week"
domain="[('create_date', '&gt;=', datetime.datetime.combine(context_today() - datetime.timedelta(days=7), datetime.time(0,0,0)))]"/>
<separator/>
<filter string="Safety" name="safety"
domain="[('x_fc_urgency', '=', 'safety')]"/>
<filter string="Urgent" name="urgent"
domain="[('x_fc_urgency', '=', 'urgent')]"/>
<filter string="Third-Party" name="thirdparty"
domain="[('x_fc_third_party_equipment', '=', True)]"/>
<filter string="Quote Only" name="quote_only"
domain="[('x_fc_is_quote_only', '=', True)]"/>
<filter string="Rush / Emergency" name="rush"
domain="[('x_fc_rush_requested', '=', True)]"/>
<filter string="Awaiting Parts" name="awaiting_parts"
domain="[('x_fc_parts_awaiting', '=', True)]"/>
<separator/>
<filter string="From Backend Wizard" name="src_backend"
domain="[('x_fc_intake_source', '=', 'backend_wizard')]"/>
<filter string="From Sales Rep Portal" name="src_salesrep"
domain="[('x_fc_intake_source', '=', 'sales_rep_portal')]"/>
<filter string="From Client Portal" name="src_client"
domain="[('x_fc_intake_source', '=', 'client_portal')]"/>
<separator/>
<filter string="Open (not closed)" name="open"
domain="[('state', 'not in', ('done', 'cancel'))]"/>
<group>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
<filter string="Urgency" name="group_urgency" context="{'group_by': 'x_fc_urgency'}"/>
<filter string="Category" name="group_category" context="{'group_by': 'x_fc_repair_category_id'}"/>
<filter string="Intake Source" name="group_source" context="{'group_by': 'x_fc_intake_source'}"/>
</group>
</search>
</field>
</record>
<record id="action_fusion_repair_dashboard" model="ir.actions.act_window">
<field name="name">Service Calls</field>
<field name="res_model">repair.order</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_fusion_repair_dashboard_search"/>
<field name="context">{'search_default_open': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No service calls yet</p>
<p>
Click <strong>New</strong> in the top-left to open the guided
intake wizard. The form will walk you through caller info,
equipment selection, the issue, urgency and photos.
</p>
</field>
</record>
<record id="action_fusion_repair_dashboard_kanban" model="ir.actions.act_window.view">
<field name="sequence" eval="1"/>
<field name="view_mode">kanban</field>
<field name="view_id" ref="view_fusion_repair_dashboard_kanban"/>
<field name="act_window_id" ref="action_fusion_repair_dashboard"/>
</record>
</odoo>

View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_part_order_list" model="ir.ui.view">
<field name="name">fusion.repair.part.order.list</field>
<field name="model">fusion.repair.part.order</field>
<field name="arch" type="xml">
<list string="Parts to Order"
decoration-info="state == 'draft'"
decoration-warning="state == 'ordered'"
decoration-success="state == 'received'"
decoration-muted="state in ('fitted', 'cancelled')">
<field name="name"/>
<field name="partner_id"/>
<field name="repair_order_id"/>
<field name="description"/>
<field name="oem_part_number" optional="show"/>
<field name="manufacturer" optional="show"/>
<field name="quantity"/>
<field name="ordered_date" optional="show"/>
<field name="expected_date" optional="show"/>
<field name="received_date" optional="hide"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<record id="view_repair_part_order_form" model="ir.ui.view">
<field name="name">fusion.repair.part.order.form</field>
<field name="model">fusion.repair.part.order</field>
<field name="arch" type="xml">
<form string="Part Order">
<header>
<button name="action_mark_ordered" type="object"
string="Mark Ordered with Manufacturer" class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_mark_received" type="object"
string="Mark Received in Warehouse" class="btn-primary"
invisible="state != 'ordered'"/>
<button name="action_mark_fitted" type="object"
string="Mark Fitted" class="btn-secondary"
invisible="state != 'received'"/>
<button name="action_cancel" type="object"
string="Cancel" class="btn-secondary"
invisible="state in ('cancelled', 'fitted')"
confirm="Cancel this part order? This cannot be undone."/>
<field name="state" widget="statusbar"
statusbar_visible="draft,ordered,received,fitted"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="repair_order_id" options="{'no_create': True}"/>
<field name="partner_id" readonly="1"/>
<field name="description"/>
<field name="oem_part_number"/>
<field name="manufacturer"/>
<field name="quantity"/>
</group>
<group>
<field name="ordered_date" readonly="state != 'draft'"/>
<field name="expected_date"/>
<field name="received_date" readonly="state != 'ordered'"/>
<field name="ordered_by_id" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Tech Notes" name="notes">
<field name="notes"/>
</page>
<page string="Photos" name="photos">
<field name="photo_ids" widget="many2many_binary"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_repair_part_order" model="ir.actions.act_window">
<field name="name">Parts to Order</field>
<field name="res_model">fusion.repair.part.order</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_product_category_list" model="ir.ui.view">
<field name="name">fusion.repair.product.category.list</field>
<field name="model">fusion.repair.product.category</field>
<field name="arch" type="xml">
<list string="Repair Categories">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="safety_critical"/>
<field name="intake_template_id"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_repair_product_category_form" model="ir.ui.view">
<field name="name">fusion.repair.product.category.form</field>
<field name="model">fusion.repair.product.category</field>
<field name="arch" type="xml">
<form string="Repair Category">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="e.g. Stairlift"/>
</h1>
</div>
<group>
<group>
<field name="code" placeholder="e.g. stairlift"/>
<field name="sequence"/>
<field name="icon"/>
</group>
<group>
<field name="safety_critical"/>
<field name="intake_template_id"/>
<field name="active"/>
</group>
</group>
<field name="description" placeholder="Describe what equipment falls into this category..."/>
</sheet>
</form>
</field>
</record>
<record id="action_repair_product_category" model="ir.actions.act_window">
<field name="name">Equipment Categories</field>
<field name="res_model">fusion.repair.product.category</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Service plan toggle on the product template -->
<record id="view_product_template_form_service_plan" model="ir.ui.view">
<field name="name">product.template.form.service.plan.fusion_repairs</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Service Plan" name="fusion_repairs_plan"
groups="fusion_repairs.group_fusion_repairs_manager">
<group>
<field name="x_fc_is_service_plan"/>
<field name="x_fc_plan_visits_included"
invisible="not x_fc_is_service_plan"/>
<field name="x_fc_plan_duration_months"
invisible="not x_fc_is_service_plan"/>
<field name="x_fc_plan_category_id"
invisible="not x_fc_is_service_plan"
options="{'no_create': True}"/>
</group>
</page>
</xpath>
</field>
</record>
<!-- Subscription list -->
<record id="view_service_plan_subscription_list" model="ir.ui.view">
<field name="name">fusion.repair.service.plan.subscription.list</field>
<field name="model">fusion.repair.service.plan.subscription</field>
<field name="arch" type="xml">
<list string="Service Plan Subscriptions"
decoration-success="state == 'active'"
decoration-warning="state == 'exhausted'"
decoration-muted="state == 'expired' or state == 'cancelled'">
<field name="name"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="category_id" optional="show"/>
<field name="visits_used"/>
<field name="visits_included"/>
<field name="visits_remaining"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- Subscription form -->
<record id="view_service_plan_subscription_form" model="ir.ui.view">
<field name="name">fusion.repair.service.plan.subscription.form</field>
<field name="model">fusion.repair.service.plan.subscription</field>
<field name="arch" type="xml">
<form string="Service Plan Subscription">
<header>
<button name="action_cancel" type="object" string="Cancel Plan"
invisible="state == 'cancelled'"
confirm="Cancel this service plan? Remaining visits will be forfeited."/>
<field name="state" widget="statusbar"
statusbar_visible="active,exhausted,expired,cancelled"/>
</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="category_id" readonly="1"/>
<field name="sale_order_id" readonly="1"/>
</group>
<group>
<field name="start_date"/>
<field name="end_date"/>
<field name="visits_included"/>
<field name="visits_used" readonly="1"/>
<field name="visits_remaining" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Burn History" name="burn_history">
<field name="burn_history_ids" readonly="1">
<list>
<field name="burned_on"/>
<field name="repair_order_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_service_plan_subscription" model="ir.actions.act_window">
<field name="name">Service Plans</field>
<field name="res_model">fusion.repair.service.plan.subscription</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_warranty_coverage_list" model="ir.ui.view">
<field name="name">fusion.repair.warranty.coverage.list</field>
<field name="model">fusion.repair.warranty.coverage</field>
<field name="arch" type="xml">
<list string="Repair Warranty Coverage">
<field name="name"/>
<field name="repair_id"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="start_date"/>
<field name="coverage_days"/>
<field name="expiry_date"/>
<field name="is_active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_repair_warranty_coverage_form" model="ir.ui.view">
<field name="name">fusion.repair.warranty.coverage.form</field>
<field name="model">fusion.repair.warranty.coverage</field>
<field name="arch" type="xml">
<form string="Warranty Coverage">
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="repair_id" options="{'no_create': True}"/>
<field name="partner_id" readonly="1"/>
<field name="product_id" readonly="1"/>
<field name="lot_id" readonly="1"/>
</group>
<group>
<field name="start_date"/>
<field name="coverage_days"/>
<field name="expiry_date" readonly="1"/>
<field name="is_active" readonly="1" widget="boolean_toggle"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<field name="notes"/>
</sheet>
</form>
</field>
</record>
<record id="action_repair_warranty_coverage" model="ir.actions.act_window">
<field name="name">Repair Warranties</field>
<field name="res_model">fusion.repair.warranty.coverage</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.fusion_repairs</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app string="Fusion Repairs"
name="fusion_repairs"
groups="fusion_repairs.group_fusion_repairs_manager">
<block title="Notifications" name="fc_repairs_notifications">
<setting id="fc_repairs_enable_email_notifications"
string="Enable Email Notifications"
help="Master toggle for all automated repair-related emails (intake confirmations, dispatch alerts, office digests).">
<field name="fc_repairs_enable_email_notifications"/>
</setting>
</block>
<block title="Intake Behaviour" name="fc_repairs_intake">
<setting id="fc_repairs_duplicate_call_window_days"
string="Duplicate Call Window (days)"
help="When an intake matches an open repair from this many days back on the same phone, the wizard offers 'add note instead'.">
<field name="fc_repairs_duplicate_call_window_days"/>
</setting>
<setting id="fc_repairs_outstanding_balance_threshold"
string="Outstanding Balance Warning ($)"
help="Show a warning if the client's open invoice total exceeds this amount.">
<field name="fc_repairs_outstanding_balance_threshold"/>
</setting>
</block>
<block title="Pricing Variance" name="fc_repairs_pricing">
<setting id="fc_repairs_variance_threshold_pct"
string="Variance Threshold (%)"
help="If the actual repair cost exceeds the estimate by more than this percentage, invoicing is blocked until manager review.">
<field name="fc_repairs_variance_threshold_pct"/>
</setting>
<setting id="fc_repairs_variance_threshold_amount"
string="Variance Threshold ($)"
help="Absolute variance amount that also triggers re-quote (whichever hits first).">
<field name="fc_repairs_variance_threshold_amount"/>
</setting>
</block>
<block title="Public Client Portal" name="fc_repairs_client_portal">
<setting id="fc_repairs_client_portal_url"
string="Portal URL Path"
help="URL path mentioned in voicemail greetings and printed on QR stickers. Phase 1 ships the form at this path.">
<field name="fc_repairs_client_portal_url"/>
</setting>
<setting id="fc_repairs_client_portal_rate_limit_per_hour"
string="Rate Limit (per hour per IP)"
help="Anti-spam limit for the public form.">
<field name="fc_repairs_client_portal_rate_limit_per_hour"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_res_partner_form_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">res.partner.form.inherit.fusion_repairs</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Service Preferences" name="fusion_repairs_prefs"
groups="fusion_repairs.group_fusion_repairs_user">
<group>
<group>
<field name="x_fc_preferred_tech_id" options="{'no_create': True}"/>
<field name="x_fc_preferred_window"/>
</group>
<group>
<field name="x_fc_repair_count" readonly="1"/>
</group>
</group>
<separator string="Access Notes for Technicians"/>
<field name="x_fc_access_notes"
placeholder="e.g. Dog in front yard, use side door, gate code 1234"/>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_res_users_form_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">res.users.form.inherit.fusion_repairs</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Repairs" name="fusion_repairs_user"
groups="fusion_repairs.group_fusion_repairs_manager">
<group>
<group string="Skills &amp; Costing">
<field name="x_fc_repair_skills" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="x_fc_tech_cost_rate" widget="monetary"/>
<field name="company_currency_id" invisible="1"/>
</group>
<group string="On-Call Rotation">
<field name="x_fc_on_call"/>
<field name="x_fc_on_call_priority"
invisible="not x_fc_on_call"/>
<field name="x_fc_on_call_phone"
invisible="not x_fc_on_call"
placeholder="Leave blank to use partner phone"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_sale_order_form_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">sale.order.form.inherit.fusion_repairs</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_repair_orders"
type="object"
class="oe_stat_button"
icon="fa-wrench"
invisible="x_fc_repair_order_count == 0"
groups="fusion_repairs.group_fusion_repairs_user">
<field name="x_fc_repair_order_count" widget="statinfo" string="Repairs"/>
</button>
<button name="action_view_maintenance_contracts"
type="object"
class="oe_stat_button"
icon="fa-calendar-check-o"
invisible="x_fc_maintenance_contract_count == 0"
groups="fusion_repairs.group_fusion_repairs_user">
<field name="x_fc_maintenance_contract_count"
widget="statinfo" string="Maintenance"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_service_catalog_list" model="ir.ui.view">
<field name="name">fusion.repair.service.catalog.list</field>
<field name="model">fusion.repair.service.catalog</field>
<field name="arch" type="xml">
<list string="Service Catalogue">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="product_category_id"/>
<field name="service_product_id"/>
<field name="estimated_hours"/>
<field name="estimated_cost" widget="monetary"/>
<field name="auto_schedule"/>
<field name="active" widget="boolean_toggle"/>
<field name="company_currency_id" column_invisible="True"/>
</list>
</field>
</record>
<record id="view_repair_service_catalog_form" model="ir.ui.view">
<field name="name">fusion.repair.service.catalog.form</field>
<field name="model">fusion.repair.service.catalog</field>
<field name="arch" type="xml">
<form string="Service Catalogue Entry">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="e.g. Stairlift Motor Replacement"/>
</h1>
</div>
<group>
<group>
<field name="code"/>
<field name="product_category_id"
options="{'no_create': True}"/>
<field name="task_type"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="estimated_hours" widget="float_time"/>
<field name="estimated_cost" widget="monetary"/>
<field name="auto_schedule"/>
<field name="active"/>
<field name="company_currency_id" invisible="1"/>
</group>
</group>
<separator string="Auto-Match"/>
<field name="symptom_keywords"
placeholder="Comma-separated keywords (e.g. motor, stuck, won't move)"/>
<separator string="Billing"/>
<group>
<field name="service_product_id"
options="{'no_create': True}"/>
<field name="pricelist_id"
options="{'no_create': True}"/>
</group>
<field name="default_parts_product_ids" widget="many2many_tags"
options="{'no_create': True}"/>
</sheet>
</form>
</field>
</record>
<record id="action_repair_service_catalog" model="ir.actions.act_window">
<field name="name">Service Catalogue</field>
<field name="res_model">fusion.repair.service.catalog</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Maps + View Repair buttons to the technician task form
(header so they're prominent on mobile). -->
<record id="view_technician_task_form_inherit_fusion_repairs"
model="ir.ui.view">
<field name="name">fusion.technician.task.form.inherit.fusion_repairs</field>
<field name="model">fusion.technician.task</field>
<field name="inherit_id" ref="fusion_tasks.view_technician_task_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_open_in_maps"
type="object"
string="Open in Maps"
class="btn-secondary"
icon="fa-map-marker"
invisible="not address_display and not partner_id"/>
<button name="action_view_repair_order"
type="object"
string="View Repair"
class="btn-secondary"
icon="fa-wrench"
invisible="not x_fc_repair_order_id"/>
<button name="action_timer_start"
type="object"
string="Start Timer"
class="btn-success"
icon="fa-play-circle"
invisible="x_fc_timer_running_since"/>
<button name="action_timer_stop"
type="object"
string="Stop Timer"
class="btn-warning"
icon="fa-stop-circle"
invisible="not x_fc_timer_running_since"/>
</xpath>
<xpath expr="//field[@name='partner_id']" position="after">
<field name="x_fc_repair_order_id" readonly="1"
invisible="not x_fc_repair_order_id"/>
<field name="x_fc_timer_running_since" readonly="1"
invisible="not x_fc_timer_running_since"/>
<field name="x_fc_timer_accumulated_minutes" readonly="1"
widget="float" digits="[12,1]"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import repair_intake_wizard
from . import repair_visit_report_wizard
from . import qr_sticker_wizard

View File

@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""QR sticker generator (CL17).
Generates a printable PDF with one sticker per selected serial number.
Each sticker has a QR code linking to /repair?sn=<serial>. Stick on the
equipment; client scans -> public client portal opens with the unit
pre-filled.
Accessible from stock.lot via a server action AND as a standalone wizard
under Fusion Repairs > Configuration.
"""
import base64
import io
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionRepairQRStickerWizard(models.TransientModel):
_name = 'fusion.repair.qr.sticker.wizard'
_description = 'QR Sticker Generator Wizard'
lot_ids = fields.Many2many(
'stock.lot',
string='Serials',
help='One sticker will be generated per serial.',
)
product_id = fields.Many2one(
'product.product',
string='Filter by Product',
help='Optional - limits the serial picker to lots of this product.',
)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
def action_generate(self):
self.ensure_one()
if not self.lot_ids:
raise UserError(_('Select at least one serial number to print stickers for.'))
return self.env.ref('fusion_repairs.action_report_qr_stickers') \
.report_action(self)
# ------------------------------------------------------------------
# Helpers used by the QWeb report
# ------------------------------------------------------------------
def _portal_base_url(self):
ICP = self.env['ir.config_parameter'].sudo()
base = (ICP.get_param('web.base.url', '') or '').rstrip('/')
path = ICP.get_param('fusion_repairs.client_portal_url', '/repair') or '/repair'
return base + path
def get_sticker_url(self, lot):
"""Return the full URL that the QR code on this sticker encodes."""
url = self._portal_base_url()
serial = (lot.name or '').strip()
return f"{url}?sn={serial}" if serial else url
def get_qr_data_uri(self, url, size=180):
"""Return a base64 PNG data URI for the QR code of the given URL.
Uses the `qrcode` library if available (it's a transitive dep of many
Odoo modules); falls back to a simple ASCII placeholder if not so the
report still renders (with a warning).
"""
try:
import qrcode
img = qrcode.make(url)
buf = io.BytesIO()
img.save(buf, format='PNG')
b64 = base64.b64encode(buf.getvalue()).decode('ascii')
return f"data:image/png;base64,{b64}"
except Exception as e:
_logger.warning('QR sticker generation failed for %s: %s', url, e)
return ""

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_qr_sticker_wizard_form" model="ir.ui.view">
<field name="name">fusion.repair.qr.sticker.wizard.form</field>
<field name="model">fusion.repair.qr.sticker.wizard</field>
<field name="arch" type="xml">
<form string="Generate QR Stickers">
<sheet>
<div class="alert alert-info" role="alert">
<i class="fa fa-qrcode me-2"/>
Each selected serial number prints as one sticker. Affix to
the equipment so the client can scan and submit a service request
without typing the serial.
</div>
<group>
<field name="product_id" options="{'no_create': True}"/>
<field name="lot_ids" widget="many2many_tags"
domain="[('product_id', '=?', product_id)]"
options="{'no_create': True}"/>
</group>
</sheet>
<footer>
<button name="action_generate" type="object"
string="Print Stickers" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_qr_sticker_wizard" model="ir.actions.act_window">
<field name="name">Generate QR Stickers</field>
<field name="res_model">fusion.repair.qr.sticker.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,419 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Backend intake wizard.
A simple Phase 1 transient model that captures one-or-many equipment items
per call, then delegates to fusion.repair.intake.service to create the
repair.order(s). The shared service guarantees identical behaviour to the
sales rep portal and the public client portal added in later phases.
Multi-equipment per call is supported via the equipment_ids One2many.
Includes Phase 1 polish:
- C1: duplicate-call detection (yellow banner if the partner has an open
repair from the last N days)
- C5: outstanding-balance warning (red banner if open invoice total > config)
- C6: quote-only mode (creates the repair but does NOT dispatch a tech)
"""
import logging
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class RepairIntakeWizard(models.TransientModel):
_name = 'fusion.repair.intake.wizard'
_description = 'Repair Intake Wizard'
# ------------------------------------------------------------------
# CALLER / CLIENT
# ------------------------------------------------------------------
intake_user_id = fields.Many2one(
'res.users',
string='Taken By',
default=lambda self: self.env.user,
required=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
required=True,
help='Existing client. Use the create-and-edit dialog to add a new contact.',
)
partner_phone = fields.Char(
related='partner_id.phone',
string='Phone',
readonly=True,
)
# ------------------------------------------------------------------
# CONTEXTUAL BANNERS (C1 + C5)
# Computed reactively when the partner is selected. Shown in the form
# so CS knows immediately about duplicate calls or unpaid invoices.
# ------------------------------------------------------------------
duplicate_repair_ids = fields.Many2many(
'repair.order',
compute='_compute_partner_context',
string='Open Repairs (last N days)',
)
duplicate_count = fields.Integer(
compute='_compute_partner_context',
string='Duplicate Call Count',
)
duplicate_window_days = fields.Integer(
compute='_compute_partner_context',
string='Duplicate Window (days)',
)
currency_id = fields.Many2one(
'res.currency',
compute='_compute_partner_context',
string='Currency',
)
outstanding_balance = fields.Monetary(
compute='_compute_partner_context',
currency_field='currency_id',
string='Open Invoice Balance',
)
outstanding_invoice_count = fields.Integer(
compute='_compute_partner_context',
string='Open Invoices',
)
show_outstanding_warning = fields.Boolean(
compute='_compute_partner_context',
string='Show Outstanding Balance Warning',
)
# ------------------------------------------------------------------
# OPTIONS (C6 quote-only mode)
# ------------------------------------------------------------------
quote_only = fields.Boolean(
string='Quote Only - Do Not Dispatch',
help='Create the service request and quote the client, but do NOT '
'auto-create a technician dispatch task. Use this when the client '
'is gathering quotes or has not yet authorised the repair.',
)
# ------------------------------------------------------------------
# Bundle 8: rush / emergency options + live surcharge preview
# ------------------------------------------------------------------
rush_requested = fields.Boolean(
string='Rush / Emergency Service',
help='Tick when the client needs faster-than-normal turnaround. '
'Surcharge is calculated automatically from the rate card.',
)
rush_tier = fields.Selection(
[
('same_day', 'Same Day (during business hours)'),
('next_day', 'Next Day Priority'),
('after_hours', 'After Hours (5pm-9pm weekday)'),
('weekend', 'Weekend'),
('holiday', 'Statutory Holiday'),
],
string='Rush Tier',
)
rush_techs_required = fields.Integer(
string='Technicians Required',
default=1,
)
rush_surcharge_preview = fields.Monetary(
string='Quoted Surcharge',
compute='_compute_rush_surcharge_preview',
currency_field='currency_id',
)
rush_acknowledged = fields.Boolean(
string='Client Agreed to Price',
help='Tick this AFTER you have read the surcharge to the client over the '
'phone and they have said yes. The repair will record the '
'acknowledgement timestamp + your user id for audit.',
)
@api.depends('rush_tier', 'rush_techs_required', 'equipment_ids.repair_category_id')
def _compute_rush_surcharge_preview(self):
Rates = self.env['fusion.repair.emergency.charge'].sudo()
for w in self:
if not w.rush_tier or not w.equipment_ids:
w.rush_surcharge_preview = 0.0
continue
# Use the FIRST equipment's category for the preview - per-equipment
# surcharges land on each repair.order after create.
cat = w.equipment_ids[:1].repair_category_id
w.rush_surcharge_preview = Rates.calculate(
cat, w.rush_tier, w.rush_techs_required or 1,
)
# ------------------------------------------------------------------
# EQUIPMENT (one-or-many)
# ------------------------------------------------------------------
equipment_ids = fields.One2many(
'fusion.repair.intake.wizard.equipment',
'wizard_id',
string='Equipment Items',
required=True,
)
# ------------------------------------------------------------------
# COMPUTES
# ------------------------------------------------------------------
@api.depends('partner_id')
def _compute_partner_context(self):
ICP = self.env['ir.config_parameter'].sudo()
try:
window_days = int(ICP.get_param(
'fusion_repairs.duplicate_call_window_days', '14'
))
except (ValueError, TypeError):
window_days = 14
try:
threshold = float(ICP.get_param(
'fusion_repairs.outstanding_balance_threshold', '100'
))
except (ValueError, TypeError):
threshold = 100.0
# Avoid sudo - CS users already have access to their own company's
# repairs/invoices via the standard groups + the Repairs Office rule.
Repair = self.env['repair.order']
Move = self.env['account.move']
company_ids = self.env.companies.ids
default_currency = self.env.company.currency_id
cutoff = fields.Datetime.now() - timedelta(days=window_days)
for w in self:
w.duplicate_window_days = window_days
if not w.partner_id:
w.duplicate_repair_ids = False
w.duplicate_count = 0
w.outstanding_balance = 0.0
w.outstanding_invoice_count = 0
w.show_outstanding_warning = False
w.currency_id = default_currency
continue
# Multi-company scoped duplicate detection. search_count for the
# real total + search(limit=5) for the display list - so the banner
# never lies about a partner with >5 open calls.
dup_domain = [
('partner_id', '=', w.partner_id.id),
('state', 'not in', ('done', 'cancel')),
('create_date', '>=', cutoff),
('company_id', 'in', company_ids),
]
w.duplicate_repair_ids = Repair.search(
dup_domain, order='create_date desc', limit=5,
)
w.duplicate_count = Repair.search_count(dup_domain)
# commercial_partner_id is the canonical "billed-to root" - covers
# child contacts AND walks up from a child if the caller IS a child.
commercial = w.partner_id.commercial_partner_id or w.partner_id
inv_domain = [
('commercial_partner_id', '=', commercial.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('company_id', 'in', company_ids),
]
# _read_group pushes the SUM to Postgres - O(1) load vs O(N) records.
rows = Move._read_group(
inv_domain, aggregates=['amount_residual:sum', '__count'],
)
balance, invoice_count = rows[0] if rows else (0.0, 0)
w.currency_id = default_currency
w.outstanding_balance = balance or 0.0
w.outstanding_invoice_count = invoice_count or 0
w.show_outstanding_warning = (balance or 0.0) >= threshold
# ------------------------------------------------------------------
# SUBMIT
# ------------------------------------------------------------------
def action_submit(self):
self.ensure_one()
if not self.equipment_ids:
raise UserError(_('Please add at least one piece of equipment.'))
payload = {
'partner_id': self.partner_id.id,
'intake_user_id': self.intake_user_id.id,
'quote_only': self.quote_only,
'rush_requested': self.rush_requested,
'rush_tier': self.rush_tier if self.rush_requested else False,
'rush_techs_required': self.rush_techs_required if self.rush_requested else 1,
'rush_acknowledged': self.rush_acknowledged,
'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
}
# sudo() so sub-operations (mail.activity, mail.mail, fusion.technician.task)
# never trip on permission checks - x_fc_intake_user_id preserves audit identity.
repairs = self.env['fusion.repair.intake.service'].sudo().create_repair_orders(
payload, source='backend_wizard',
)
# If CS ticked "rush" and "client agreed", stamp the ack on every spawned repair.
if self.rush_requested and self.rush_acknowledged:
for r in repairs:
r.x_fc_rush_acknowledged_at = fields.Datetime.now()
r.x_fc_rush_acknowledged_by_id = self.intake_user_id.id or self.env.uid
if len(repairs) == 1:
return {
'type': 'ir.actions.act_window',
'name': repairs.name,
'res_model': 'repair.order',
'view_mode': 'form',
'res_id': repairs.id,
}
return {
'type': 'ir.actions.act_window',
'name': _('Service Calls Created (%(count)s)', count=len(repairs)),
'res_model': 'repair.order',
'view_mode': 'list,form',
'domain': [('id', 'in', repairs.ids)],
}
def action_open_existing_repair(self):
"""C1: jump to the most recent duplicate repair so CS can add a note
instead of creating a new repair."""
self.ensure_one()
if not self.duplicate_repair_ids:
return False
repair = self.duplicate_repair_ids[0]
return {
'type': 'ir.actions.act_window',
'name': repair.name,
'res_model': 'repair.order',
'view_mode': 'form',
'res_id': repair.id,
'target': 'current',
}
def action_view_outstanding_invoices(self):
"""C5: open the list of unpaid invoices for context."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Open Invoices - %s', self.partner_id.name or ''),
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [
('partner_id', 'child_of', self.partner_id.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
],
'target': 'current',
}
def _equipment_payload(self, eq):
"""Render an equipment record as a dict the intake service expects."""
return {
'product_id': eq.product_id.id or False,
'lot_id': eq.lot_id.id or False,
'repair_category_id': eq.repair_category_id.id or False,
'intake_template_id': eq.intake_template_id.id or False,
'third_party': eq.third_party,
'urgency': eq.urgency,
'issue_summary': eq.issue_summary or '',
'issue_category': eq.issue_category or '',
'internal_notes': eq.internal_notes or '',
'schedule_date': eq.scheduled_date or False,
'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [],
'answers': [], # Phase 1 wizard doesn't expose per-question answer rows yet
}
class RepairIntakeWizardEquipment(models.TransientModel):
"""A single piece of equipment captured in the wizard.
Multiple lines = multi-equipment intake (one repair.order per line).
"""
_name = 'fusion.repair.intake.wizard.equipment'
_description = 'Repair Intake Wizard - Equipment Line'
_order = 'sequence, id'
wizard_id = fields.Many2one(
'fusion.repair.intake.wizard',
string='Wizard',
required=True,
ondelete='cascade',
)
sequence = fields.Integer(default=10)
# Equipment identification
repair_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Category',
required=True,
)
product_id = fields.Many2one(
'product.product',
string='Product',
help='Specific product if known. Leave blank for generic equipment.',
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
domain="[('product_id', '=', product_id)]",
help='Lot or serial number if known.',
)
third_party = fields.Boolean(
string='Not Purchased From Us',
help='Tick if this equipment was bought elsewhere - we still service it but '
'warranty is not honoured and a service call-out fee applies.',
)
# Intake context
intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Question Template',
help='Defaults to the template configured on the category if left blank.',
)
# Triage
urgency = fields.Selection(
[('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue')],
string='Urgency',
default='normal',
required=True,
)
scheduled_date = fields.Datetime(
string='Preferred Date',
default=fields.Datetime.now,
)
issue_summary = fields.Char(
string='Issue Summary',
help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").',
)
issue_category = fields.Char(
string='Symptom Category',
help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").',
)
internal_notes = fields.Text(string='Internal Notes')
photo_ids = fields.Many2many(
'ir.attachment',
'fusion_repair_intake_wizard_eq_photo_rel',
'eq_id',
'attachment_id',
string='Photos / Videos',
)
@api.onchange('repair_category_id')
def _onchange_repair_category_id(self):
"""Pre-fill the intake template from the category default."""
if self.repair_category_id and not self.intake_template_id:
self.intake_template_id = self.repair_category_id.intake_template_id
@api.onchange('product_id')
def _onchange_product_id(self):
"""Pre-fill the category from the product if defined."""
if self.product_id and not self.repair_category_id:
cat = self.product_id.product_tmpl_id.x_fc_repair_category_id
if cat:
self.repair_category_id = cat

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_intake_wizard_form" model="ir.ui.view">
<field name="name">fusion.repair.intake.wizard.form</field>
<field name="model">fusion.repair.intake.wizard</field>
<field name="arch" type="xml">
<form string="New Service Call">
<sheet>
<group>
<group string="Caller / Client">
<field name="intake_user_id" options="{'no_create': True}"/>
<field name="partner_id"
options="{'no_create_edit': False, 'no_quick_create': False}"/>
<field name="partner_phone" readonly="1" invisible="not partner_id"/>
</group>
</group>
<!-- C1: duplicate-call detection banner -->
<div class="alert alert-warning d-flex justify-content-between align-items-center"
role="alert"
invisible="duplicate_count == 0">
<div>
<i class="fa fa-exclamation-triangle me-1"/>
<strong>Open repair already exists for this client</strong>
(<field name="duplicate_count" nolabel="1" readonly="1" class="d-inline"/>
in last <field name="duplicate_window_days" nolabel="1" readonly="1" class="d-inline"/> days).
Consider adding a note to the existing repair instead.
</div>
<button name="action_open_existing_repair"
type="object"
string="Open Existing Repair"
class="btn btn-sm btn-warning"/>
</div>
<field name="duplicate_repair_ids" invisible="1"/>
<field name="currency_id" invisible="1"/>
<!-- C5: outstanding-balance warning banner -->
<div class="alert alert-danger d-flex justify-content-between align-items-center"
role="alert"
invisible="not show_outstanding_warning">
<div>
<i class="fa fa-money me-1"/>
<strong>Outstanding balance:</strong>
<field name="outstanding_balance" widget="monetary" nolabel="1" readonly="1" class="d-inline"/>
across <field name="outstanding_invoice_count" nolabel="1" readonly="1" class="d-inline"/> invoice(s).
Worth mentioning during this call.
</div>
<button name="action_view_outstanding_invoices"
type="object"
string="View Invoices"
class="btn btn-sm btn-danger"/>
</div>
<separator string="Equipment Items (one repair per item)"/>
<field name="equipment_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="repair_category_id" options="{'no_create': True}"/>
<field name="product_id" optional="show"/>
<field name="lot_id" optional="hide"/>
<field name="third_party" optional="show"/>
<field name="urgency" widget="badge"
decoration-success="urgency == 'normal'"
decoration-warning="urgency == 'urgent'"
decoration-danger="urgency == 'safety'"/>
<field name="issue_summary"/>
<field name="scheduled_date" optional="hide"/>
</list>
<form>
<group>
<group>
<field name="repair_category_id" options="{'no_create': True}"/>
<field name="product_id"/>
<field name="lot_id"/>
<field name="third_party"/>
</group>
<group>
<field name="urgency"/>
<field name="scheduled_date"/>
<field name="intake_template_id" options="{'no_create': True}"/>
</group>
</group>
<field name="issue_summary"
placeholder="One-line summary (e.g. 'stairlift stops halfway up')"/>
<field name="issue_category"
placeholder="Symptom tag (e.g. battery, motor, remote)"/>
<field name="internal_notes" placeholder="Internal notes"/>
<separator string="Photos / Videos"/>
<field name="photo_ids" widget="many2many_binary"/>
</form>
</field>
<!-- C6: quote-only mode -->
<separator string="Options"/>
<group>
<field name="quote_only"/>
</group>
<!-- Bundle 8: rush / emergency surcharge -->
<separator string="Rush / Emergency Service"/>
<group>
<field name="rush_requested"/>
<field name="rush_tier"
required="rush_requested"
invisible="not rush_requested"/>
<field name="rush_techs_required"
invisible="not rush_requested"/>
<field name="rush_surcharge_preview"
widget="monetary"
readonly="1"
invisible="not rush_requested"/>
<field name="currency_id" invisible="1"/>
<field name="rush_acknowledged"
invisible="not rush_requested"/>
</group>
<div class="alert alert-warning"
role="alert"
invisible="not rush_requested or rush_acknowledged">
<i class="fa fa-exclamation-triangle me-1"/>
<strong>Read the surcharge to the client and get verbal OK.</strong>
Then tick the "Client Agreed to Price" box above before submitting.
</div>
</sheet>
<footer>
<button string="Submit"
name="action_submit"
type="object"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,654 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Technician visit report wizard.
Opened from a completed (or in-progress) repair.order. Captures:
- labour hours
- parts/consumables used
- recommended upsell products
- optional client signature
On confirm:
- writes labour + parts as repair.order lines (Odoo native operations)
- updates x_fc_actual_cost on the repair
- triggers variance reconciliation (sets x_fc_requires_requote if over threshold)
- if not requote: confirms the repair (state='under_repair' -> 'done' via Odoo native flow)
- offers an action_collect_payment shortcut to fire Poynt on the resulting invoice
"""
import logging
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class RepairVisitReportWizard(models.TransientModel):
_name = 'fusion.repair.visit.report.wizard'
_description = 'Repair Visit Report Wizard'
repair_id = fields.Many2one(
'repair.order',
string='Repair Order',
required=True,
readonly=True,
)
technician_id = fields.Many2one(
'res.users',
string='Technician',
default=lambda self: self.env.user,
domain="[('x_fc_is_field_staff', '=', True)]",
)
# Labour
labour_hours = fields.Float(
string='Labour Hours',
required=True,
default=1.0,
)
# Parts used (simple line model below)
parts_line_ids = fields.One2many(
'fusion.repair.visit.report.wizard.line',
'wizard_id',
string='Parts Used',
)
# Outcome
notes = fields.Html(string='Technician Notes')
found_another_issue = fields.Boolean(
string='Found Another Issue',
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,
)
# ----- T4 client signature -----
client_signature = fields.Binary(
string='Client Signature',
attachment=True,
help='Captured via signature widget on tech mobile - proves the '
'client accepted the work.',
)
client_signature_name = fields.Char(
string='Signed By',
help='Type the client name as they signed (for the audit log).',
)
# ----- T7 no-show photo proof -----
no_show = fields.Boolean(
string='Client No-Show',
help='Tick if the client was not present. Forces a no-show photo.',
)
no_show_photo = fields.Binary(
string='No-Show Photo',
attachment=True,
help='Photo of the door / driveway proving the technician attended.',
)
# ----- T6 parts replaced - serial capture -----
parts_serial_capture = fields.Text(
string='Replaced Parts - Serials',
help='One serial per line. Used for OEM warranty claims.',
)
# ----- Bundle 8: Cannot Fix Today - Needs Parts -----
outcome = fields.Selection(
[
('completed', 'Repair Complete - Close It'),
('parts_needed', "Can't Fix Today - Need to Order Parts"),
('rescheduled', 'Could Not Reach / Rescheduled'),
],
string='Visit Outcome',
default='completed',
required=True,
help='Drives what happens after you submit: completed -> closes the '
"repair; parts_needed -> captures the part info, emails the client, "
"schedules follow-up; rescheduled -> repair stays open.",
)
needs_parts_line_ids = fields.One2many(
'fusion.repair.visit.report.wizard.partline',
'wizard_id',
string='Parts To Order',
help='ONE line per distinct part. Description + OEM number + photos go to '
'procurement so they can place the manufacturer order from your input '
'alone.',
)
# ----- Bundle 9: callout pricing + warranty -----
callout_distance_km = fields.Float(
related='repair_id.x_fc_callout_distance_km',
string='One-Way Distance (km)',
readonly=False,
help='Distance from shop to client. Beyond the rate-card threshold, '
'EVERY km is billed BOTH WAYS, per tech.',
)
callout_techs = fields.Integer(
related='repair_id.x_fc_callout_techs',
string='Technicians on Callout',
readonly=False,
)
callout_tier = fields.Selection(
related='repair_id.x_fc_callout_tier',
string='Callout Tier',
readonly=False,
)
callout_in_shop = fields.Boolean(
related='repair_id.x_fc_in_shop',
string='In-Shop Repair',
readonly=False,
)
callout_labor_hours_used = fields.Float(
string='Repair Hours (after 30 min inspection)',
default=1.0,
help='Total hours of REPAIR WORK after the 30 minutes the callout fee covers. '
'Minimum 1 hour is billed even if the actual fix took less.',
)
quote_total_preview = fields.Monetary(
related='repair_id.x_fc_quote_total',
currency_field='company_currency_id',
readonly=True,
)
quote_breakdown_preview = fields.Text(
related='repair_id.x_fc_quote_breakdown_text',
readonly=True,
)
labor_warranty_status_preview = fields.Selection(
related='repair_id.x_fc_labor_warranty_status',
readonly=True,
)
labor_warranty_id_preview = fields.Many2one(
related='repair_id.x_fc_labor_warranty_id',
readonly=True,
)
# Void path: tech finds misuse / negligence -> warranty is void
warranty_void_reason = fields.Selection(
[
('user_negligence', 'User Negligence'),
('gross_negligence', 'Gross Negligence'),
('misuse', 'Misuse'),
('over_recommended_use', 'Over-Recommended Use'),
('accidental_damage', 'Accidental Damage'),
],
string='Void Warranty Reason',
help='If you find evidence the unit was misused, pick the reason. The '
'matching labor warranty record (if any) is voided permanently '
'and the client is billed full labor.',
)
warranty_void_notes = fields.Text(string='Void Notes')
# Variance display
estimated_cost = fields.Monetary(
related='repair_id.x_fc_estimated_cost',
currency_field='company_currency_id',
readonly=True,
)
actual_cost = fields.Monetary(
string='Actual Cost',
compute='_compute_actual_cost',
currency_field='company_currency_id',
)
variance_pct = fields.Float(
string='Variance %',
compute='_compute_actual_cost',
)
requires_requote = fields.Boolean(
compute='_compute_actual_cost',
)
company_currency_id = fields.Many2one(
'res.currency',
related='repair_id.company_currency_id',
readonly=True,
)
@api.depends('labour_hours', 'parts_line_ids.subtotal', 'repair_id.x_fc_estimated_cost')
def _compute_actual_cost(self):
ICP = self.env['ir.config_parameter'].sudo()
try:
threshold_pct = float(ICP.get_param('fusion_repairs.variance_threshold_pct', '20'))
except (ValueError, TypeError):
threshold_pct = 20.0
try:
threshold_amt = float(ICP.get_param('fusion_repairs.variance_threshold_amount', '100'))
except (ValueError, TypeError):
threshold_amt = 100.0
for w in self:
catalog = w.repair_id.x_fc_service_catalog_id
labour_rate = 0.0
if catalog and catalog.service_product_id:
labour_rate = catalog.service_product_id.list_price
parts_total = sum(w.parts_line_ids.mapped('subtotal'))
w.actual_cost = (w.labour_hours * labour_rate) + parts_total
est = w.estimated_cost or 0.0
variance_pct = ((w.actual_cost - est) / est * 100) if est else 0.0
w.variance_pct = variance_pct
# One-sided: only OVER-cost triggers re-quote. Coming in under
# estimate is good news and must not block invoicing.
over_pct = variance_pct
over_amt = w.actual_cost - est
w.requires_requote = est > 0 and (
over_pct >= threshold_pct or over_amt >= threshold_amt
)
# ------------------------------------------------------------------
# ACTION
# ------------------------------------------------------------------
def action_confirm(self):
self.ensure_one()
repair = self.repair_id
if not repair:
raise UserError(_('No repair selected.'))
# Create native repair operations (stock moves) for the parts used.
# 'add' type moves consume parts from the parts source location and
# flow through to the invoice when action_create_sale_order() is run.
self._create_repair_part_moves(repair)
# Persist actual cost + requote flag on the repair.
repair.write({
'x_fc_actual_cost': self.actual_cost,
'x_fc_requires_requote': self.requires_requote,
# Bundle 9 - persist hours the tech actually worked + resolve warranty
'x_fc_callout_labor_hours': self.callout_labor_hours_used,
})
# Bundle 9: resolve labor warranty + apply void reason if the tech
# found misuse during the visit.
repair.action_check_labor_warranty()
if self.warranty_void_reason and repair.x_fc_labor_warranty_id:
repair.x_fc_labor_warranty_id.action_void(
reason=self.warranty_void_reason,
notes=self.warranty_void_notes or '',
)
repair.x_fc_labor_warranty_status = 'void_misuse'
repair.message_post(body=Markup(_(
'Warranty <b>VOIDED</b> on this visit. Reason: %(r)s. '
'Full labor charged.'
)) % {'r': dict(self._fields['warranty_void_reason'].selection).get(
self.warranty_void_reason)})
# Append technician notes to chatter.
if self.notes:
repair.message_post(body=self.notes)
# Spawn a follow-up repair if the tech found another issue.
stub = False
if self.found_another_issue:
stub = repair.copy({
'state': 'draft',
'internal_notes': _(
'<p><em>Spawned from visit report on %(ref)s. Add details for the new issue.</em></p>',
ref=repair.name,
),
'x_fc_intake_source': 'manual',
'x_fc_intake_session_id': repair.x_fc_intake_session_id,
'x_fc_estimated_cost': 0.0,
'x_fc_actual_cost': 0.0,
'x_fc_requires_requote': False,
'x_fc_intake_template_id': False,
'x_fc_service_catalog_id': False,
'x_fc_maintenance_contract_id': False,
})
repair.message_post(
body=Markup(_(
'Spawned follow-up repair <b>%(name)s</b> for "found another issue".'
)) % {'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)
# T4 / T6 / T7: persist captured artefacts as ir.attachment on the
# repair so they survive the wizard close.
self._persist_mobile_artefacts(repair)
# M5: burn a pre-paid service plan visit if the client has one and
# the repair is a maintenance visit. The wizard intentionally does NOT
# zero out the client's invoice line - the office still posts the
# invoice; the burn is informational + the office reconciles credits
# in their accounting flow.
if not repair.x_fc_is_quote_only:
self._burn_service_plan_visit(repair)
# Bundle 8: parts-needed branch - capture the parts, flag the repair,
# email the client, leave the repair OPEN with awaiting_parts substate.
if self.outcome == 'parts_needed':
self._handle_parts_needed(repair)
elif self.outcome == 'rescheduled':
repair.message_post(body=Markup(_(
'Visit reported as <b>rescheduled</b>. Repair kept open.'
)))
# BUG-B1 fix: actually close the repair so the whole downstream chain
# (NPS cron, dashboard "done this month" stats, customer survey) fires.
# Leave open if requote needed - the office will re-quote and the tech
# will revisit. No-show / parts-needed / rescheduled / quote-only also
# stay open.
elif (self.outcome == 'completed'
and not self.requires_requote
and not self.no_show
and not repair.x_fc_is_quote_only
and not stub):
self._close_repair(repair)
elif self.no_show:
repair.message_post(body=Markup(_(
'Repair kept <b>open</b> due to no-show. Office to reschedule.'
)))
elif self.requires_requote:
repair.message_post(body=Markup(_(
'Repair kept <b>open</b> pending re-quote (variance flag).'
)))
# If a stub was spawned, open it directly so the tech can fill in details.
# 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': repair.name,
'res_model': 'repair.order',
'view_mode': 'form',
'res_id': repair.id,
}
def _persist_mobile_artefacts(self, repair):
"""T4/T6/T7: attach signature image, no-show photo, and serial list
to the repair so they survive after the transient wizard closes."""
Attachment = self.env['ir.attachment'].sudo()
if self.client_signature:
Attachment.create({
'name': f'signature-{repair.name}.png',
'datas': self.client_signature,
'res_model': 'repair.order',
'res_id': repair.id,
'mimetype': 'image/png',
})
who = self.client_signature_name or repair.partner_id.name or ''
repair.message_post(body=Markup(_(
'Client signature captured (<b>%s</b>).'
)) % who)
if self.no_show:
if self.no_show_photo:
Attachment.create({
'name': f'no-show-{repair.name}.jpg',
'datas': self.no_show_photo,
'res_model': 'repair.order',
'res_id': repair.id,
'mimetype': 'image/jpeg',
})
repair.message_post(body=Markup(_(
'Visit recorded as <b>client no-show</b>%s.'
)) % (' (photo attached)' if self.no_show_photo else ''))
if self.parts_serial_capture and self.parts_serial_capture.strip():
repair.message_post(body=Markup(_(
'Replaced part serials captured:<br/><pre>%s</pre>'
)) % self.parts_serial_capture.strip())
def _handle_parts_needed(self, repair):
"""Capture each part line as a fusion.repair.part.order record,
flag the repair as Awaiting Parts, and email the client a
"we found the problem - here's the timeline" note."""
if not self.needs_parts_line_ids:
raise UserError(_(
'Tick "Can\'t Fix Today - Need to Order Parts" but no parts '
'are captured. Add at least one part line so procurement can '
'place the order.'
))
PartOrder = self.env['fusion.repair.part.order'].sudo()
Attachment = self.env['ir.attachment'].sudo()
max_lead = 0
for line in self.needs_parts_line_ids:
# Copy any uploaded photos onto attachments owned by the part order.
photo_ids = []
for att in line.photo_ids:
copied = Attachment.create({
'name': att.name,
'datas': att.datas,
'mimetype': att.mimetype,
})
photo_ids.append(copied.id)
part = PartOrder.create({
'repair_order_id': repair.id,
'description': line.description,
'oem_part_number': line.oem_part_number,
'manufacturer': line.manufacturer,
'quantity': line.quantity or 1.0,
'notes': line.notes,
'photo_ids': [(6, 0, photo_ids)] if photo_ids else False,
'expected_date': line.expected_lead_days and (
fields.Date.context_today(self)
+ timedelta(days=line.expected_lead_days)
) or False,
})
max_lead = max(max_lead, int(line.expected_lead_days or 0))
repair.write({
'x_fc_parts_awaiting': True,
'x_fc_parts_eta_date': (
fields.Date.context_today(self) + timedelta(days=max_lead + 2)
if max_lead else False
),
})
# Office activity - "place these orders today".
repair.activity_schedule(
summary='Order parts from manufacturer(s)',
note=_('Tech captured %d part(s) - place the order(s) today.'
) % len(self.needs_parts_line_ids),
user_id=repair.user_id.id or self.env.uid,
)
# Client comms.
tpl = self.env.ref(
'fusion_repairs.email_template_repair_awaiting_parts',
raise_if_not_found=False,
)
if tpl and repair.partner_id and repair.partner_id.email:
try:
tpl.send_mail(repair.id, force_send=False)
except Exception:
_logger.exception('Awaiting-parts email failed for %s', repair.name)
repair.message_post(body=Markup(_(
'Visit reported as <b>parts needed</b>. Captured %(n)d part order(s); '
'repair flagged "Awaiting Parts". Client notified.'
)) % {'n': len(self.needs_parts_line_ids)})
def _close_repair(self, repair):
"""Drive the Odoo native state machine from draft -> done.
Odoo 19 sequence: draft -> action_validate (confirmed/under_repair)
-> action_repair_start (under_repair) -> action_repair_end (done).
Calls are guarded - silently re-runs only the missing steps.
"""
try:
if repair.state == 'draft':
# action_validate is the standard entry path; if the product is
# storable it expects reservations etc., so fall back to the
# simpler _action_repair_confirm() helper if validate refuses.
try:
repair.action_validate()
except Exception as e:
_logger.info(
'action_validate skipped for %s: %s; using internal confirm.',
repair.name, e,
)
repair._action_repair_confirm()
if repair.state == 'confirmed':
repair.action_repair_start()
if repair.state == 'under_repair':
repair.action_repair_end()
repair.message_post(body=Markup(_(
'Visit report submitted - repair closed by <b>%s</b>.'
)) % (self.technician_id.name or self.env.user.name))
except Exception as e:
_logger.exception(
'Visit report could not close repair %s automatically: %s',
repair.name, e,
)
repair.message_post(body=Markup(_(
'<b>Could not auto-close repair</b>: %s. Office must close manually.'
)) % str(e))
def _burn_service_plan_visit(self, repair):
"""M5: deduct one visit from the most-recently-active service plan
covering this repair. Quietly no-ops if the client has no plan."""
Plan = self.env['fusion.repair.service.plan.subscription'].sudo()
sub = Plan.find_for_repair(repair)
if sub:
sub.burn_visit(repair)
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').
Locations follow the repair order's configured source / parts locations;
Odoo natively links these moves to the SO line generated by
action_create_sale_order() so they invoice correctly.
"""
Move = self.env['stock.move'].sudo()
for line in self.parts_line_ids:
if not line.product_id or line.quantity <= 0:
continue
vals = {
'name': line.product_id.display_name,
'product_id': line.product_id.id,
'product_uom_qty': line.quantity,
'product_uom': line.product_id.uom_id.id,
'repair_id': repair.id,
'repair_line_type': 'add',
'location_id': repair.location_id.id,
'location_dest_id': repair.parts_location_id.id or repair.location_id.id,
'company_id': repair.company_id.id,
}
try:
Move.create(vals)
except Exception as e:
_logger.warning(
'Could not create repair part move on %s for %s: %s',
repair.name, line.product_id.display_name, e,
)
class RepairVisitReportWizardLine(models.TransientModel):
_name = 'fusion.repair.visit.report.wizard.line'
_description = 'Repair Visit Report Wizard - Part Line'
wizard_id = fields.Many2one(
'fusion.repair.visit.report.wizard',
required=True,
ondelete='cascade',
)
product_id = fields.Many2one(
'product.product',
string='Part',
required=True,
)
quantity = fields.Float(default=1.0, required=True)
unit_price = fields.Float(string='Unit Price')
subtotal = fields.Float(compute='_compute_subtotal', store=True)
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.unit_price = self.product_id.list_price
@api.depends('quantity', 'unit_price')
def _compute_subtotal(self):
for line in self:
line.subtotal = line.quantity * line.unit_price
class RepairVisitReportWizardPartLine(models.TransientModel):
"""Bundle 8: parts the tech needs the office to ORDER from the manufacturer.
Captured during the visit report when outcome='parts_needed'; one record per
distinct part. On wizard confirm, each line creates a
fusion.repair.part.order which is the procurement-facing record.
"""
_name = 'fusion.repair.visit.report.wizard.partline'
_description = 'Visit Report - Part to Order'
wizard_id = fields.Many2one(
'fusion.repair.visit.report.wizard',
required=True,
ondelete='cascade',
)
description = fields.Char(
string='Description',
required=True,
help='Plain English (e.g. "Handicare 1100 right armrest").',
)
oem_part_number = fields.Char(string='OEM #')
manufacturer = fields.Char(string='Manufacturer')
quantity = fields.Float(default=1.0, required=True)
expected_lead_days = fields.Integer(
string='Lead Time (days)',
default=7,
help='Tech estimate. Office uses this to set client ETA expectations.',
)
notes = fields.Text(string='Notes for Procurement')
photo_ids = fields.Many2many(
'ir.attachment',
'fusion_repair_visit_partline_photo_rel',
'partline_id', 'attachment_id',
string='Photos',
)

View File

@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_visit_report_wizard_form" model="ir.ui.view">
<field name="name">fusion.repair.visit.report.wizard.form</field>
<field name="model">fusion.repair.visit.report.wizard</field>
<field name="arch" type="xml">
<form string="Visit Report">
<sheet>
<group>
<group>
<field name="repair_id" readonly="1"/>
<field name="technician_id"
options="{'no_create': True}"/>
</group>
<group>
<field name="labour_hours"/>
</group>
</group>
<separator string="Parts Used"/>
<field name="parts_line_ids">
<list editable="bottom">
<field name="product_id"/>
<field name="quantity"/>
<field name="unit_price" widget="monetary"/>
<field name="subtotal" widget="monetary"/>
</list>
</field>
<separator string="Cost Reconciliation"/>
<group>
<group>
<field name="estimated_cost" widget="monetary"/>
<field name="actual_cost" widget="monetary"/>
</group>
<group>
<field name="variance_pct" widget="float" digits="[12,1]"/>
<field name="requires_requote"/>
<field name="company_currency_id" invisible="1"/>
</group>
</group>
<div class="alert alert-warning" role="alert"
invisible="not requires_requote">
Variance exceeds the configured threshold. Saving will
mark the repair as <strong>Requires Re-Quote</strong>;
a manager must review before invoicing.
</div>
<separator string="Outcome"/>
<group>
<field name="outcome" widget="radio"/>
</group>
<field name="notes"/>
<!-- Bundle 8: parts-needed branch - rendered only when chosen -->
<separator string="Parts to Order"
invisible="outcome != 'parts_needed'"/>
<field name="needs_parts_line_ids"
invisible="outcome != 'parts_needed'">
<list editable="bottom">
<field name="description"/>
<field name="oem_part_number"/>
<field name="manufacturer"/>
<field name="quantity"/>
<field name="expected_lead_days"/>
<field name="notes" optional="show"/>
<field name="photo_ids" widget="many2many_binary"/>
</list>
</field>
<field name="found_another_issue"
invisible="outcome != 'completed'"/>
<field name="issue_inspection_cert"
invisible="outcome != 'completed'"/>
<!-- Bundle 9: callout pricing capture + live quote preview -->
<separator string="Callout Pricing (Bundle 9)"
invisible="outcome != 'completed'"/>
<group invisible="outcome != 'completed'">
<group>
<field name="callout_tier"/>
<field name="callout_in_shop"/>
<field name="callout_techs"
invisible="callout_in_shop"/>
<field name="callout_distance_km"
invisible="callout_in_shop"/>
<field name="callout_labor_hours_used"/>
</group>
<group>
<field name="labor_warranty_id_preview" readonly="1"/>
<field name="labor_warranty_status_preview" widget="badge"
decoration-success="labor_warranty_status_preview == 'eligible'"
decoration-warning="labor_warranty_status_preview in ('expired', 'waived')"
decoration-danger="labor_warranty_status_preview == 'void_misuse'"/>
<field name="warranty_void_reason"/>
<field name="warranty_void_notes"
invisible="not warranty_void_reason"
required="warranty_void_reason"/>
</group>
</group>
<group invisible="outcome != 'completed'">
<field name="quote_total_preview" widget="monetary" readonly="1"
class="oe_subtotal_footer_separator"/>
<field name="company_currency_id" invisible="1"/>
</group>
<field name="quote_breakdown_preview"
readonly="1" nolabel="1"
invisible="outcome != 'completed'"/>
<separator string="No-Show (T7)"/>
<group>
<field name="no_show"/>
<field name="no_show_photo" widget="image" filename="no_show_photo_filename"
invisible="not no_show"/>
</group>
<separator string="Parts Replaced - Serial Capture (T6)"/>
<field name="parts_serial_capture" nolabel="1"
placeholder="One serial per line - used for OEM warranty claims"/>
<separator string="Client Signature (T4)"/>
<group>
<field name="client_signature_name"/>
<field name="client_signature" widget="signature"/>
</group>
</sheet>
<footer>
<button string="Save Visit Report"
name="action_confirm"
type="object"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Fusion — Service Charges.
Seeds the service-billing product catalog (Service Call, Labour,
Delivery, Stairlift set-up, etc.) on FIRST install only.
Architecture decision: no data XML in the manifest. All products are
created imperatively in a post_init_hook. This guarantees:
- Users can edit prices / names / accounting tags freely after
install — upgrades won't overwrite them.
- Users can delete products that don't apply to their shop —
upgrades won't resurrect them (Odoo's "noupdate=1" doesn't
actually prevent re-creation when the ir.model.data row is
missing, only updates; see fusion_plating 19.0.20.5.0 hook for
the same pattern + investigation).
- Re-installing the module after uninstall DOES re-seed (the
ir.model.data sentinels are dropped on uninstall, so the next
install's hook treats it as a fresh seed).
Per-shop pricing — currently identical on Westin Healthcare and
Mobility Specialties; if they diverge we can add a setting per
shop or a pricelist override.
"""
import logging
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Rate schedule — single source of truth.
#
# Each row creates one product.template via the post_init_hook.
# Tuple structure: (xmlid_suffix, name, category, uom_xmlid, list_price,
# description)
#
# Per-km surcharges (Rush / Outside Local / After-Hours) are noted in
# the description so the dispatcher knows to add a km line manually.
# A formula-based pricelist would automate this but is out of scope —
# matching today's manual workflow on both shops.
# ---------------------------------------------------------------------------
_SERVICE_CHARGES = [
# (xmlid_suffix, name, default_code, uom_xmlid, price, description)
# ---- Standard Service ----
('standard_service_call',
'Standard Service Call',
'SVC-STD-CALL', 'uom.product_uom_unit', 95.00,
'Service Call — appointment outside a Westin Healthcare location. '
'Billed once per service request. Includes the first 30 minutes '
'of labour; additional time billed at the Standard Labour Rate. '
'Excludes parts (covered by manufacturer warranty when applicable).'),
('standard_labour',
'Standard Labour (Hourly)',
'SVC-STD-LABOUR', 'uom.product_uom_hour', 85.00,
'Standard hourly labour rate. Pro-rated in 30-minute increments. '
'Starts after the 30 minutes included in the Service Call. '
'Applies per technician when multiple are on the job.'),
('in_shop_labour',
'In-Shop Labour (Hourly)',
'SVC-INSHOP-LABOUR', 'uom.product_uom_hour', 75.00,
'Hourly labour rate when the work is done at the Westin Healthcare '
'shop instead of on-site. Pro-rated in 30-minute increments.'),
('rush_service_call',
'Rush Service Call',
'SVC-RUSH-CALL', 'uom.product_uom_unit', 120.00,
'Rush dispatch — same-day / priority response. Adds $0.70 per '
'km (2-way) on top of this flat fee; add the mileage as a '
'separate line.'),
('after_hours_service_call',
'After-Hours Service Call',
'SVC-AH-CALL', 'uom.product_uom_unit', 140.00,
'Service call outside standard business hours. Adds $0.70 per '
'km (2-way) on top of this flat fee; add the mileage as a '
'separate line.'),
# ---- Lift & Elevating Service ----
('lift_service_call',
'Lift & Elevating Service Call',
'SVC-LIFT-CALL', 'uom.product_uom_unit', 160.00,
'Service Call for stairlift / lift / elevating equipment. '
'Includes the first 30 minutes of labour. Excludes parts '
'unless covered by manufacturer warranty.'),
('lift_labour',
'Lift & Elevating Labour (Hourly)',
'SVC-LIFT-LABOUR', 'uom.product_uom_hour', 110.00,
'Hourly labour rate for stairlift / lift / elevating equipment. '
'Pro-rated in 30-minute increments. Per-technician.'),
# ---- Delivery / Pickup ----
('delivery_local',
'Local Delivery / Pickup',
'DEL-LOCAL', 'uom.product_uom_unit', 35.00,
'Drop-off or pick-up within Brampton.'),
('delivery_outside_local',
'Outside Local Delivery / Pickup',
'DEL-OUT', 'uom.product_uom_unit', 60.00,
'Drop-off or pick-up outside Brampton.'),
('delivery_rush',
'Rush Delivery / Pickup',
'DEL-RUSH', 'uom.product_uom_unit', 60.00,
'Same-day delivery or pickup. Adds $0.70 per km (2-way) on top '
'of this flat fee; add the mileage as a separate line.'),
('delivery_lift_chair',
'Lift Chair Delivery + Set-up',
'DEL-LIFT-CHAIR', 'uom.product_uom_unit', 120.00,
'Delivery and in-home set-up of a lift chair.'),
('delivery_hospital_bed',
'Hospital Bed Delivery + Set-up',
'DEL-HOSP-BED', 'uom.product_uom_unit', 120.00,
'Delivery and in-home set-up of a hospital bed.'),
('delivery_stairlift',
'Stairlift Delivery + Set-up',
'DEL-STAIRLIFT', 'uom.product_uom_unit', 300.00,
'Delivery and installation of a stairlift.'),
('removal_stairlift',
'Stairlift Removal',
'SVC-STAIRLIFT-RM', 'uom.product_uom_unit', 300.00,
'On-site removal of an existing stairlift.'),
]
def post_init_hook(env):
_seed_service_charges_once(env)
def _seed_service_charges_once(env):
"""Create product.template rows for the service catalog.
Idempotent — each row guarded by an ir.model.data xmlid check.
If the xmlid already resolves to a record, that product is left
alone (its price / name / accounting tags may have been edited
by the shop). New rows are created with an ir.model.data sentinel
so a future run sees them as already-seeded.
Re-running the hook by hand:
env['ir.module.module'].search([('name', '=', 'fusion_service_charges')]).button_upgrade()
# (post_init_hook only fires on first install in Odoo 19; for
# a re-seed you'd uninstall+reinstall, which is fine because
# ir.model.data is dropped on uninstall)
"""
Product = env['product.template'].sudo()
IMD = env['ir.model.data'].sudo()
module_name = 'fusion_service_charges'
created = []
skipped = []
for (xmlid_suffix, name, default_code, uom_xmlid, price,
description) in _SERVICE_CHARGES:
existing = IMD.search([
('module', '=', module_name),
('name', '=', xmlid_suffix),
], limit=1)
if existing:
skipped.append(default_code)
continue
uom = env.ref(uom_xmlid, raise_if_not_found=False)
if not uom:
_logger.warning(
'fusion_service_charges: UoM %s not found, '
'falling back to product_uom_unit', uom_xmlid,
)
uom = env.ref('uom.product_uom_unit')
# Odoo 19 retired uom_po_id on product.template — uom_id is the
# single source of truth for sale + purchase.
product = Product.create({
'name': name,
'type': 'service',
'default_code': default_code,
'list_price': price,
'sale_ok': True,
'purchase_ok': False,
'uom_id': uom.id,
'description_sale': description,
})
IMD.create({
'module': module_name,
'name': xmlid_suffix,
'model': 'product.template',
'res_id': product.id,
'noupdate': True,
})
created.append(default_code)
if created:
_logger.info(
'fusion_service_charges: seeded %d product(s) — %s',
len(created), ', '.join(created),
)
if skipped:
_logger.info(
'fusion_service_charges: skipped %d existing product(s) — %s',
len(skipped), ', '.join(skipped),
)

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion — Service Charges',
'version': '19.0.1.0.0',
'category': 'Sales',
'summary': (
'Standard service-call, labour, delivery, and installation '
'products for Westin Healthcare and Mobility Specialties.'
),
'description': """
Fusion — Service Charges
==========================
Seeds the service-billing product catalog used by Westin Healthcare
and Mobility Specialties:
* Standard Service: Service Call, Labour (hourly), In-Shop Labour,
Rush Service Call, After-Hours Service Call
* Lift & Elevating Service: Service Call, Labour (hourly)
* Delivery / Pickup: Local, Outside Local Area, Rush, Lift Chair
set-up, Hospital Bed set-up, Stairlift set-up, Stairlift Removal
Loading pattern (deliberate):
* Products are created via post_init_hook on FIRST install only.
* No data XML is registered in the manifest, so ``-u`` upgrades
never touch the records. Edits and deletions made by sales/ops
survive every upgrade.
* The hook is idempotent — sentinel xmlid check skips already-
seeded products. Re-running the hook by hand is safe.
* Re-installing the module after uninstall re-creates the products
(the ir.model.data sentinels go away on uninstall, so the next
install treats it as fresh).
Per-km surcharges (Rush, Outside Local) ARE captured on the
product as a hint in the product description; actual km billing
is left as a manual SO-line tweak by the dispatcher (matches
current shop workflow — formula-based pricing would need a
sale.order.line.onchange to compute, out of scope here).
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': [
'product',
'uom',
],
# Empty on purpose — no data XML. See the docstring on
# _seed_service_charges_once() for why every product is created
# imperatively via the post_init_hook.
'data': [],
'post_init_hook': 'post_init_hook',
'installable': True,
'application': False,
'auto_install': False,
}