Merge branch 'main' of https://github.com/gsinghpal/Odoo-Modules
This commit is contained in:
1351
docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
Normal file
1351
docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
Normal file
File diff suppressed because it is too large
Load Diff
7
fusion_repairs/__init__.py
Normal file
7
fusion_repairs/__init__.py
Normal 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
|
||||
134
fusion_repairs/__manifest__.py
Normal file
134
fusion_repairs/__manifest__.py
Normal 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,
|
||||
}
|
||||
7
fusion_repairs/controllers/__init__.py
Normal file
7
fusion_repairs/controllers/__init__.py
Normal 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
|
||||
372
fusion_repairs/controllers/portal_client_repair.py
Normal file
372
fusion_repairs/controllers/portal_client_repair.py
Normal 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,
|
||||
})
|
||||
70
fusion_repairs/controllers/portal_maintenance_booking.py
Normal file
70
fusion_repairs/controllers/portal_maintenance_booking.py
Normal 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,
|
||||
})
|
||||
186
fusion_repairs/controllers/portal_sales_rep_repair.py
Normal file
186
fusion_repairs/controllers/portal_sales_rep_repair.py
Normal 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),
|
||||
})
|
||||
162
fusion_repairs/data/callout_rate_data.xml
Normal file
162
fusion_repairs/data/callout_rate_data.xml
Normal 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 & 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 & 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 & 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 & 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 & ELEVATING - statutory holiday. $360 callout.</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
73
fusion_repairs/data/delivery_charge_data.xml
Normal file
73
fusion_repairs/data/delivery_charge_data.xml
Normal 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>
|
||||
91
fusion_repairs/data/emergency_charge_data.xml
Normal file
91
fusion_repairs/data/emergency_charge_data.xml
Normal 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>
|
||||
378
fusion_repairs/data/intake_template_data.xml
Normal file
378
fusion_repairs/data/intake_template_data.xml
Normal 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>
|
||||
66
fusion_repairs/data/ir_config_parameter_data.xml
Normal file
66
fusion_repairs/data/ir_config_parameter_data.xml
Normal 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>
|
||||
86
fusion_repairs/data/ir_cron_data.xml
Normal file
86
fusion_repairs/data/ir_cron_data.xml
Normal 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>
|
||||
88
fusion_repairs/data/ir_sequence_data.xml
Normal file
88
fusion_repairs/data/ir_sequence_data.xml
Normal 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>
|
||||
66
fusion_repairs/data/mail_activity_type_data.xml
Normal file
66
fusion_repairs/data/mail_activity_type_data.xml
Normal 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>
|
||||
483
fusion_repairs/data/mail_template_data.xml
Normal file
483
fusion_repairs/data/mail_template_data.xml
Normal 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>
|
||||
94
fusion_repairs/data/repair_product_category_data.xml
Normal file
94
fusion_repairs/data/repair_product_category_data.xml
Normal 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 & 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>
|
||||
245
fusion_repairs/data/self_check_data.xml
Normal file
245
fusion_repairs/data/self_check_data.xml
Normal 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>
|
||||
49
fusion_repairs/migrations/19.0.2.1.0/post-migration.py
Normal file
49
fusion_repairs/migrations/19.0.2.1.0/post-migration.py
Normal 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');
|
||||
""")
|
||||
30
fusion_repairs/models/__init__.py
Normal file
30
fusion_repairs/models/__init__.py
Normal 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
|
||||
88
fusion_repairs/models/intake_answer.py
Normal file
88
fusion_repairs/models/intake_answer.py
Normal 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 = ''
|
||||
84
fusion_repairs/models/intake_question.py
Normal file
84
fusion_repairs/models/intake_question.py
Normal 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").',
|
||||
)
|
||||
554
fusion_repairs/models/intake_service.py
Normal file
554
fusion_repairs/models/intake_service.py
Normal 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
|
||||
74
fusion_repairs/models/intake_template.py
Normal file
74
fusion_repairs/models/intake_template.py
Normal 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},
|
||||
}
|
||||
227
fusion_repairs/models/maintenance_contract.py
Normal file
227
fusion_repairs/models/maintenance_contract.py
Normal 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',
|
||||
})
|
||||
42
fusion_repairs/models/product_template.py
Normal file
42
fusion_repairs/models/product_template.py
Normal 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.',
|
||||
)
|
||||
380
fusion_repairs/models/repair_ai_service.py
Normal file
380
fusion_repairs/models/repair_ai_service.py
Normal 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])
|
||||
178
fusion_repairs/models/repair_callout_rate.py
Normal file
178
fusion_repairs/models/repair_callout_rate.py
Normal 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
|
||||
189
fusion_repairs/models/repair_dashboard.py
Normal file
189
fusion_repairs/models/repair_dashboard.py
Normal 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,
|
||||
}
|
||||
88
fusion_repairs/models/repair_delivery_charge.py
Normal file
88
fusion_repairs/models/repair_delivery_charge.py
Normal 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)
|
||||
107
fusion_repairs/models/repair_emergency_charge.py
Normal file
107
fusion_repairs/models/repair_emergency_charge.py
Normal 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)
|
||||
229
fusion_repairs/models/repair_inspection.py
Normal file
229
fusion_repairs/models/repair_inspection.py
Normal file
@@ -0,0 +1,229 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Compliance inspection certificates (M1).
|
||||
|
||||
Per the design spec section "Phase 4 - Compliance, claims, analytics":
|
||||
Stairlifts / porch lifts need an annual safety inspection certificate
|
||||
(jurisdictional requirement in many places). This model tracks issued
|
||||
certificates, their expiry dates, and a daily cron warns the office +
|
||||
client when one is approaching the 30-day expiry mark.
|
||||
|
||||
A certificate is issued AFTER a successful inspection technician visit -
|
||||
the visit-report wizard's "Issue Compliance Certificate" button creates
|
||||
the record and renders a PDF.
|
||||
|
||||
Phase 1 jurisdiction support: a single 'Ontario' jurisdiction text field
|
||||
on the certificate; future phases add per-jurisdiction PDF templates.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class FusionRepairInspectionCertificate(models.Model):
|
||||
_name = 'fusion.repair.inspection.certificate'
|
||||
_inherit = ['mail.thread']
|
||||
_description = 'Repair Inspection Certificate'
|
||||
_order = 'issued_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Certificate Number',
|
||||
required=True,
|
||||
default='New',
|
||||
copy=False,
|
||||
readonly=True,
|
||||
tracking=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Client',
|
||||
required=True,
|
||||
tracking=True,
|
||||
index=True,
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Equipment',
|
||||
required=True,
|
||||
domain="[('x_fc_repair_category_id.safety_critical', '=', True)]",
|
||||
tracking=True,
|
||||
)
|
||||
lot_id = fields.Many2one(
|
||||
'stock.lot',
|
||||
string='Serial Number',
|
||||
tracking=True,
|
||||
)
|
||||
repair_order_id = fields.Many2one(
|
||||
'repair.order',
|
||||
string='Inspection Repair',
|
||||
help='The repair / technician task during which this inspection was done.',
|
||||
ondelete='set null',
|
||||
)
|
||||
inspector_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Inspector',
|
||||
required=True,
|
||||
default=lambda self: self.env.user,
|
||||
tracking=True,
|
||||
domain="[('x_fc_is_field_staff', '=', True)]",
|
||||
)
|
||||
|
||||
jurisdiction = fields.Selection(
|
||||
[
|
||||
('on', 'Ontario'),
|
||||
('bc', 'British Columbia'),
|
||||
('ab', 'Alberta'),
|
||||
('qc', 'Quebec'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Jurisdiction',
|
||||
default='on',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
issued_date = fields.Date(
|
||||
string='Issued',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
tracking=True,
|
||||
)
|
||||
valid_for_months = fields.Integer(
|
||||
string='Valid For (Months)',
|
||||
default=12,
|
||||
required=True,
|
||||
)
|
||||
expiry_date = fields.Date(
|
||||
string='Expires',
|
||||
compute='_compute_expiry_date',
|
||||
store=True,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# Status compute (non-stored - time-dependent, per Bundle 1 C4 fix pattern).
|
||||
status = fields.Selection(
|
||||
[
|
||||
('valid', 'Valid'),
|
||||
('expiring', 'Expiring Soon'),
|
||||
('expired', 'Expired'),
|
||||
('revoked', 'Revoked'),
|
||||
],
|
||||
string='Status',
|
||||
compute='_compute_status',
|
||||
)
|
||||
revoked = fields.Boolean(
|
||||
string='Revoked',
|
||||
copy=False,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
notes = fields.Html(string='Inspector Notes')
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# Reminder tracking (X2-style band markers so the cron doesn't spam).
|
||||
last_reminder_band = fields.Selection(
|
||||
[('30', '30 days'), ('7', '7 days')],
|
||||
string='Last Reminder',
|
||||
copy=False,
|
||||
)
|
||||
|
||||
_certificate_number_unique = models.Constraint(
|
||||
'unique(name)',
|
||||
'Inspection certificate numbers must be unique.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CREATE
|
||||
# ------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', 'New') == 'New':
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code(
|
||||
'fusion.repair.inspection.certificate'
|
||||
) or 'CERT/NEW'
|
||||
return super().create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# COMPUTES
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends('issued_date', 'valid_for_months')
|
||||
def _compute_expiry_date(self):
|
||||
for c in self:
|
||||
if c.issued_date and c.valid_for_months:
|
||||
c.expiry_date = c.issued_date + relativedelta(months=c.valid_for_months)
|
||||
else:
|
||||
c.expiry_date = False
|
||||
|
||||
def _compute_status(self):
|
||||
today = fields.Date.context_today(self)
|
||||
for c in self:
|
||||
if c.revoked:
|
||||
c.status = 'revoked'
|
||||
elif not c.expiry_date:
|
||||
c.status = 'valid'
|
||||
elif c.expiry_date < today:
|
||||
c.status = 'expired'
|
||||
elif c.expiry_date <= today + timedelta(days=30):
|
||||
c.status = 'expiring'
|
||||
else:
|
||||
c.status = 'valid'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ACTIONS
|
||||
# ------------------------------------------------------------------
|
||||
def action_revoke(self):
|
||||
for c in self:
|
||||
c.revoked = True
|
||||
c.message_post(body=_('Certificate revoked.'))
|
||||
|
||||
def action_print(self):
|
||||
self.ensure_one()
|
||||
return self.env.ref(
|
||||
'fusion_repairs.action_report_inspection_certificate'
|
||||
).report_action(self)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRON: warn the client 30 + 7 days before expiry
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def cron_send_expiry_reminders(self):
|
||||
"""Daily cron. Sends a reminder at the 30-day band, then again at
|
||||
the 7-day band, so the client books their re-inspection visit
|
||||
before the certificate lapses."""
|
||||
Service = self.env.get('fusion.repair.intake.service')
|
||||
if Service and not Service._notifications_enabled():
|
||||
return
|
||||
today = fields.Date.context_today(self)
|
||||
tpl = self.env.ref(
|
||||
'fusion_repairs.email_template_inspection_expiry_reminder',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not tpl:
|
||||
return
|
||||
for band_label, days in (('30', 30), ('7', 7)):
|
||||
target = today + timedelta(days=days)
|
||||
certs = self.search([
|
||||
('revoked', '=', False),
|
||||
('expiry_date', '=', target),
|
||||
('partner_id.email', '!=', False),
|
||||
'|', ('last_reminder_band', '=', False),
|
||||
('last_reminder_band', '!=', band_label),
|
||||
])
|
||||
for c in certs:
|
||||
# Skip if a smaller band already sent (30 -> 7 progression).
|
||||
if c.last_reminder_band and int(c.last_reminder_band) <= days:
|
||||
continue
|
||||
try:
|
||||
tpl.send_mail(c.id, force_send=False)
|
||||
c.last_reminder_band = band_label
|
||||
except Exception:
|
||||
continue
|
||||
231
fusion_repairs/models/repair_labor_warranty.py
Normal file
231
fusion_repairs/models/repair_labor_warranty.py
Normal 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.'))
|
||||
226
fusion_repairs/models/repair_on_call_service.py
Normal file
226
fusion_repairs/models/repair_on_call_service.py
Normal 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)
|
||||
1217
fusion_repairs/models/repair_order.py
Normal file
1217
fusion_repairs/models/repair_order.py
Normal file
File diff suppressed because it is too large
Load Diff
252
fusion_repairs/models/repair_part_order.py
Normal file
252
fusion_repairs/models/repair_part_order.py
Normal 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)})
|
||||
64
fusion_repairs/models/repair_product_category.py
Normal file
64
fusion_repairs/models/repair_product_category.py
Normal 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 ''
|
||||
60
fusion_repairs/models/repair_self_check_rule.py
Normal file
60
fusion_repairs/models/repair_self_check_rule.py
Normal 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.',
|
||||
)
|
||||
270
fusion_repairs/models/repair_service_plan.py
Normal file
270
fusion_repairs/models/repair_service_plan.py
Normal 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),
|
||||
})
|
||||
131
fusion_repairs/models/repair_warranty.py
Normal file
131
fusion_repairs/models/repair_warranty.py
Normal 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)
|
||||
64
fusion_repairs/models/res_config_settings.py
Normal file
64
fusion_repairs/models/res_config_settings.py
Normal 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,
|
||||
)
|
||||
64
fusion_repairs/models/res_partner.py
Normal file
64
fusion_repairs/models/res_partner.py
Normal 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)
|
||||
57
fusion_repairs/models/res_users.py
Normal file
57
fusion_repairs/models/res_users.py
Normal 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,
|
||||
)
|
||||
80
fusion_repairs/models/sale_order.py
Normal file
80
fusion_repairs/models/sale_order.py
Normal 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)],
|
||||
}
|
||||
151
fusion_repairs/models/service_catalog.py
Normal file
151
fusion_repairs/models/service_catalog.py
Normal 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()
|
||||
153
fusion_repairs/models/technician_task.py
Normal file
153
fusion_repairs/models/technician_task.py
Normal 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',
|
||||
}
|
||||
167
fusion_repairs/report/inspection_certificate_report.xml
Normal file
167
fusion_repairs/report/inspection_certificate_report.xml
Normal 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>
|
||||
86
fusion_repairs/report/qr_sticker_report.xml
Normal file
86
fusion_repairs/report/qr_sticker_report.xml
Normal 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>
|
||||
53
fusion_repairs/security/ir.model.access.csv
Normal file
53
fusion_repairs/security/ir.model.access.csv
Normal 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
|
||||
|
176
fusion_repairs/security/security.xml
Normal file
176
fusion_repairs/security/security.xml
Normal 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>
|
||||
BIN
fusion_repairs/static/description/icon.png
Normal file
BIN
fusion_repairs/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
149
fusion_repairs/static/src/components/dashboard/dashboard.js
Normal file
149
fusion_repairs/static/src/components/dashboard/dashboard.js
Normal 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);
|
||||
341
fusion_repairs/static/src/components/dashboard/dashboard.xml
Normal file
341
fusion_repairs/static/src/components/dashboard/dashboard.xml
Normal 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"> · <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 <= 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"/> · <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>
|
||||
173
fusion_repairs/static/src/js/portal_client_repair.js
Normal file
173
fusion_repairs/static/src/js/portal_client_repair.js
Normal 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,
|
||||
);
|
||||
107
fusion_repairs/static/src/js/portal_repair_intake.js
Normal file
107
fusion_repairs/static/src/js/portal_repair_intake.js
Normal 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);
|
||||
63
fusion_repairs/static/src/scss/_fr_tokens.scss
Normal file
63
fusion_repairs/static/src/scss/_fr_tokens.scss
Normal 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});
|
||||
323
fusion_repairs/static/src/scss/dashboard.scss
Normal file
323
fusion_repairs/static/src/scss/dashboard.scss
Normal 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; }
|
||||
}
|
||||
}
|
||||
39
fusion_repairs/static/src/scss/portal_client_repair.scss
Normal file
39
fusion_repairs/static/src/scss/portal_client_repair.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
fusion_repairs/static/src/scss/portal_repair_mobile.scss
Normal file
39
fusion_repairs/static/src/scss/portal_repair_mobile.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
95
fusion_repairs/views/intake_template_views.xml
Normal file
95
fusion_repairs/views/intake_template_views.xml
Normal 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>
|
||||
67
fusion_repairs/views/maintenance_contract_views.xml
Normal file
67
fusion_repairs/views/maintenance_contract_views.xml
Normal 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 <= 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>
|
||||
121
fusion_repairs/views/menus.xml
Normal file
121
fusion_repairs/views/menus.xml
Normal 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>
|
||||
287
fusion_repairs/views/portal_client_repair_templates.xml
Normal file
287
fusion_repairs/views/portal_client_repair_templates.xml
Normal 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>
|
||||
152
fusion_repairs/views/portal_maintenance_templates.xml
Normal file
152
fusion_repairs/views/portal_maintenance_templates.xml
Normal 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>
|
||||
281
fusion_repairs/views/portal_sales_rep_templates.xml
Normal file
281
fusion_repairs/views/portal_sales_rep_templates.xml
Normal 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">·</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 & 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>
|
||||
30
fusion_repairs/views/repair_callout_rate_views.xml
Normal file
30
fusion_repairs/views/repair_callout_rate_views.xml
Normal 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>
|
||||
10
fusion_repairs/views/repair_dashboard_views.xml
Normal file
10
fusion_repairs/views/repair_dashboard_views.xml
Normal 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>
|
||||
27
fusion_repairs/views/repair_delivery_charge_views.xml
Normal file
27
fusion_repairs/views/repair_delivery_charge_views.xml
Normal 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>
|
||||
26
fusion_repairs/views/repair_emergency_charge_views.xml
Normal file
26
fusion_repairs/views/repair_emergency_charge_views.xml
Normal 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>
|
||||
79
fusion_repairs/views/repair_inspection_views.xml
Normal file
79
fusion_repairs/views/repair_inspection_views.xml
Normal file
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_repair_inspection_list" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.inspection.certificate.list</field>
|
||||
<field name="model">fusion.repair.inspection.certificate</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Inspection Certificates"
|
||||
decoration-success="status == 'valid'"
|
||||
decoration-warning="status == 'expiring'"
|
||||
decoration-danger="status == 'expired' or status == 'revoked'">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="lot_id" optional="show"/>
|
||||
<field name="jurisdiction"/>
|
||||
<field name="issued_date"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="status" widget="badge"/>
|
||||
<field name="inspector_user_id" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_repair_inspection_form" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.inspection.certificate.form</field>
|
||||
<field name="model">fusion.repair.inspection.certificate</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Inspection Certificate">
|
||||
<header>
|
||||
<button name="action_print" type="object" string="Print Certificate"
|
||||
class="btn-primary" icon="fa-print"/>
|
||||
<button name="action_revoke" type="object" string="Revoke"
|
||||
class="btn-secondary" icon="fa-ban"
|
||||
invisible="revoked == True"
|
||||
confirm="Revoke this certificate? This cannot be undone."/>
|
||||
<field name="status" widget="badge"
|
||||
decoration-success="status == 'valid'"
|
||||
decoration-warning="status == 'expiring'"
|
||||
decoration-danger="status == 'expired' or status == 'revoked'"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" readonly="1"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id" options="{'no_create': True}"/>
|
||||
<field name="product_id" options="{'no_create': True}"/>
|
||||
<field name="lot_id"/>
|
||||
<field name="repair_order_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="inspector_user_id"/>
|
||||
<field name="jurisdiction"/>
|
||||
<field name="issued_date"/>
|
||||
<field name="valid_for_months"/>
|
||||
<field name="expiry_date" readonly="1"/>
|
||||
<field name="last_reminder_band" readonly="1"/>
|
||||
<field name="revoked" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="notes" placeholder="Inspection notes, measurements, photos taken..."/>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_repair_inspection" model="ir.actions.act_window">
|
||||
<field name="name">Inspection Certificates</field>
|
||||
<field name="res_model">fusion.repair.inspection.certificate</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
77
fusion_repairs/views/repair_labor_warranty_views.xml
Normal file
77
fusion_repairs/views/repair_labor_warranty_views.xml
Normal 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>
|
||||
419
fusion_repairs/views/repair_order_views.xml
Normal file
419
fusion_repairs/views/repair_order_views.xml
Normal 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 & 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', '>=', datetime.datetime.combine(context_today(), datetime.time(0,0,0)))]"/>
|
||||
<filter string="This Week" name="week"
|
||||
domain="[('create_date', '>=', 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>
|
||||
91
fusion_repairs/views/repair_part_order_views.xml
Normal file
91
fusion_repairs/views/repair_part_order_views.xml
Normal 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>
|
||||
55
fusion_repairs/views/repair_product_category_views.xml
Normal file
55
fusion_repairs/views/repair_product_category_views.xml
Normal 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>
|
||||
108
fusion_repairs/views/repair_service_plan_views.xml
Normal file
108
fusion_repairs/views/repair_service_plan_views.xml
Normal 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>
|
||||
59
fusion_repairs/views/repair_warranty_views.xml
Normal file
59
fusion_repairs/views/repair_warranty_views.xml
Normal 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>
|
||||
61
fusion_repairs/views/res_config_settings_views.xml
Normal file
61
fusion_repairs/views/res_config_settings_views.xml
Normal 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>
|
||||
29
fusion_repairs/views/res_partner_views.xml
Normal file
29
fusion_repairs/views/res_partner_views.xml
Normal 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>
|
||||
33
fusion_repairs/views/res_users_views.xml
Normal file
33
fusion_repairs/views/res_users_views.xml
Normal 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 & 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>
|
||||
31
fusion_repairs/views/sale_order_views.xml
Normal file
31
fusion_repairs/views/sale_order_views.xml
Normal 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>
|
||||
74
fusion_repairs/views/service_catalog_views.xml
Normal file
74
fusion_repairs/views/service_catalog_views.xml
Normal 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>
|
||||
49
fusion_repairs/views/technician_task_views.xml
Normal file
49
fusion_repairs/views/technician_task_views.xml
Normal 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>
|
||||
7
fusion_repairs/wizard/__init__.py
Normal file
7
fusion_repairs/wizard/__init__.py
Normal 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
|
||||
83
fusion_repairs/wizard/qr_sticker_wizard.py
Normal file
83
fusion_repairs/wizard/qr_sticker_wizard.py
Normal 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 ""
|
||||
39
fusion_repairs/wizard/qr_sticker_wizard_views.xml
Normal file
39
fusion_repairs/wizard/qr_sticker_wizard_views.xml
Normal 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>
|
||||
419
fusion_repairs/wizard/repair_intake_wizard.py
Normal file
419
fusion_repairs/wizard/repair_intake_wizard.py
Normal 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
|
||||
135
fusion_repairs/wizard/repair_intake_wizard_views.xml
Normal file
135
fusion_repairs/wizard/repair_intake_wizard_views.xml
Normal 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>
|
||||
654
fusion_repairs/wizard/repair_visit_report_wizard.py
Normal file
654
fusion_repairs/wizard/repair_visit_report_wizard.py
Normal 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',
|
||||
)
|
||||
138
fusion_repairs/wizard/repair_visit_report_wizard_views.xml
Normal file
138
fusion_repairs/wizard/repair_visit_report_wizard_views.xml
Normal 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>
|
||||
196
fusion_service_charges/__init__.py
Normal file
196
fusion_service_charges/__init__.py
Normal 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),
|
||||
)
|
||||
58
fusion_service_charges/__manifest__.py
Normal file
58
fusion_service_charges/__manifest__.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user